gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1 llimphi-hal/src/lib.rs Rust · 2975 siq'ikuna · 131.5 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

1//! llimphi-hal — Puente al Silicio.
2//!
3//! Aísla el motor del sistema operativo. Pinta en ventana Wayland/X11
4//! (vía `mirada` en producción, vía `winit` en dev) o framebuffer directo
5//! del kernel `wawa` (TODO). Trait `Surface` abstracto + struct `Hal`
6//! que posee Instance/Adapter/Device/Queue de wgpu.
7
8use std::sync::Arc;
9
10pub use raw_window_handle;
11pub use wgpu;
12pub use winit;
13
14use winit::window::Window;
15
16/// Errores al adquirir un frame de la superficie.
17#[derive(Debug)]
18pub enum SurfaceError {
19    Lost,
20    Outdated,
21    OutOfMemory,
22    Timeout,
23    /// La ventana está **tapada** (minimizada o detrás de otra) y el sistema no
24    /// entrega textura. Llegó con wgpu 29, que la distingue del resto.
25    ///
26    /// **No es una falla**, y por eso tiene variante propia en vez de caer en
27    /// [`SurfaceError::Other`]: quien lleva el bucle cuenta los acquire fallidos
28    /// y tras unos cuantos reconstruye el stack GPU entero. Una ventana tapada
29    /// unos segundos dispararía esa reconstrucción sin que haya nada roto. Lo
30    /// correcto es saltear el fotograma y volver a pedirlo.
31    Occluded,
32    /// La surface quedó **irrecuperable a nivel de dispositivo**: reconfigurar
33    /// y recrear la `wgpu::Surface` (reusando el mismo device/adapter) fallaron
34    /// ambos. El único remedio es reconstruir todo el stack GPU
35    /// (instance+adapter+device+queue+surface) y con él los recursos colgados
36    /// del device (renderer vello, compositores, texturas). El dueño del bucle
37    /// de render debe atrapar esta variante y rehacer su mundo GPU — ver
38    /// [`Hal::recreate_for_window`] (camino winit) y el runner de layer-shell de
39    /// `pata` (camino raw-surface). Distinta de [`SurfaceError::Lost`], que es
40    /// un hipo transitorio que la propia surface ya recuperó.
41    ///
42    /// Se dispara en el modo de falla de Iris Xe bajo mirada-DRM: al resetear
43    /// el output, la surface Vulkan cae con «Surface does not support the
44    /// adapter's queue family» y recrearla contra el mismo adapter no ayuda.
45    /// Antes esto tumbaba/loopeaba a los clientes (greeter/pata/shuma) o exigía
46    /// forzar el backend GL; la recuperación de device lo resuelve de forma
47    /// general (no atada a esta GPU).
48    DeviceLost,
49    Other(String),
50}
51
52impl std::fmt::Display for SurfaceError {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            Self::Lost => write!(f, "surface lost"),
56            Self::Outdated => write!(f, "surface outdated"),
57            Self::OutOfMemory => write!(f, "surface out of memory"),
58            Self::Timeout => write!(f, "surface timeout"),
59            Self::Occluded => write!(f, "ventana tapada (no es falla: saltear el cuadro)"),
60            Self::DeviceLost => write!(f, "device lost (full GPU-stack rebuild required)"),
61            Self::Other(s) => write!(f, "surface error: {s}"),
62        }
63    }
64}
65
66impl std::error::Error for SurfaceError {}
67
68/// Errores al construir Hal o crear una Surface.
69#[derive(Debug)]
70pub enum HalError {
71    NoAdapter,
72    RequestDevice(String),
73    CreateSurface(String),
74}
75
76impl std::fmt::Display for HalError {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            Self::NoAdapter => write!(f, "no GPU adapter available"),
80            Self::RequestDevice(s) => write!(f, "request_device failed: {s}"),
81            Self::CreateSurface(s) => write!(f, "create_surface failed: {s}"),
82        }
83    }
84}
85
86impl std::error::Error for HalError {}
87
88/// Superficie gráfica donde llimphi pinta.
89///
90/// Vello (rasterizador) emite a una textura intermedia con storage binding
91/// (la única forma portable: los formatos de swapchain no aceptan writes
92/// de compute shader en muchos adapters). En `present` se blittea la
93/// intermedia al swapchain real y se hace el flip.
94///
95/// Implementaciones:
96/// - [`WinitSurface`]: ventana Wayland/X11/Win32/Cocoa (dev + producción vía mirada).
97/// - [`RawSurface`]: `wgpu::Surface` creada desde handles raw (wlr-layer-shell).
98/// - `CanvasSurface` (sólo `wasm32`): un `<canvas>` del DOM — el camino navegador.
99/// - `WawaFramebufferSurface` (TODO): framebuffer directo del kernel wawa.
100pub trait Surface {
101    fn size(&self) -> (u32, u32);
102    fn resize(&mut self, width: u32, height: u32);
103    /// Adquiere la textura intermedia donde el raster pinta este frame.
104    fn acquire(&mut self) -> Result<Frame, SurfaceError>;
105    /// Blittea la intermedia al swapchain y la presenta.
106    fn present(&mut self, frame: Frame, hal: &Hal);
107}
108
109/// Frame en curso. `view()` devuelve la textura intermedia (Rgba8Unorm,
110/// STORAGE_BINDING) lista para que vello escriba sobre ella.
111pub struct Frame {
112    surface_texture: wgpu::SurfaceTexture,
113    surface_view: wgpu::TextureView,
114    intermediate_view: wgpu::TextureView,
115    /// Textura secundaria para la capa de overlay (menús/paleta/modal)
116    /// cuando hay contenido `gpu_paint` que la taparía. El overlay se
117    /// rasteriza aquí con fondo transparente y luego se compone con
118    /// alpha SOBRE la intermedia (que ya tiene UI + video). Ver
119    /// [`OverlayCompositor`] y el eventloop de `llimphi-ui`.
120    overlay_view: wgpu::TextureView,
121    width: u32,
122    height: u32,
123}
124
125impl Frame {
126    pub fn view(&self) -> &wgpu::TextureView {
127        &self.intermediate_view
128    }
129
130    /// Vista de la textura de overlay (mismo tamaño y formato que la
131    /// intermedia). Sólo se usa en el camino de compositing del overlay.
132    pub fn overlay_view(&self) -> &wgpu::TextureView {
133        &self.overlay_view
134    }
135
136    pub fn size(&self) -> (u32, u32) {
137        (self.width, self.height)
138    }
139}
140
141/// Estado wgpu compartido. Una instancia por proceso. `Device` y `Queue`
142/// son `Arc` internamente, así que clonar es barato.
143pub struct Hal {
144    pub instance: wgpu::Instance,
145    pub adapter: wgpu::Adapter,
146    pub device: wgpu::Device,
147    pub queue: wgpu::Queue,
148}
149
150/// Override del backend wgpu por la env `LLIMPHI_WGPU_BACKEND`. Valores:
151/// `gl`/`gles`/`opengl`/`egl` → GL; `vulkan`/`vk` → Vulkan; `primary` → PRIMARY;
152/// `all` → todos. Sin la env (o valor no reconocido) devuelve `None` y el caller
153/// usa su default (PRIMARY con fallback a todos).
154///
155/// **Para qué**: en Iris Xe bajo mirada-DRM, el backend Vulkan revienta al
156/// reconfigurar la surface tras un reset de output («Surface does not support the
157/// adapter's queue family»: falta `VK_EXT_physical_device_drm` y wgpu no puede
158/// re-matchear el adapter a la surface recreada). Como recrear la surface reusa el
159/// mismo device/adapter, el error persiste y los clientes layer (greeter/pata/
160/// shuma) entran en crash-loop al boot y en cada cambio de VT. Forzar GL
161/// (`LLIMPHI_WGPU_BACKEND=gl`) esquiva el problema —GL no tiene el concepto de
162/// queue family— al costo del bug de teardown de GL (SIGSEGV al cerrar, tolerable
163/// frente a un crash-loop que impide la sesión). Los clientes lo heredan del env
164/// del compositor. Ver memoria `pata-layer-closed-arje-drm`.
165fn backend_override() -> Option<wgpu::Backends> {
166    let v = std::env::var("LLIMPHI_WGPU_BACKEND").ok()?;
167    match v.trim().to_ascii_lowercase().as_str() {
168        "gl" | "gles" | "opengl" | "egl" => Some(wgpu::Backends::GL),
169        "vulkan" | "vk" => Some(wgpu::Backends::VULKAN),
170        "primary" => Some(wgpu::Backends::PRIMARY),
171        "all" => Some(wgpu::Backends::all()),
172        other => {
173            eprintln!("llimphi-hal: LLIMPHI_WGPU_BACKEND=«{other}» no reconocido; uso el default (PRIMARY)");
174            None
175        }
176    }
177}
178
179/// Un `RawDisplayHandle` presentado como `HasDisplayHandle`, para poder
180/// entregárselo a `wgpu::InstanceDescriptor`.
181///
182/// Hace falta en el camino de layer-shell (`pata`, la barra, el greeter), donde
183/// no hay una `winit::Window` que ya implemente el trait: lo único que tenemos
184/// es el handle crudo que el caller nos pasa.
185#[derive(Debug, Clone, Copy)]
186struct DisplayCrudo(raw_window_handle::RawDisplayHandle);
187
188// SAFETY: el contrato de `new_for_raw_surface` ya exige que los handles apunten
189// a objetos vivos durante toda la vida de la surface — es la misma promesa que
190// sostiene a `create_surface_unsafe`, y este envoltorio no la extiende, sólo la
191// transporta. `RawDisplayHandle` es un puntero opaco: `Send`/`Sync` valen porque
192// wgpu no lo desreferencia fuera del hilo del compositor.
193unsafe impl Send for DisplayCrudo {}
194unsafe impl Sync for DisplayCrudo {}
195
196impl raw_window_handle::HasDisplayHandle for DisplayCrudo {
197    fn display_handle(
198        &self,
199    ) -> Result<raw_window_handle::DisplayHandle<'_>, raw_window_handle::HandleError> {
200        // SAFETY: ver arriba — el handle sobrevive a este préstamo por contrato.
201        Ok(unsafe { raw_window_handle::DisplayHandle::borrow_raw(self.0) })
202    }
203}
204
205/// Construye una `wgpu::Instance` con los backends pedidos y, si se tiene, el
206/// display del sistema de ventanas.
207///
208/// **El display no es un adorno.** wgpu 29 lo sumó al `InstanceDescriptor` y su
209/// propia documentación es explícita: en GLES es *necesario para presentar,
210/// especialmente en Wayland*; en Vulkan/Metal/DX12 se ignora. Y GL no es un
211/// camino hipotético acá — es justo el fallback al que caen `Hal::new` y
212/// `new_for_raw_surface` cuando no hay adaptador PRIMARY. Antes de wgpu 29 no
213/// existía la opción y el backend GL se las arreglaba; pasarlo cuando lo
214/// tenemos es lo que evita descubrir en una VM vieja que la ventana no presenta.
215///
216/// Existe además porque en wgpu 29 `InstanceDescriptor` **perdió `Default`** (el
217/// display no tiene un valor razonable por omisión), así que los cuatro sitios
218/// que construían una instancia con `..Default::default()` necesitaban todos la
219/// misma reescritura.
220/// El display que lleva adentro un `SurfaceTargetUnsafe`, si es de los que
221/// traen handles crudos.
222///
223/// Las otras variantes del enum (las de puntero de plataforma) no exponen un
224/// display separado; ahí no hay nada que enhebrar y se devuelve `None`, que es
225/// exactamente el comportamiento de antes de wgpu 29.
226fn display_de(target: &wgpu::SurfaceTargetUnsafe) -> Option<DisplayCrudo> {
227    match target {
228        wgpu::SurfaceTargetUnsafe::RawHandle {
229            raw_display_handle, ..
230        } => raw_display_handle.map(DisplayCrudo),
231        _ => None,
232    }
233}
234
235/// Instancia **sin** display: los caminos donde no hay ventana todavía (el
236/// arranque diferido de escritorio) o donde no aplica (canvas del navegador).
237fn instancia(backends: wgpu::Backends) -> wgpu::Instance {
238    let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
239    desc.backends = backends;
240    wgpu::Instance::new(desc)
241}
242
243/// Instancia **con** display. Ver el porqué en [`instancia`].
244///
245/// Genérica en vez de tomar un `Box<dyn _>` porque wgpu **no reexporta** el
246/// trait `WgpuHasDisplayHandle` que pide ese campo: no hay forma de nombrarlo
247/// desde afuera. Sí hay una impl general para todo `HasDisplayHandle + Debug +
248/// Send + Sync + 'static`, así que se piden esos límites y la coerción al objeto
249/// de trait la resuelve el compilador al asignar el campo.
250fn instancia_con<D>(backends: wgpu::Backends, display: D) -> wgpu::Instance
251where
252    D: raw_window_handle::HasDisplayHandle + core::fmt::Debug + Send + Sync + 'static,
253{
254    let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
255    desc.backends = backends;
256    desc.display = Some(Box::new(display));
257    wgpu::Instance::new(desc)
258}
259
260/// Pista de memoria explícita por la env `LLIMPHI_WGPU_MEM`. `low`/`min`/`ram`
261/// → [`MemoryHints::MemoryUsage`] (el asignador de wgpu minimiza la huella:
262/// bloques de suballocación chicos, menos reserva especulativa). `perf` →
263/// [`MemoryHints::Performance`] (pools grandes, prioriza velocidad). `None` si
264/// la env no está o no se reconoce → el caller **autodetecta** (ver
265/// [`resolver_memory_hint`]). El manager de `pacha` la fija por contexto para
266/// equipos chicos; sin ella, la app decide sola.
267fn memory_hint_env() -> Option<wgpu::MemoryHints> {
268    match std::env::var("LLIMPHI_WGPU_MEM")
269        .map(|v| v.trim().to_ascii_lowercase())
270        .as_deref()
271    {
272        Ok("low") | Ok("min") | Ok("ram") | Ok("memoria") => Some(wgpu::MemoryHints::MemoryUsage),
273        Ok("perf") | Ok("performance") | Ok("velocidad") => Some(wgpu::MemoryHints::Performance),
274        Ok(other) if !other.is_empty() => {
275            eprintln!("llimphi-hal: LLIMPHI_WGPU_MEM=«{other}» no reconocido; autodetecto");
276            None
277        }
278        _ => None,
279    }
280}
281
282/// Resuelve la pista de memoria del device: la env manda; sin ella,
283/// **autoadapta** a la máquina. Un adapter de tipo `Cpu` (raster por software,
284/// llvmpipe / VM sin GPU) no tiene VRAM y compone contra la RAM del sistema, así
285/// que ahí `MemoryUsage` (mínima huella) es la elección correcta por defecto. Con
286/// una GPU real se mantiene `Performance` salvo que el env/pacha pida lo
287/// contrario. Es la contraparte a nivel de render de `pacha_core::Rendimiento`.
288/// Los límites con los que se pide el `Device`, en un solo sitio.
289///
290/// `Limits::default()` cubre los 5 storage buffers/stage que vello necesita;
291/// `downlevel_defaults()` sólo expone 4 y rompe el raster. Si el adapter no lo
292/// aguanta, `using_resolution` recorta lo recortable (texturas y buffers
293/// grandes) preservando los conteos mínimos.
294///
295/// Encima de eso, **el techo de textura se redondea hacia abajo a una potencia
296/// de dos**. No es cosmético: en el perfil hybrid, `vello_hybrid` dimensiona su
297/// textura de alfas con `max_texture_dimension_2d` y la direcciona por máscara y
298/// corrimiento (`i & (ancho-1)`, `i >> ancho.trailing_zeros()`), suponiendo sin
299/// verificar que es potencia de dos. El Mali-G57 del Redmi reporta 16383, cuyo
300/// `trailing_zeros()` es 0 — y ahí se cae TODA la cobertura parcial: cero
301/// antialiasing y cada forma perdiendo su primer tile, sin un solo error de
302/// validación. La versión documentada y medida de esto es
303/// `llimphi_hybrid::limites_seguros`; acá se repite la cuenta en vez de
304/// depender del crate para no arrastrar `vello_hybrid` a cada build de
305/// escritorio. Ver `02_ruway/llimphi/android/DIAGNOSTICO.md` §F8.
306///
307/// En una GPU que ya reporta potencia de dos —todas las de escritorio— es un
308/// no-op.
309fn limites_para(adapter: &wgpu::Adapter) -> wgpu::Limits {
310    let mut limites = wgpu::Limits::default().using_resolution(adapter.limits());
311    let techo = limites.max_texture_dimension_2d;
312    if techo > 0 && !techo.is_power_of_two() {
313        limites.max_texture_dimension_2d = 1 << (u32::BITS - 1 - techo.leading_zeros());
314    }
315    limites
316}
317
318fn resolver_memory_hint(adapter: &wgpu::Adapter) -> wgpu::MemoryHints {
319    if let Some(h) = memory_hint_env() {
320        return h;
321    }
322    match adapter.get_info().device_type {
323        wgpu::DeviceType::Cpu => wgpu::MemoryHints::MemoryUsage,
324        _ => wgpu::MemoryHints::Performance,
325    }
326}
327
328/// Preferencia de potencia por la env `LLIMPHI_WGPU_POWER`. `low`/`ahorro` →
329/// [`PowerPreference::LowPower`] (elige la iGPU integrada en equipos híbridos:
330/// menos consumo, menos RAM de driver, y de paso esquiva el problema de dmabuf
331/// cruzando dispositivos en Optimus). Sin la env → `HighPerformance`, el default
332/// de siempre. Una barra o un WM 2D nunca necesitan la dGPU discreta.
333fn power_preference_override() -> wgpu::PowerPreference {
334    match std::env::var("LLIMPHI_WGPU_POWER")
335        .map(|v| v.trim().to_ascii_lowercase())
336        .as_deref()
337    {
338        Ok("low") | Ok("ahorro") | Ok("integrada") => wgpu::PowerPreference::LowPower,
339        Ok("high") | Ok("alto") | Ok("discreta") => wgpu::PowerPreference::HighPerformance,
340        Ok(other) if !other.is_empty() => {
341            eprintln!("llimphi-hal: LLIMPHI_WGPU_POWER=«{other}» no reconocido; uso HighPerformance");
342            wgpu::PowerPreference::HighPerformance
343        }
344        _ => wgpu::PowerPreference::HighPerformance,
345    }
346}
347
348/// Precisión de color que se le pide al **swapchain**.
349///
350/// ## El techo honesto, antes de que nadie prometa HDR
351///
352/// El motor rasteriza **siempre** contra una textura intermedia
353/// [`INTERMEDIATE_FORMAT`] = `Rgba8Unorm`, porque `vello` lo exige
354/// (`Renderer::render_to_texture` pide justo ese formato con `STORAGE_BINDING`).
355/// O sea: **el contenido vello es 8 bits por canal, elijas el swapchain que
356/// elijas**. Pedir `Rgb10` con una UI normal sólo *upsamplea* 8 bits a 10 — no
357/// aparece rango que la escena no tenga. Eso no es HDR, es HDR falso, y el
358/// motivo por el que este enum NO tiene variantes `HdrPq`/`HdrHlg`.
359///
360/// Dos cosas sí son reales y por eso esto existe:
361///
362/// 1. **El camino GPU-directo** (`llimphi_raster::gpu::GpuBatch`) rasteriza a
363///    un target parametrizable con color de vértice `Float32x4`, así que un
364///    canvas denso que pinte por ahí **sí** carga más de 8 bits
365///    (`llimphi-raster/tests/gpu_hdr_precision`). Para que esa precisión llegue
366///    a la pantalla hace falta que el swapchain no la tire, y eso es lo que
367///    elige este enum.
368/// 2. **El banding de los degradés**: 10 bits en el swapchain suaviza las
369///    escaleras de un gradiente de fondo grande aunque la fuente sea de 8, que
370///    es un efecto chico pero visible y gratis.
371///
372/// ## Lo que wgpu 29 NO deja pedir
373///
374/// El espacio de color. `wgpu::SurfaceConfiguration` no tiene campo de
375/// colorspace y `wgpu::SurfaceCapabilities` no enumera espacios (verificado en
376/// `wgpu-types-29.0.3/src/surface.rs:136-153`: sólo `formats`, `present_modes`,
377/// `alpha_modes`, `usages`). Vulkan tiene `VK_EXT_swapchain_colorspace`, pero
378/// wgpu no lo expone. Sin ese campo **no se puede señalar PQ ni HLG**, así que
379/// un display HDR de verdad seguirá interpretando lo presentado como sRGB. Eso
380/// es un bloqueo de upstream, no una tarea pendiente de este crate.
381#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
382#[non_exhaustive]
383pub enum ColorTarget {
384    /// 8 bits por canal, sin sRGB de hardware. El default de siempre: el blit
385    /// desde la intermedia lineal preserva los valores tal cual.
386    #[default]
387    Srgb8,
388    /// 10 bits por canal (`Rgb10a2Unorm`). Cae a [`ColorTarget::Srgb8`] si la
389    /// surface no lo ofrece.
390    Rgb10,
391    /// Punto flotante de 16 bits (`Rgba16Float`). Es el único que preserva
392    /// valores > 1.0 del camino GPU-directo. Raro fuera de Vulkan con
393    /// colorspace extendido; cae a `Rgb10` y después a `Srgb8`.
394    Float16,
395}
396
397impl ColorTarget {
398    /// Los formatos que sirven a este objetivo, del preferido al tolerable.
399    fn candidatos(self) -> &'static [wgpu::TextureFormat] {
400        use wgpu::TextureFormat as F;
401        match self {
402            // Bgra8 primero: es el que casi todo compositor prefiere, y así se
403            // evita una conversión de canales en el present.
404            ColorTarget::Srgb8 => &[F::Bgra8Unorm, F::Rgba8Unorm],
405            ColorTarget::Rgb10 => &[F::Rgb10a2Unorm],
406            ColorTarget::Float16 => &[F::Rgba16Float],
407        }
408    }
409
410    /// La cadena de degradación: lo que se intenta si el objetivo no está.
411    fn cadena(self) -> &'static [ColorTarget] {
412        match self {
413            ColorTarget::Srgb8 => &[ColorTarget::Srgb8],
414            ColorTarget::Rgb10 => &[ColorTarget::Rgb10, ColorTarget::Srgb8],
415            ColorTarget::Float16 => {
416                &[ColorTarget::Float16, ColorTarget::Rgb10, ColorTarget::Srgb8]
417            }
418        }
419    }
420}
421
422/// Objetivo de color pedido por entorno: `LLIMPHI_COLOR = srgb8 | rgb10 |
423/// float16`. Mismo idiom que [`memory_hint_env`] y `LLIMPHI_PRESENT_MODE`:
424/// sirve para un A/B en la laptop con display real sin recompilar. Sin la env,
425/// [`ColorTarget::Srgb8`] — cero regresión contra el comportamiento histórico.
426fn color_target_env() -> ColorTarget {
427    match std::env::var("LLIMPHI_COLOR")
428        .map(|v| v.trim().to_ascii_lowercase())
429        .as_deref()
430    {
431        Ok("srgb8") | Ok("srgb") | Ok("8") => ColorTarget::Srgb8,
432        Ok("rgb10") | Ok("10") | Ok("10bit") => ColorTarget::Rgb10,
433        Ok("float16") | Ok("f16") | Ok("hdr") => ColorTarget::Float16,
434        Ok(other) if !other.is_empty() => {
435            eprintln!("llimphi-hal: LLIMPHI_COLOR=«{other}» no reconocido; uso srgb8");
436            ColorTarget::Srgb8
437        }
438        _ => ColorTarget::Srgb8,
439    }
440}
441
442/// Elige el formato del swapchain entre los que ofrece la surface, para el
443/// `objetivo` pedido. **Función pura** — por eso se puede certificar por texto
444/// sin GPU (`tests/formato.rs`).
445///
446/// Degrada por la cadena de [`ColorTarget::cadena`] y, si no encuentra nada
447/// conocido, devuelve `None` para que el caller aplique su propio último
448/// recurso (el primer formato que liste la surface, que es el preferido del
449/// backend).
450///
451/// Nunca elige un formato `*Srgb`: la intermedia es lineal y el blit debe
452/// pasar los valores tal cual; con un target sRGB el hardware aplicaría la
453/// curva por segunda vez y todo saldría lavado.
454pub fn elegir_formato(
455    disponibles: &[wgpu::TextureFormat],
456    objetivo: ColorTarget,
457) -> Option<wgpu::TextureFormat> {
458    objetivo
459        .cadena()
460        .iter()
461        .flat_map(|t| t.candidatos())
462        .find(|f| disponibles.contains(f))
463        .copied()
464}
465
466/// El formato de swapchain para esta surface: [`elegir_formato`] con el
467/// objetivo del entorno, y el primer formato de la surface como último recurso.
468fn formato_de_swapchain(caps: &wgpu::SurfaceCapabilities) -> wgpu::TextureFormat {
469    elegir_formato(&caps.formats, color_target_env()).unwrap_or(caps.formats[0])
470}
471
472/// Gancho de verificación: fuerza un [`SurfaceError::DeviceLost`] sintético cada
473/// N cuadros para poder ejercitar la recuperación de device SIN esperar un reset
474/// de output real (que en Iris Xe sólo pasa bajo mirada-DRM, no bajo KDE/kwin).
475///
476/// Activar con `LLIMPHI_SIMULATE_DEVICE_LOSS=N` (N≥1): cada N `acquire()`
477/// exitosos, el N-ésimo devuelve `DeviceLost` en vez de un `Frame`. El dueño del
478/// bucle debe reconstruir todo el stack GPU; si tras eso el render sigue vivo y
479/// sin fugas, la recuperación quedó certificada como texto (no screenshot). Sin
480/// la env, es un no-op de coste cero (un `load` atómico Relaxed). El contador es
481/// global al proceso: con varias surfaces reparte los trips entre ellas, lo cual
482/// es justo lo que queremos (probar que CUALQUIER surface dispara el rebuild).
483fn simulate_device_loss_trip() -> bool {
484    use std::sync::atomic::{AtomicU64, Ordering};
485    use std::sync::OnceLock;
486    static PERIOD: OnceLock<Option<u64>> = OnceLock::new();
487    let period = *PERIOD.get_or_init(|| {
488        std::env::var("LLIMPHI_SIMULATE_DEVICE_LOSS")
489            .ok()
490            .and_then(|v| v.trim().parse::<u64>().ok())
491            .filter(|n| *n >= 1)
492    });
493    let Some(period) = period else { return false };
494    static COUNT: AtomicU64 = AtomicU64::new(0);
495    // fetch_add devuelve el valor previo; el trip cae en el múltiplo de period.
496    (COUNT.fetch_add(1, Ordering::Relaxed) + 1) % period == 0
497}
498
499impl Hal {
500    /// Construye Hal pidiendo un adapter compatible con una surface dada
501    /// (recomendado: pasar `Some(&surface)` para garantizar que el adapter
502    /// elegido sabe presentar a esa surface).
503    pub async fn new(
504        compatible_surface: Option<&wgpu::Surface<'static>>,
505    ) -> Result<Self, HalError> {
506        Self::new_inner(compatible_surface, false).await
507    }
508
509    /// Construye un `Hal` **headless** (sin surface) prefiriendo el adapter de
510    /// **software** (llvmpipe/lavapipe, `deviceType == CPU`) cuando
511    /// `prefer_software`. Es el modo de los tests golden de `llimphi-test`: el
512    /// rasterizador por software es DETERMINISTA e idéntico host-a-host y en CI,
513    /// así el golden no queda atado a la GPU de quien lo generó. Si la máquina no
514    /// expone un fallback adapter (no hay lavapipe instalado), cae al adapter
515    /// normal — en ese caso el golden se certifica con tolerancia perceptual, no
516    /// bit-exacta. Nunca pasa `compatible_surface`: renderiza a textura, no
517    /// presenta a ventana.
518    ///
519    /// # Correrlo contra la GPU de verdad
520    ///
521    /// Con `LLIMPHI_TEST_GPU=1` se ignora `prefer_software` y se usa el adapter
522    /// normal. Existe porque **toda** la certificación headless de llimphi
523    /// —cachés, troceado, scissor, precisión float, el texto por atlas, los tres
524    /// caminos a píxeles— corre contra un rasterizador de software, y eso deja
525    /// una pregunta sin contestar: ¿el hardware hace lo mismo?
526    ///
527    /// No es una duda abstracta. El 2026-08-05 se midió el ahorro del `scissor`
528    /// en las dos: llvmpipe decía 97,9 %, la Iris Xe dice 57 %. Ahí la
529    /// diferencia era de *rendimiento*, que se esperaba; pero nada garantiza que
530    /// una diferencia de *resultado* no esté esperando en algún borde.
531    ///
532    /// El default sigue siendo el software, y debe seguir: es lo que hace que un
533    /// golden valga lo mismo en cualquier máquina y en CI. Esto es para preguntar
534    /// a mano, no para cambiar la referencia.
535    pub async fn new_headless(prefer_software: bool) -> Result<Self, HalError> {
536        let forzar_gpu = std::env::var("LLIMPHI_TEST_GPU").ok().as_deref() == Some("1");
537        if prefer_software && !forzar_gpu {
538            if let Ok(hal) = Self::new_inner(None, true).await {
539                return Ok(hal);
540            }
541            // Sin fallback adapter disponible: seguimos con el adapter normal; la
542            // tolerancia perceptual del arnés absorbe la diferencia de vendor.
543        }
544        Self::new_inner(None, false).await
545    }
546
547    async fn new_inner(
548        compatible_surface: Option<&wgpu::Surface<'static>>,
549        force_fallback_adapter: bool,
550    ) -> Result<Self, HalError> {
551        let opts = wgpu::RequestAdapterOptions {
552            power_preference: power_preference_override(),
553            force_fallback_adapter,
554            compatible_surface,
555        };
556        // Preferimos backends PRIMARY (Vulkan/Metal/DX12). El backend GL de
557        // wgpu sobre Mesa/Wayland tiene un bug de teardown: al soltar la
558        // instancia, `eglTerminate` marshalea sobre una conexión Wayland ya
559        // muerta (`wl_proxy_marshal`) y revienta con SIGSEGV. Con
560        // `Backends::all()` (el default), wgpu puede elegir GL aun habiendo
561        // Vulkan, y la app crashea al cerrar/teardown. Forzamos PRIMARY; si la
562        // máquina no tiene Vulkan/Metal/DX12 (VM vieja, etc.) caemos a todos
563        // los backends —incluido GL— para no dejarla sin gráficos. En el
564        // camino de escritorio `compatible_surface` es `None` (la surface se
565        // crea después contra esta misma instancia), así que cambiar de
566        // instancia aquí es seguro.
567        // Override por env (`LLIMPHI_WGPU_BACKEND`) o PRIMARY por default.
568        let forced = backend_override();
569        // Sin display: este camino no tiene ventana todavía (la surface se crea
570        // después, contra esta misma instancia). Los caminos que SÍ la tienen
571        // —`new_for_raw_surface`, `recreate_for_window`— la pasan.
572        let primary = instancia(forced.unwrap_or(wgpu::Backends::PRIMARY));
573        let (instance, adapter) = match primary.request_adapter(&opts).await {
574            Ok(a) => (primary, a),
575            // Con override explícito NO caemos a otros backends: el operador eligió.
576            Err(_) if forced.is_some() => return Err(HalError::NoAdapter),
577            Err(_) => {
578                let all = instancia(wgpu::Backends::all());
579                let a = all
580                    .request_adapter(&opts)
581                    .await
582                    .map_err(|_| HalError::NoAdapter)?;
583                (all, a)
584            }
585        };
586        let limits = limites_para(&adapter);
587        let (device, queue) = adapter
588            .request_device(&wgpu::DeviceDescriptor {
589                label: Some("llimphi-hal-device"),
590                required_features: wgpu::Features::empty(),
591                required_limits: limits,
592                memory_hints: resolver_memory_hint(&adapter),
593                experimental_features: wgpu::ExperimentalFeatures::default(),
594                trace: wgpu::Trace::Off,
595            })
596            .await
597            .map_err(|e| HalError::RequestDevice(e.to_string()))?;
598        instalar_handler_errores(&device);
599        Ok(Self {
600            instance,
601            adapter,
602            device,
603            queue,
604        })
605    }
606
607    /// Construye el `Hal` **y** una [`RawSurface`] a la vez, eligiendo el adaptador
608    /// **compatible con esa surface** — el dispositivo que el compositor sabe
609    /// presentar. Es el camino correcto para el backend layer-shell de `pata`.
610    ///
611    /// El problema que resuelve: en sistemas multi-GPU (Optimus), pedir el
612    /// adaptador sin pista de surface (`new(None)` con `HighPerformance`) puede
613    /// elegir la dGPU mientras el compositor compone en la iGPU → los dmabuf
614    /// cruzan dispositivos y `get_capabilities` devuelve 0 formatos (la surface
615    /// "no expone formatos"). Pasar `compatible_surface` ata el adaptador al
616    /// dispositivo del compositor. Como la surface hace falta ANTES de pedir el
617    /// adaptador, y `new` crea la instancia internamente, este constructor une los
618    /// dos pasos.
619    ///
620    /// `make_target` reconstruye el `SurfaceTargetUnsafe` cada vez que se llama
621    /// (los `RawHandle` son `Copy`): `create_surface_unsafe` consume el target y
622    /// puede que probemos dos instancias (PRIMARY y, si no hay adaptador, todos
623    /// los backends — el GL de Mesa/Wayland revienta en teardown, por eso PRIMARY
624    /// primero, igual que [`Hal::new`]).
625    ///
626    /// # Safety
627    /// Los handles que produce `make_target` deben apuntar a objetos Wayland/…
628    /// vivos durante toda la vida de la `RawSurface` devuelta.
629    pub async unsafe fn new_for_raw_surface(
630        make_target: impl Fn() -> wgpu::SurfaceTargetUnsafe,
631        width: u32,
632        height: u32,
633    ) -> Result<(Self, RawSurface), HalError> {
634        // PRIMARY (Vulkan/Metal/DX12) primero; si no hay adaptador compatible, a
635        // todos los backends recreando instancia y surface.
636        // Override por env (`LLIMPHI_WGPU_BACKEND`) o PRIMARY por default.
637        let forced = backend_override();
638        // El display del compositor sale del propio target: es el mismo handle
639        // que `create_surface_unsafe` va a usar. Enhebrarlo importa acá más que
640        // en ningún otro camino — éste es el de layer-shell sobre Wayland, y es
641        // el que cae a GL cuando no hay Vulkan.
642        let display = display_de(&make_target());
643        let primary = match display {
644            Some(d) => instancia_con(forced.unwrap_or(wgpu::Backends::PRIMARY), d),
645            None => instancia(forced.unwrap_or(wgpu::Backends::PRIMARY)),
646        };
647        let prim_surface = unsafe { primary.create_surface_unsafe(make_target()) }
648            .map_err(|e| HalError::CreateSurface(e.to_string()))?;
649        let prim_adapter = primary
650            .request_adapter(&wgpu::RequestAdapterOptions {
651                power_preference: power_preference_override(),
652                force_fallback_adapter: false,
653                compatible_surface: Some(&prim_surface),
654            })
655            .await;
656        let (instance, adapter, wgpu_surface) = match prim_adapter {
657            Ok(a) => (primary, a, prim_surface),
658            // Con override explícito NO caemos a otros backends: el operador eligió.
659            Err(_) if forced.is_some() => return Err(HalError::NoAdapter),
660            Err(_) => {
661                let all = match display {
662                    Some(d) => instancia_con(wgpu::Backends::all(), d),
663                    None => instancia(wgpu::Backends::all()),
664                };
665                let surface = unsafe { all.create_surface_unsafe(make_target()) }
666                    .map_err(|e| HalError::CreateSurface(e.to_string()))?;
667                let a = all
668                    .request_adapter(&wgpu::RequestAdapterOptions {
669                        power_preference: power_preference_override(),
670                        force_fallback_adapter: false,
671                        compatible_surface: Some(&surface),
672                    })
673                    .await
674                    .map_err(|_| HalError::NoAdapter)?;
675                (all, a, surface)
676            }
677        };
678        let limits = limites_para(&adapter);
679        let (device, queue) = adapter
680            .request_device(&wgpu::DeviceDescriptor {
681                label: Some("llimphi-hal-device"),
682                required_features: wgpu::Features::empty(),
683                required_limits: limits,
684                memory_hints: resolver_memory_hint(&adapter),
685                experimental_features: wgpu::ExperimentalFeatures::default(),
686                trace: wgpu::Trace::Off,
687            })
688            .await
689            .map_err(|e| HalError::RequestDevice(e.to_string()))?;
690        instalar_handler_errores(&device);
691        let hal = Self {
692            instance,
693            adapter,
694            device,
695            queue,
696        };
697        // Extraemos los raw handles del target para que la `RawSurface` pueda
698        // recrearse ante una pérdida (los `RawHandle` son `Copy`).
699        let (raw_display, raw_window) = match make_target() {
700            wgpu::SurfaceTargetUnsafe::RawHandle {
701                raw_display_handle,
702                raw_window_handle,
703            } => (
704                // wgpu 29 volvió opcional el display del target. `RawSurface`
705                // lo guarda sin envolver porque lo necesita SIEMPRE para
706                // recrearse; si el caller no lo trajo, no hay con qué recrear y
707                // es mejor decirlo acá que fallar a mitad de una recuperación.
708                raw_display_handle.ok_or_else(|| {
709                    HalError::CreateSurface(
710                        "new_for_raw_surface requiere un display handle en el target".into(),
711                    )
712                })?,
713                raw_window_handle,
714            ),
715            _ => {
716                return Err(HalError::CreateSurface(
717                    "new_for_raw_surface requiere SurfaceTargetUnsafe::RawHandle".into(),
718                ))
719            }
720        };
721        let surface =
722            RawSurface::from_surface(&hal, wgpu_surface, raw_display, raw_window, width, height)?;
723        Ok((hal, surface))
724    }
725
726    /// Reconstruye **todo el stack GPU** (instance+adapter+device+queue) y una
727    /// [`WinitSurface`] nueva sobre la MISMA ventana, eligiendo el adapter
728    /// compatible con la surface recreada. Es la recuperación real ante
729    /// [`SurfaceError::DeviceLost`] en el camino winit (apps `llimphi_ui::run`:
730    /// greeter/shuma/nada/cosmos…): reconfigurar o recrear sólo la surface no
731    /// alcanza cuando el adapter dejó de matchear (Iris Xe «queue family»); hay
732    /// que rehacer el device entero. Tras esto el caller debe reconstruir lo que
733    /// cuelga del device (renderer vello, compositores, texturas de la app)
734    /// contra el `Hal` devuelto.
735    ///
736    /// Es **surface-first** (crea la surface antes de pedir el adapter con
737    /// `compatible_surface`), lo correcto para garantizar el match adapter↔surface
738    /// tras la pérdida. Bloquea internamente (`pollster`) porque la recuperación
739    /// ocurre dentro de un handler de redraw síncrono. Respeta el override de
740    /// backend (`LLIMPHI_WGPU_BACKEND`) igual que [`Hal::new`].
741    ///
742    /// **No existe en wasm**, justamente por ese `pollster`: en el navegador no
743    /// hay hilos y el `Condvar` de `block_on` paniquea. La recuperación de
744    /// device en web tendría que ser asíncrona, como el arranque
745    /// ([`Hal::for_canvas`]); hoy no está, y que la función no exista convierte
746    /// el intento en un error de compilación en vez de un `unreachable` en
747    /// producción.
748    #[cfg(not(target_arch = "wasm32"))]
749    pub fn recreate_for_window(
750        window: Arc<Window>,
751    ) -> Result<(Self, WinitSurface), HalError> {
752        let forced = backend_override();
753        // La ventana YA implementa `HasDisplayHandle`, así que acá enhebrar el
754        // display no cuesta nada: se clona el `Arc`. Importa porque este camino
755        // también cae a `Backends::all()` —o sea GL— si el adapter PRIMARY no
756        // vuelve, y es la recuperación tras perder el device: presentar mal acá
757        // significa una ventana negra permanente.
758        let make_instance = |backends: wgpu::Backends| {
759            instancia_con(backends, window.clone())
760        };
761        let request_adapter = |inst: &wgpu::Instance, surf: &wgpu::Surface<'static>| {
762            pollster::block_on(inst.request_adapter(&wgpu::RequestAdapterOptions {
763                power_preference: power_preference_override(),
764                force_fallback_adapter: false,
765                compatible_surface: Some(surf),
766            }))
767        };
768        let primary = make_instance(forced.unwrap_or(wgpu::Backends::PRIMARY));
769        let prim_surface = primary
770            .create_surface(window.clone())
771            .map_err(|e| HalError::CreateSurface(e.to_string()))?;
772        let (instance, adapter, surface) = match request_adapter(&primary, &prim_surface) {
773            Ok(a) => (primary, a, prim_surface),
774            // Con override explícito NO caemos a otros backends: el operador eligió.
775            Err(_) if forced.is_some() => return Err(HalError::NoAdapter),
776            Err(_) => {
777                let all = make_instance(wgpu::Backends::all());
778                let surface = all
779                    .create_surface(window.clone())
780                    .map_err(|e| HalError::CreateSurface(e.to_string()))?;
781                let a = request_adapter(&all, &surface).map_err(|_| HalError::NoAdapter)?;
782                (all, a, surface)
783            }
784        };
785        let limits = limites_para(&adapter);
786        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
787            label: Some("llimphi-hal-device"),
788            required_features: wgpu::Features::empty(),
789            required_limits: limits,
790            memory_hints: resolver_memory_hint(&adapter),
791            experimental_features: wgpu::ExperimentalFeatures::default(),
792            trace: wgpu::Trace::Off,
793        }))
794        .map_err(|e| HalError::RequestDevice(e.to_string()))?;
795        instalar_handler_errores(&device);
796        let hal = Self {
797            instance,
798            adapter,
799            device,
800            queue,
801        };
802        let surface = WinitSurface::from_surface(&hal, window, surface)?;
803        Ok((hal, surface))
804    }
805}
806
807/// Surface basada en `winit::window::Window`. Mantiene una textura
808/// intermedia `Rgba8Unorm` con storage binding (donde pinta vello) y
809/// un `TextureBlitter` que la copia al swapchain al presentar.
810pub struct WinitSurface {
811    window: Arc<Window>,
812    /// `None` sólo entre [`WinitSurface::soltar_vk_surface`] y la creación de
813    /// la siguiente: en Android hay que DESTRUIR la VkSurface antes de crear
814    /// otra sobre la misma `ANativeWindow`.
815    surface: Option<wgpu::Surface<'static>>,
816    config: wgpu::SurfaceConfiguration,
817    device: wgpu::Device,
818    intermediate: wgpu::Texture,
819    intermediate_view: wgpu::TextureView,
820    /// Textura de la capa de overlay (ver [`Frame::overlay_view`]).
821    overlay: wgpu::Texture,
822    overlay_view: wgpu::TextureView,
823    blitter: wgpu::util::TextureBlitter,
824    /// La instancia con que se creó la surface: guardada para poder RECREARLA
825    /// desde la ventana ante un hipo del compositor/GPU (ver
826    /// [`WinitSurface::recreate`]) antes de escalar a un rebuild de device.
827    instance: wgpu::Instance,
828    /// El `present` descubrió que el device está perdido. Lo levanta
829    /// [`Surface::present`] y lo cobra el siguiente [`Surface::acquire`],
830    /// devolviendo [`SurfaceError::DeviceLost`] para que el runtime rehaga el
831    /// stack por el camino que ya existe. Ver el porqué en `present`.
832    device_perdido: bool,
833}
834
835/// El formato de la textura contra la que rasteriza **siempre** el motor.
836///
837/// Es público porque el rasterizador lo necesita y no puede deducirlo: un
838/// `TextureView` de wgpu no dice su formato. Y es un solo formato, no el del
839/// swapchain, porque [`Frame::view`] y [`Frame::overlay_view`] devuelven las
840/// texturas **intermedias**, no la de presentación — el blitter de
841/// `llimphi-hal` es quien traduce al formato que pida la pantalla. Eso le
842/// ahorra al rasterizador tener que compilar pipelines por formato, que en
843/// GPUs móviles es la operación cara (y en el Mali del teléfono, la peligrosa).
844pub const INTERMEDIATE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
845
846impl WinitSurface {
847    /// Constructor "feliz": crea la `wgpu::Surface` internamente.
848    /// Conveniente en desktop donde la secuencia normal es
849    /// `Hal::new(None)` → `WinitSurface::new(hal, window)`. **En Android
850    /// usar [`WinitSurface::from_surface`]** — allí la surface debe
851    /// existir antes del `request_adapter(compatible_surface=Some(...))`,
852    /// y crearla dos veces sobre la misma `ANativeWindow` falla con
853    /// `ERROR_NATIVE_WINDOW_IN_USE_KHR`.
854    pub fn new(hal: &Hal, window: Arc<Window>) -> Result<Self, HalError> {
855        let surface = hal
856            .instance
857            .create_surface(window.clone())
858            .map_err(|e| HalError::CreateSurface(e.to_string()))?;
859        Self::from_surface(hal, window, surface)
860    }
861
862    /// Constructor reutilizable: arma el `WinitSurface` envolviendo una
863    /// `wgpu::Surface` ya creada por el caller. Necesario en Android
864    /// porque el orden allí es:
865    ///
866    /// 1. `instance.create_surface(window)`
867    /// 2. `instance.request_adapter(compatible_surface=Some(&surface))`
868    /// 3. `adapter.request_device(...)`
869    /// 4. `WinitSurface::from_surface(hal, window, surface)`
870    ///
871    /// — no se puede dropear la surface entre 2 y 4 ni recrearla, porque
872    /// Android reserva la `ANativeWindow` por VkSurface y rechaza un
873    /// segundo `vkCreateAndroidSurfaceKHR` sobre la misma ventana.
874    pub fn from_surface(
875        hal: &Hal,
876        window: Arc<Window>,
877        surface: wgpu::Surface<'static>,
878    ) -> Result<Self, HalError> {
879        let size = window.inner_size();
880        let caps = surface.get_capabilities(&hal.adapter);
881        // Preferimos Bgra8Unorm o Rgba8Unorm (no sRGB) para que el blit
882        // desde la intermedia lineal preserve los valores tal cual. Más
883        // precisión sólo si la pide `LLIMPHI_COLOR` — ver [`ColorTarget`].
884        let format = formato_de_swapchain(&caps);
885        let config = wgpu::SurfaceConfiguration {
886            // El swapchain solo necesita render-attachment: vello no escribe
887            // directo, escribe a la intermedia y luego se blittea.
888            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
889            format,
890            width: size.width.max(1),
891            height: size.height.max(1),
892            present_mode: choose_present_mode(&caps),
893            desired_maximum_frame_latency: 2,
894            alpha_mode: caps.alpha_modes[0],
895            view_formats: vec![],
896        };
897        configure_checked(&hal.device, &surface, &config).map_err(HalError::CreateSurface)?;
898        let (intermediate, intermediate_view) =
899            create_intermediate(&hal.device, config.width, config.height);
900        let (overlay, overlay_view) =
901            create_intermediate(&hal.device, config.width, config.height);
902        let blitter = wgpu::util::TextureBlitter::new(&hal.device, format);
903        Ok(Self {
904            window,
905            surface: Some(surface),
906            config,
907            device: hal.device.clone(),
908            intermediate,
909            intermediate_view,
910            overlay,
911            overlay_view,
912            blitter,
913            instance: hal.instance.clone(),
914            device_perdido: false,
915        })
916    }
917
918    pub fn format(&self) -> wgpu::TextureFormat {
919        self.config.format
920    }
921
922    /// La `wgpu::Surface` viva. Sólo falta entre [`soltar_vk_surface`] y la
923    /// creación de la siguiente, ventana en la que nadie debe pedir frames.
924    ///
925    /// [`soltar_vk_surface`]: WinitSurface::soltar_vk_surface
926    #[inline]
927    fn vk(&self) -> &wgpu::Surface<'static> {
928        self.surface
929            .as_ref()
930            .expect("WinitSurface usada después de soltar su VkSurface")
931    }
932
933    /// **Destruye la `VkSurface` ya mismo**, sin tocar el resto.
934    ///
935    /// Existe por la regla de Android: **una sola `VkSurface` por
936    /// `ANativeWindow`**. Rehacer el stack GPU tras un `DeviceLost` crea la
937    /// surface nueva contra la MISMA ventana, así que si la vieja sigue viva
938    /// `vkCreateAndroidSurfaceKHR` falla y `wgpu-hal` lo trata como fatal
939    /// (`create_android_surface(...).expect("AndroidSurface failed")`,
940    /// `vulkan/instance.rs:525`) → abort. Medido en metal el 2026-07-28: el
941    /// rebuild moría justo ahí, porque `rebuild_gpu_after_device_loss` sólo
942    /// reemplazaba `state.surface` **después** de haber creado la nueva.
943    ///
944    /// Es responsabilidad del caller no pedir frames entre esta llamada y la
945    /// instalación de la surface nueva.
946    pub fn soltar_vk_surface(&mut self) {
947        self.surface = None;
948    }
949
950    /// Recrea la `wgpu::Surface` desde la MISMA ventana winit y la reconfigura.
951    /// Paso intermedio de recuperación: barato frente a rehacer el device.
952    /// Lo llama [`WinitSurface::acquire`] cuando `get_current_texture` devuelve
953    /// `Outdated`/`Lost` y reconfigurar no bastó. Si tras esto la surface sigue
954    /// rota (el adapter dejó de matchear — Iris Xe «queue family»), `acquire`
955    /// escala a [`SurfaceError::DeviceLost`] y el dueño del bucle reconstruye el
956    /// `Hal` entero con [`Hal::recreate_for_window`].
957    fn recreate(&mut self) -> Result<(), String> {
958        // La vieja MUERE antes de crear la nueva: en Android crear una segunda
959        // VkSurface sobre la misma ANativeWindow falla
960        // (`ERROR_NATIVE_WINDOW_IN_USE_KHR`), y en el resto de las plataformas
961        // soltarla primero no cuesta nada.
962        self.surface = None;
963        let surface = self
964            .instance
965            .create_surface(self.window.clone())
966            .map_err(|e| format!("create_surface: {e}"))?;
967        configure_checked(&self.device, &surface, &self.config)?;
968        self.surface = Some(surface);
969        Ok(())
970    }
971}
972
973/// Surface sobre una `wgpu::Surface` creada desde **handles raw** (sin
974/// `winit::Window`): la usa el backend `wlr-layer-shell` de `pata` para pintar
975/// en una *layer surface* de Wayland (barras/paneles al nivel de eww/waybar).
976/// Misma mecánica que [`WinitSurface`] —intermedia `Rgba8Unorm` + blit al
977/// swapchain— pero el tamaño se pasa explícito porque no hay ventana que
978/// consultar. La `wgpu::Surface` la crea el caller (típicamente con
979/// `instance.create_surface_unsafe` desde los punteros `wl_display`/`wl_surface`).
980pub struct RawSurface {
981    surface: wgpu::Surface<'static>,
982    config: wgpu::SurfaceConfiguration,
983    device: wgpu::Device,
984    intermediate: wgpu::Texture,
985    intermediate_view: wgpu::TextureView,
986    overlay: wgpu::Texture,
987    overlay_view: wgpu::TextureView,
988    blitter: wgpu::util::TextureBlitter,
989    /// La instancia + los raw handles del `wl_surface`/`wl_display`, guardados para
990    /// **recrear** la `wgpu::Surface` cuando queda irrecuperable (ver
991    /// [`RawSurface::recreate`]). Los `RawHandle` son `Copy` y apuntan a objetos
992    /// que el caller mantiene vivos toda la vida de la `RawSurface`.
993    instance: wgpu::Instance,
994    raw_display: raw_window_handle::RawDisplayHandle,
995    raw_window: raw_window_handle::RawWindowHandle,
996}
997
998impl RawSurface {
999    /// Envuelve una `wgpu::Surface` ya creada, con el tamaño físico inicial. Los
1000    /// `raw_display`/`raw_window` son los handles con que se creó la surface: se
1001    /// guardan para poder RECREARLA si el compositor la invalida (Iris Xe), ya que
1002    /// reconfigurar la misma surface no siempre recupera.
1003    pub fn from_surface(
1004        hal: &Hal,
1005        surface: wgpu::Surface<'static>,
1006        raw_display: raw_window_handle::RawDisplayHandle,
1007        raw_window: raw_window_handle::RawWindowHandle,
1008        width: u32,
1009        height: u32,
1010    ) -> Result<Self, HalError> {
1011        let caps = surface.get_capabilities(&hal.adapter);
1012        let info = hal.adapter.get_info();
1013        // Si la superficie no expone formatos, el compositor no la soporta por
1014        // este backend (Vulkan/GL WSI): error claro en vez de un panic por
1015        // indexar `formats[0]` sobre una lista vacía.
1016        let format = match elegir_formato(&caps.formats, color_target_env())
1017            .or_else(|| caps.formats.first().copied())
1018        {
1019            Some(f) => f,
1020            None => {
1021                return Err(HalError::CreateSurface(format!(
1022                    "la superficie no expone formatos (adapter {:?}/{:?}): el compositor no la soporta por {:?} WSI",
1023                    info.backend, info.device_type, info.backend
1024                )))
1025            }
1026        };
1027        // Para una layer surface (wlr-layer-shell) la transparencia es
1028        // crítica: la usamos para popovers/menús que pintan un panel chico y
1029        // dejan el resto transparente para ver el escritorio. La heurística
1030        // ingenua `caps.alpha_modes.first()` cae a veces en `Opaque` (el
1031        // compositor descarta alpha) — el clear TRANSPARENT se compone como
1032        // negro literal y el menú inicio sale como un cuadrón negro.
1033        //
1034        // Preferencia: PreMultiplied > PostMultiplied > Inherit > Auto >
1035        // Opaque. Los dos primeros componen alpha como esperamos; los dos
1036        // siguientes dejan que el compositor decida (típicamente respeta el
1037        // alpha del buffer ARGB); Opaque es el último recurso.
1038        let alpha_mode = {
1039            use wgpu::CompositeAlphaMode as Mode;
1040            let want = [
1041                Mode::PreMultiplied,
1042                Mode::PostMultiplied,
1043                Mode::Inherit,
1044                Mode::Auto,
1045            ];
1046            want.iter()
1047                .copied()
1048                .find(|m| caps.alpha_modes.contains(m))
1049                .or_else(|| caps.alpha_modes.first().copied())
1050                .unwrap_or(Mode::Auto)
1051        };
1052        let config = wgpu::SurfaceConfiguration {
1053            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1054            format,
1055            width: width.max(1),
1056            height: height.max(1),
1057            present_mode: choose_present_mode(&caps),
1058            desired_maximum_frame_latency: 2,
1059            alpha_mode,
1060            view_formats: vec![],
1061        };
1062        configure_checked(&hal.device, &surface, &config).map_err(HalError::CreateSurface)?;
1063        let (intermediate, intermediate_view) =
1064            create_intermediate(&hal.device, config.width, config.height);
1065        let (overlay, overlay_view) =
1066            create_intermediate(&hal.device, config.width, config.height);
1067        let blitter = wgpu::util::TextureBlitter::new(&hal.device, format);
1068        Ok(Self {
1069            surface,
1070            config,
1071            device: hal.device.clone(),
1072            intermediate,
1073            intermediate_view,
1074            overlay,
1075            overlay_view,
1076            blitter,
1077            instance: hal.instance.clone(),
1078            raw_display,
1079            raw_window,
1080        })
1081    }
1082
1083    pub fn format(&self) -> wgpu::TextureFormat {
1084        self.config.format
1085    }
1086
1087    /// Recrea la `wgpu::Surface` desde el raw handle del `wl_surface` y la
1088    /// reconfigura. Es la recuperación REAL cuando la surface quedó irrecuperable
1089    /// —p. ej. `configure` falla con «Surface does not support the adapter's queue
1090    /// family» tras un reset del compositor en Iris Xe—: reconfigurar la MISMA
1091    /// surface no basta, hay que crear una nueva. Devuelve el error si falla (el
1092    /// caller salta el frame y reintenta). Sin esto, la barra quedaba en un bucle
1093    /// de «surface perdida» hasta que se caía la conexión (Broken pipe).
1094    fn recreate(&mut self) -> Result<(), String> {
1095        let target = wgpu::SurfaceTargetUnsafe::RawHandle {
1096            // wgpu 29 volvió opcional el display del target.
1097            raw_display_handle: Some(self.raw_display),
1098            raw_window_handle: self.raw_window,
1099        };
1100        // SAFETY: los handles apuntan a objetos Wayland que el caller mantiene
1101        // vivos toda la vida de la `RawSurface` (mismo contrato que `from_surface`).
1102        let surface = unsafe { self.instance.create_surface_unsafe(target) }
1103            .map_err(|e| format!("create_surface_unsafe: {e}"))?;
1104        configure_checked(&self.device, &surface, &self.config)?;
1105        self.surface = surface;
1106        Ok(())
1107    }
1108}
1109
1110impl Surface for RawSurface {
1111    fn size(&self) -> (u32, u32) {
1112        (self.config.width, self.config.height)
1113    }
1114
1115    fn resize(&mut self, width: u32, height: u32) {
1116        let (w, h) = (width.max(1), height.max(1));
1117        // Sin cambio de tamaño NO reconfiguramos. El backend layer-shell de `pata`
1118        // llama a `resize` en cada cuadro (no tiene eventos de resize como winit);
1119        // reconfigurar el swapchain por cuadro lo reconstruye una y otra vez, y en
1120        // Vulkan WSI eso **destruye el `wl_buffer` recién presentado antes de que el
1121        // compositor lo componga** — wlroots lo tolera, smithay (mirada) no, y la
1122        // superficie queda en negro (el compositor ve `buffer=None`).
1123        if self.config.width == w && self.config.height == h {
1124            return;
1125        }
1126        self.config.width = w;
1127        self.config.height = h;
1128        if let Err(e) = configure_checked(&self.device, &self.surface, &self.config) {
1129            // Igual que en `acquire`: si reconfigurar no recupera, recreamos la
1130            // surface desde el raw handle antes de rendirnos.
1131            match self.recreate() {
1132                Ok(()) => eprintln!("llimphi-hal: surface RECREADA en resize (reconfigure falló: {e})"),
1133                Err(e2) => {
1134                    eprintln!("llimphi-hal: configure en resize falló ({e}); recrear también falló ({e2})")
1135                }
1136            }
1137        }
1138        let (tex, view) = create_intermediate(&self.device, self.config.width, self.config.height);
1139        self.intermediate = tex;
1140        self.intermediate_view = view;
1141        let (otex, oview) =
1142            create_intermediate(&self.device, self.config.width, self.config.height);
1143        self.overlay = otex;
1144        self.overlay_view = oview;
1145    }
1146
1147    fn acquire(&mut self) -> Result<Frame, SurfaceError> {
1148        // Gancho de verificación: DeviceLost sintético cada N cuadros
1149        // (`LLIMPHI_SIMULATE_DEVICE_LOSS`). No-op sin la env.
1150        if simulate_device_loss_trip() {
1151            return Err(SurfaceError::DeviceLost);
1152        }
1153        // wgpu 29 dejó de devolver `Result` acá: ahora es un enum de estado. La
1154        // traducción no es literal en un punto y conviene tenerlo claro —
1155        // `Suboptimal` **trae textura válida** y en wgpu 27 llegaba como `Ok`
1156        // con un `suboptimal: bool` al costado. Tratarlo como error sería tirar
1157        // un fotograma perfectamente bueno en cada cambio de tamaño.
1158        use wgpu::CurrentSurfaceTexture as Estado;
1159        let texture = match self.surface.get_current_texture() {
1160            Estado::Success(t) | Estado::Suboptimal(t) => t,
1161            // El backend layer-shell no tiene un evento de resize que reconfigure
1162            // el swapchain; si quedó obsoleto/perdido, lo reconstruimos aquí mismo
1163            // y reintentamos una vez. Sin esto el panel quedaría en negro para
1164            // siempre tras el primer `Outdated`.
1165            e @ (Estado::Outdated | Estado::Lost) => {
1166                // Reconfigurar CAPTURANDO: si la surface está realmente perdida,
1167                // `configure` emite un error de validación que, sin captura,
1168                // mataría el proceso (era el crash de pata). Aquí lo devolvemos
1169                // como `DeviceLost` y el caller reconstruye el stack GPU.
1170                if let Err(msg) = configure_checked(&self.device, &self.surface, &self.config) {
1171                    // Reconfigurar no recuperó (surface realmente perdida): RECREAR
1172                    // desde el raw handle contra el MISMO device. Si eso también
1173                    // falla, el device quedó irrecuperable (el adapter dejó de
1174                    // matchear — Iris Xe «queue family») → DeviceLost: el runner
1175                    // de pata rehace `Hal` + todas las surfaces/renderers.
1176                    match self.recreate() {
1177                        Ok(()) => eprintln!(
1178                            "llimphi-hal: surface RECREADA tras {e:?} (reconfigure falló: {msg})"
1179                        ),
1180                        Err(msg2) => {
1181                            eprintln!(
1182                                "llimphi-hal: reconfigurar tras {e:?} falló ({msg}); recrear también falló ({msg2}) → DeviceLost"
1183                            );
1184                            return Err(SurfaceError::DeviceLost);
1185                        }
1186                    }
1187                }
1188                // Sigue rota tras reconfigurar+recrear → device irrecuperable.
1189                match self.surface.get_current_texture() {
1190                    Estado::Success(t) | Estado::Suboptimal(t) => t,
1191                    _ => return Err(SurfaceError::DeviceLost),
1192                }
1193            }
1194            Estado::Timeout => return Err(SurfaceError::Timeout),
1195            // Ventana tapada (minimizada o detrás de otra). **No es una falla**,
1196            // y confundirla con una sale caro: el runtime cuenta acquires
1197            // fallidos y a los 8 rehace el stack GPU entero, así que una ventana
1198            // tapada ocho cuadros dispararía una reconstrucción por nada.
1199            Estado::Occluded => return Err(SurfaceError::Occluded),
1200            otro => return Err(SurfaceError::Other(format!("{otro:?}"))),
1201        };
1202        let surface_view = texture
1203            .texture
1204            .create_view(&wgpu::TextureViewDescriptor::default());
1205        Ok(Frame {
1206            surface_texture: texture,
1207            surface_view,
1208            intermediate_view: self.intermediate_view.clone(),
1209            overlay_view: self.overlay_view.clone(),
1210            width: self.config.width,
1211            height: self.config.height,
1212        })
1213    }
1214
1215    fn present(&mut self, frame: Frame, hal: &Hal) {
1216        let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1217            label: Some("llimphi-blit-raw"),
1218        });
1219        self.blitter.copy(
1220            &hal.device,
1221            &mut encoder,
1222            &frame.intermediate_view,
1223            &frame.surface_view,
1224        );
1225        hal.queue.submit(std::iter::once(encoder.finish()));
1226        frame.surface_texture.present();
1227    }
1228}
1229
1230// ---------------------------------------------------------------------------
1231//  El HAL del navegador: un `<canvas>` del DOM
1232// ---------------------------------------------------------------------------
1233
1234/// Surface sobre un `<canvas>` del DOM. Es la tercera implementación de
1235/// [`Surface`], hermana de [`WinitSurface`] y [`RawSurface`], y la única que
1236/// existe en `wasm32`: el resto del stack (raster, layout, text, compositor) ya
1237/// compila para wasm sin tocar nada.
1238///
1239/// Misma mecánica que las otras dos —intermedia `Rgba8Unorm` con storage
1240/// binding donde pinta vello + blit al swapchain— pero:
1241///
1242/// - La `wgpu::Surface` se crea con [`wgpu::SurfaceTarget::Canvas`], no desde
1243///   una ventana ni desde handles raw.
1244/// - **No se puede bloquear el hilo.** El main thread del navegador no admite
1245///   `pollster::block_on`; por eso el camino de construcción es
1246///   [`Hal::for_canvas`], `async`, y `configure` no usa error-scopes (que
1247///   resuelven de forma asíncrona). Ver [`configure_web`].
1248/// - El tamaño de la surface es el **backing store** del canvas
1249///   (`canvas.width`/`canvas.height`, en píxeles físicos), que NO es su tamaño
1250///   CSS. Confundirlos es el bug clásico del canvas borroso en pantallas HiDPI:
1251///   usar [`CanvasSurface::resize_to_css`] para sincronizarlos por
1252///   `devicePixelRatio`.
1253///
1254/// ## WebGPU sí, WebGL2 todavía no
1255///
1256/// El renderer full-GPU de vello escribe la intermedia por **compute shader**,
1257/// y eso exige `STORAGE_BINDING` — que WebGPU da y **WebGL2 no tiene**. Si el
1258/// navegador cae al backend GL, [`CanvasSurface::from_surface`] devuelve un
1259/// error explícito en vez de dejar que reviente como error de validación
1260/// críptico. El camino WebGL2 es `vello_hybrid` (feature `hybrid` de
1261/// `llimphi-raster`, CPU+GPU sin compute), que necesita su propia surface
1262/// porque no pinta a una storage texture. Pendiente.
1263#[cfg(target_arch = "wasm32")]
1264pub struct CanvasSurface {
1265    canvas: web_sys::HtmlCanvasElement,
1266    surface: wgpu::Surface<'static>,
1267    config: wgpu::SurfaceConfiguration,
1268    device: wgpu::Device,
1269    #[allow(dead_code)]
1270    intermediate: wgpu::Texture,
1271    intermediate_view: wgpu::TextureView,
1272    #[allow(dead_code)]
1273    overlay: wgpu::Texture,
1274    overlay_view: wgpu::TextureView,
1275    blitter: wgpu::util::TextureBlitter,
1276}
1277
1278#[cfg(target_arch = "wasm32")]
1279impl Hal {
1280    /// Construye el stack GPU entero atado a un `<canvas>`: instancia,
1281    /// surface, adapter compatible, device y [`CanvasSurface`]. **Es el
1282    /// equivalente web de [`Hal::recreate_for_window`]**, y por el mismo
1283    /// motivo hace surface-first: el adapter se pide con
1284    /// `compatible_surface: Some(&surface)` para que el navegador entregue uno
1285    /// que sepa presentar a ESE canvas.
1286    ///
1287    /// `async` sin alternativa: en el main thread del navegador no se puede
1288    /// bloquear. El caller típico es
1289    /// `wasm_bindgen_futures::spawn_local(async move { Hal::for_canvas(c).await })`.
1290    pub async fn for_canvas(
1291        canvas: web_sys::HtmlCanvasElement,
1292    ) -> Result<(Self, CanvasSurface), HalError> {
1293        // En web no aplica la preferencia PRIMARY-o-nada del escritorio (el bug
1294        // de teardown de EGL/Mesa no existe acá): `all()` deja que el navegador
1295        // ofrezca WebGPU y, si no hay, WebGL2 — el chequeo de storage textures
1296        // en `CanvasSurface::from_surface` es el que decide si sirve.
1297        // Sin display: en el navegador no hay uno que pasar — el display handle
1298        // del `InstanceDescriptor` es para el sistema de ventanas nativo.
1299        let instance = instancia(wgpu::Backends::all());
1300        let surface = instance
1301            .create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone()))
1302            .map_err(|e| HalError::CreateSurface(e.to_string()))?;
1303        let adapter = instance
1304            .request_adapter(&wgpu::RequestAdapterOptions {
1305                power_preference: power_preference_override(),
1306                force_fallback_adapter: false,
1307                compatible_surface: Some(&surface),
1308            })
1309            .await
1310            .map_err(|_| HalError::NoAdapter)?;
1311        let limits = limites_para(&adapter);
1312        let (device, queue) = adapter
1313            .request_device(&wgpu::DeviceDescriptor {
1314                label: Some("llimphi-hal-device-canvas"),
1315                required_features: wgpu::Features::empty(),
1316                required_limits: limits,
1317                memory_hints: resolver_memory_hint(&adapter),
1318                experimental_features: wgpu::ExperimentalFeatures::default(),
1319                trace: wgpu::Trace::Off,
1320            })
1321            .await
1322            .map_err(|e| HalError::RequestDevice(e.to_string()))?;
1323        instalar_handler_errores(&device);
1324        let hal = Self {
1325            instance,
1326            adapter,
1327            device,
1328            queue,
1329        };
1330        let surface = CanvasSurface::from_surface(&hal, canvas, surface)?;
1331        Ok((hal, surface))
1332    }
1333}
1334
1335#[cfg(target_arch = "wasm32")]
1336impl CanvasSurface {
1337    /// Envuelve una `wgpu::Surface` ya creada sobre el canvas. La ruta normal
1338    /// es [`Hal::for_canvas`], que hace las dos cosas en el orden correcto;
1339    /// este constructor existe para quien ya tiene un `Hal` armado.
1340    pub fn from_surface(
1341        hal: &Hal,
1342        canvas: web_sys::HtmlCanvasElement,
1343        surface: wgpu::Surface<'static>,
1344    ) -> Result<Self, HalError> {
1345        // Guarda de WebGL2: sin storage textures la intermedia de vello no se
1346        // puede crear y el error real aparecería mucho después, como validación
1347        // ilegible en medio del primer frame.
1348        let feats = hal.adapter.get_texture_format_features(INTERMEDIATE_FORMAT);
1349        if !feats
1350            .allowed_usages
1351            .contains(wgpu::TextureUsages::STORAGE_BINDING)
1352        {
1353            let info = hal.adapter.get_info();
1354            return Err(HalError::CreateSurface(format!(
1355                "el adapter del navegador ({:?}) no admite STORAGE_BINDING en {INTERMEDIATE_FORMAT:?}: \
1356                 es el camino WebGL2, y el renderer full-GPU de vello necesita compute. \
1357                 Hace falta un navegador con WebGPU, o el renderer `vello_hybrid` \
1358                 (feature `hybrid` de llimphi-raster), todavía sin surface propia.",
1359                info.backend
1360            )));
1361        }
1362        let caps = surface.get_capabilities(&hal.adapter);
1363        let format = match elegir_formato(&caps.formats, color_target_env())
1364            .or_else(|| caps.formats.first().copied())
1365        {
1366            Some(f) => f,
1367            None => {
1368                return Err(HalError::CreateSurface(
1369                    "el canvas no expone formatos de swapchain".to_string(),
1370                ))
1371            }
1372        };
1373        let config = wgpu::SurfaceConfiguration {
1374            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1375            format,
1376            // El backing store del canvas, en píxeles FÍSICOS. Ver
1377            // `resize_to_css` para el puente desde el tamaño CSS.
1378            width: canvas.width().max(1),
1379            height: canvas.height().max(1),
1380            present_mode: choose_present_mode(&caps),
1381            desired_maximum_frame_latency: 2,
1382            // En un canvas embebido en una página, `Opaque` es lo que
1383            // corresponde salvo que la app quiera dejar ver el fondo del
1384            // documento; el navegador lista primero lo que soporta.
1385            alpha_mode: caps.alpha_modes[0],
1386            view_formats: vec![],
1387        };
1388        configure_web(&hal.device, &surface, &config);
1389        let (intermediate, intermediate_view) =
1390            create_intermediate(&hal.device, config.width, config.height);
1391        let (overlay, overlay_view) = create_intermediate(&hal.device, config.width, config.height);
1392        let blitter = wgpu::util::TextureBlitter::new(&hal.device, format);
1393        Ok(Self {
1394            canvas,
1395            surface,
1396            config,
1397            device: hal.device.clone(),
1398            intermediate,
1399            intermediate_view,
1400            overlay,
1401            overlay_view,
1402            blitter,
1403        })
1404    }
1405
1406    pub fn format(&self) -> wgpu::TextureFormat {
1407        self.config.format
1408    }
1409
1410    pub fn canvas(&self) -> &web_sys::HtmlCanvasElement {
1411        &self.canvas
1412    }
1413
1414    /// Sincroniza el backing store del canvas con su tamaño **CSS** por
1415    /// `devicePixelRatio` y reconfigura el swapchain. Es lo que hay que llamar
1416    /// ante un `resize` de la ventana o un cambio de zoom/monitor.
1417    ///
1418    /// Sin esto el canvas se ve **borroso** en pantallas HiDPI: el navegador
1419    /// escala un backing store de 800×600 a 1600×1200 CSS px. Devuelve el
1420    /// tamaño físico resultante para que el caller alimente el layout (que
1421    /// trabaja en px físicos, igual que en escritorio).
1422    pub fn resize_to_css(&mut self) -> (u32, u32) {
1423        let dpr = web_sys::window().map(|w| w.device_pixel_ratio()).unwrap_or(1.0);
1424        let css_w = self.canvas.client_width().max(1) as f64;
1425        let css_h = self.canvas.client_height().max(1) as f64;
1426        let w = (css_w * dpr).round() as u32;
1427        let h = (css_h * dpr).round() as u32;
1428        self.resize(w, h);
1429        (self.config.width, self.config.height)
1430    }
1431}
1432
1433#[cfg(target_arch = "wasm32")]
1434impl Surface for CanvasSurface {
1435    fn size(&self) -> (u32, u32) {
1436        (self.config.width, self.config.height)
1437    }
1438
1439    fn resize(&mut self, width: u32, height: u32) {
1440        let (w, h) = (width.max(1), height.max(1));
1441        if self.config.width == w && self.config.height == h {
1442            return;
1443        }
1444        self.config.width = w;
1445        self.config.height = h;
1446        // El backing store del canvas manda: si no se actualiza, el navegador
1447        // escala el resultado y la surface queda desincronizada del DOM.
1448        self.canvas.set_width(w);
1449        self.canvas.set_height(h);
1450        configure_web(&self.device, &self.surface, &self.config);
1451        let (tex, view) = create_intermediate(&self.device, w, h);
1452        self.intermediate = tex;
1453        self.intermediate_view = view;
1454        let (otex, oview) = create_intermediate(&self.device, w, h);
1455        self.overlay = otex;
1456        self.overlay_view = oview;
1457    }
1458
1459    fn acquire(&mut self) -> Result<Frame, SurfaceError> {
1460        // Mismo enum que el camino nativo (wgpu 29 dejó de devolver `Result`):
1461        // `Suboptimal` **trae textura válida** y tirarla sería perder un cuadro
1462        // bueno en cada cambio de tamaño — que en el navegador es cada arrastre
1463        // del borde de la ventana.
1464        use wgpu::CurrentSurfaceTexture as Estado;
1465        let texture = match self.surface.get_current_texture() {
1466            Estado::Success(t) | Estado::Suboptimal(t) => t,
1467            // En el navegador no hay un `wl_surface` que recrear: la
1468            // recuperación es reconfigurar y reintentar una vez. Un
1469            // `Outdated` acá suele ser el canvas redimensionado entre frames.
1470            Estado::Outdated | Estado::Lost => {
1471                configure_web(&self.device, &self.surface, &self.config);
1472                match self.surface.get_current_texture() {
1473                    Estado::Success(t) | Estado::Suboptimal(t) => t,
1474                    _ => return Err(SurfaceError::DeviceLost),
1475                }
1476            }
1477            Estado::Timeout => return Err(SurfaceError::Timeout),
1478            // Pestaña en segundo plano / canvas fuera de vista. No es falla: el
1479            // runtime cuenta acquires fallidos y a los 8 rehace el stack GPU,
1480            // así que confundirla con una reconstruye todo por nada.
1481            Estado::Occluded => return Err(SurfaceError::Occluded),
1482            otro => return Err(SurfaceError::Other(format!("{otro:?}"))),
1483        };
1484        let surface_view = texture
1485            .texture
1486            .create_view(&wgpu::TextureViewDescriptor::default());
1487        Ok(Frame {
1488            surface_texture: texture,
1489            surface_view,
1490            intermediate_view: self.intermediate_view.clone(),
1491            overlay_view: self.overlay_view.clone(),
1492            width: self.config.width,
1493            height: self.config.height,
1494        })
1495    }
1496
1497    fn present(&mut self, frame: Frame, hal: &Hal) {
1498        let mut encoder = hal
1499            .device
1500            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1501                label: Some("llimphi-blit-canvas"),
1502            });
1503        self.blitter.copy(
1504            &hal.device,
1505            &mut encoder,
1506            &frame.intermediate_view,
1507            &frame.surface_view,
1508        );
1509        hal.queue.submit(std::iter::once(encoder.finish()));
1510        frame.surface_texture.present();
1511    }
1512}
1513
1514/// `configure` para el navegador: **sin** error-scope. Lo usan tanto
1515/// [`CanvasSurface`] como —vía [`configure_checked`]— [`WinitSurface`] y
1516/// [`RawSurface`] cuando el target es wasm.
1517///
1518/// [`configure_checked`] captura los errores de validación con
1519/// `push_error_scope`/`pop_error_scope`, y `pop_error_scope` devuelve un
1520/// futuro que en escritorio se resuelve con `pollster::block_on`. En el main
1521/// thread del navegador bloquear está prohibido —`block_on` espera sobre un
1522/// `Condvar` y en wasm32 eso **paniquea**, que en la página se ve como
1523/// `Uncaught RuntimeError: unreachable`— y esperar el scope de forma asíncrona
1524/// convertiría en `async` toda la cadena `resize`/`acquire`, que es sincrónica
1525/// por contrato del trait [`Surface`].
1526///
1527/// A cambio, un fallo de configuración acá no se detecta en el acto: aparece
1528/// en el siguiente `get_current_texture`, que [`CanvasSurface::acquire`]
1529/// maneja reconfigurando y, si no recupera, devolviendo `DeviceLost`. Los
1530/// errores no capturados igual salen por el handler de
1531/// [`instalar_handler_errores`], que loguea en vez de matar el proceso.
1532#[cfg(target_arch = "wasm32")]
1533fn configure_web(
1534    device: &wgpu::Device,
1535    surface: &wgpu::Surface<'static>,
1536    config: &wgpu::SurfaceConfiguration,
1537) {
1538    surface.configure(device, config);
1539}
1540
1541/// Elige el modo de presentación del swapchain.
1542///
1543/// Default: **Mailbox** si el driver lo expone, sino **Fifo**. La razón es
1544/// el cuelgue observado en las apps Llimphi (investigación 2026-05-30): con
1545/// `Fifo`/`AutoVsync`, `surface.get_current_texture()` **bloquea** esperando
1546/// el frame-callback del compositor Wayland — si el compositor no suelta un
1547/// buffer, el hilo del UI queda dormido (CPU baja, deadlock aparente).
1548/// `Mailbox` no bloquea (triple-buffer, descarta frames viejos), así que el
1549/// loop nunca se queda esperando al compositor. `Fifo` está garantizado por
1550/// spec como fallback.
1551///
1552/// Override por entorno para A/B sin recompilar (útil en la laptop con
1553/// display real): `LLIMPHI_PRESENT_MODE = fifo | mailbox | immediate |
1554/// fifo_relaxed`. Si el modo pedido no está soportado, se ignora y se aplica
1555/// el default.
1556fn choose_present_mode(caps: &wgpu::SurfaceCapabilities) -> wgpu::PresentMode {
1557    use wgpu::PresentMode::{Fifo, FifoRelaxed, Immediate, Mailbox};
1558    if let Ok(v) = std::env::var("LLIMPHI_PRESENT_MODE") {
1559        let want = match v.trim().to_ascii_lowercase().as_str() {
1560            "fifo" | "vsync" => Some(Fifo),
1561            "fifo_relaxed" | "fiforelaxed" => Some(FifoRelaxed),
1562            "mailbox" => Some(Mailbox),
1563            "immediate" | "novsync" => Some(Immediate),
1564            _ => None,
1565        };
1566        if let Some(m) = want {
1567            if caps.present_modes.contains(&m) {
1568                return m;
1569            }
1570        }
1571    }
1572    if caps.present_modes.contains(&Mailbox) {
1573        Mailbox
1574    } else {
1575        Fifo
1576    }
1577}
1578
1579/// Instala en el device un handler de errores wgpu **no capturados** que LOGUEA
1580/// en vez de dejar que el default de wgpu («Handling wgpu errors as fatal by
1581/// default») paniquee el proceso entero. Un glitch de device —p. ej. la pérdida
1582/// transitoria en un flap de VT bajo arje— NO debe tumbar la app (greeter, pata…):
1583/// la recuperación de surface (`Outdated`/`Lost`) rearma en el próximo frame. Los
1584/// errores de validación puntuales se siguen capturando con error-scopes aparte.
1585///
1586/// **En wasm el aviso va a la consola del navegador.** Con `eprintln!` a secas
1587/// el mensaje se escribe en un stderr que en una página no existe: un error de
1588/// validación durante el blit desaparecía sin rastro y el síntoma llegaba como
1589/// «el canvas no pinta», sin una sola línea que explicara por qué.
1590/// Se prendió el callback de device perdido de ALGÚN device de este proceso.
1591///
1592/// Global a propósito: todos los devices de una app llimphi salen del mismo
1593/// `Hal`, y perder uno es una condición del proceso, no de una surface. Lo lee
1594/// [`Surface::present`] —un `load` relajado por frame, costo cero— para **no
1595/// llamar a `wgpu::SurfaceTexture::present` sobre un device muerto**, que
1596/// paniquea de forma fatal e inatajable (ni error-scope ni handler lo salvan).
1597static DEVICE_PERDIDO_GLOBAL: std::sync::atomic::AtomicBool =
1598    std::sync::atomic::AtomicBool::new(false);
1599
1600fn instalar_handler_errores(device: &wgpu::Device) {
1601    // **Callback de device perdido.** Sin esto, perder el device se entera uno
1602    // por el DAÑO —`Parent device is lost` colgando de un `present`, o texturas
1603    // que nacen inválidas— y nunca por la CAUSA: wgpu tiene el motivo y el
1604    // mensaje del backend, y si nadie los pide se tiran. Costó una noche de
1605    // Android 2026-07-27: el device se perdía en el primer frame y el log sólo
1606    // mostraba los síntomas río abajo.
1607    device.set_device_lost_callback(|razon, msg| {
1608        DEVICE_PERDIDO_GLOBAL.store(true, std::sync::atomic::Ordering::Relaxed);
1609        let aviso = format!("llimphi-hal: DEVICE PERDIDO ({razon:?}): {msg}");
1610        #[cfg(target_arch = "wasm32")]
1611        web_sys::console::error_1(&aviso.as_str().into());
1612        #[cfg(not(target_arch = "wasm32"))]
1613        eprintln!("{aviso}");
1614    });
1615    device.on_uncaptured_error(std::sync::Arc::new(|e| {
1616        #[cfg(target_arch = "wasm32")]
1617        web_sys::console::error_1(
1618            &format!("llimphi-hal: error wgpu no capturado (no fatal, ignorado): {e}").into(),
1619        );
1620        #[cfg(not(target_arch = "wasm32"))]
1621        eprintln!("llimphi-hal: error wgpu no capturado (no fatal, ignorado): {e}");
1622    }));
1623}
1624
1625/// Configura el swapchain **capturando** los errores de validación de wgpu, en
1626/// vez de dejar que el handler por defecto («wgpu errors as fatal») paniquee el
1627/// proceso entero. Devuelve `Err(mensaje)` si la configuración falló — casi
1628/// siempre porque la surface se perdió (`SURFACE_LOST`, un hipo transitorio del
1629/// compositor/GPU): el caller decide (saltar el frame, marcar perdida) en vez de
1630/// morir. Robustez: una app no debe caerse por un glitch del surface (lo que
1631/// tumbaba a `pata` con «Surface does not support the adapter's queue family»).
1632///
1633/// Los errores de validación de wgpu son CPU-side: el scope los captura durante
1634/// la llamada, así que `pop_error_scope` resuelve sin bloquear contra la GPU.
1635///
1636/// **En wasm delega en [`configure_web`] y no captura nada.** «Sin bloquear
1637/// contra la GPU» no es lo mismo que «sin bloquear»: `pop_error_scope` sigue
1638/// devolviendo un futuro, y `pollster::block_on` lo espera sobre un `Condvar`.
1639/// En `wasm32-unknown-unknown` no hay hilos y ese `Condvar::wait` **paniquea**
1640/// (`std/sys/sync/condvar/no_threads.rs`), lo que en el navegador se ve como un
1641/// `Uncaught RuntimeError: unreachable`. Lo encontró correr el bucle web de
1642/// verdad en Chrome headless — ningún `cargo check` lo podía ver.
1643fn configure_checked(
1644    device: &wgpu::Device,
1645    surface: &wgpu::Surface<'static>,
1646    config: &wgpu::SurfaceConfiguration,
1647) -> Result<(), String> {
1648    #[cfg(target_arch = "wasm32")]
1649    {
1650        configure_web(device, surface, config);
1651        return Ok(());
1652    }
1653    #[cfg(not(target_arch = "wasm32"))]
1654    {
1655        // wgpu 29 mudó el `pop` al guard que devuelve `push_error_scope`, en
1656        // vez de tenerlo en el device. Es la misma captura de siempre.
1657        let alcance = device.push_error_scope(wgpu::ErrorFilter::Validation);
1658        surface.configure(device, config);
1659        match pollster::block_on(alcance.pop()) {
1660            Some(e) => Err(e.to_string()),
1661            None => Ok(()),
1662        }
1663    }
1664}
1665
1666/// Igual que [`create_intermediate`] pero **capturando** los errores de
1667/// validación de wgpu en vez de dejar que el `create_view` de la línea siguiente
1668/// se coma un error derivado.
1669///
1670/// El modo de falla que motivó esto (Android/Mali, 2026-07-27): si el
1671/// `create_texture` falla, wgpu devuelve una textura **inválida** en vez de
1672/// abortar, y el `create_view` inmediato emite `Texture with
1673/// 'llimphi-intermediate' label is invalid`. Ese error derivado **tapa el
1674/// original** — nunca se supo por qué falló la creación. Y según qué handler
1675/// tenga instalado el device, el mismo bug da dos síntomas opuestos: con el
1676/// default de wgpu («errors as fatal») la app **se cierra sola**; con
1677/// [`instalar_handler_errores`] el error se loguea, la textura inválida **se
1678/// instala igual**, y la app queda **viva pintando en negro para siempre**.
1679///
1680/// Capturando acá, el caller decide: conservar las texturas viejas (que
1681/// funcionaban) y seguir, en vez de quedarse con una rota.
1682fn create_intermediate_checked(
1683    device: &wgpu::Device,
1684    width: u32,
1685    height: u32,
1686) -> Result<(wgpu::Texture, wgpu::TextureView), String> {
1687    // Web: `pop_error_scope` devuelve un futuro y `block_on` lo espera sobre un
1688    // `Condvar` que en wasm paniquea — misma razón por la que `configure_checked`
1689    // delega en `configure_web`. Ahí seguimos sin capturar.
1690    #[cfg(target_arch = "wasm32")]
1691    {
1692        return Ok(create_intermediate(device, width, height));
1693    }
1694    #[cfg(not(target_arch = "wasm32"))]
1695    {
1696        let alcance = device.push_error_scope(wgpu::ErrorFilter::Validation);
1697        let par = create_intermediate(device, width, height);
1698        match pollster::block_on(alcance.pop()) {
1699            Some(e) => Err(e.to_string()),
1700            None => Ok(par),
1701        }
1702    }
1703}
1704
1705fn create_intermediate(
1706    device: &wgpu::Device,
1707    width: u32,
1708    height: u32,
1709) -> (wgpu::Texture, wgpu::TextureView) {
1710    let texture = device.create_texture(&wgpu::TextureDescriptor {
1711        label: Some("llimphi-intermediate"),
1712        size: wgpu::Extent3d {
1713            width,
1714            height,
1715            depth_or_array_layers: 1,
1716        },
1717        mip_level_count: 1,
1718        sample_count: 1,
1719        dimension: wgpu::TextureDimension::D2,
1720        format: INTERMEDIATE_FORMAT,
1721        // STORAGE_BINDING: vello escribe via compute shader.
1722        // TEXTURE_BINDING: el blitter la lee como sampler source.
1723        // RENDER_ATTACHMENT: render passes con clear-only (sin vello)
1724        //   también escriben aquí — desktop drivers lo tolerían sin este
1725        //   flag, Adreno con validación estricta rechaza el frame.
1726        usage: wgpu::TextureUsages::STORAGE_BINDING
1727            | wgpu::TextureUsages::TEXTURE_BINDING
1728            | wgpu::TextureUsages::RENDER_ATTACHMENT,
1729        view_formats: &[],
1730    });
1731    let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1732    (texture, view)
1733}
1734
1735/// Compositor de la capa de overlay: alpha-blittea una textura source (el
1736/// overlay rasterizado por vello sobre fondo transparente) SOBRE una textura
1737/// target (la intermedia, que ya tiene la UI principal + el video pintado por
1738/// `gpu_paint`). Resuelve el z-order: sin esto, el blit de `gpu_paint` (video)
1739/// queda encima de la capa vello del overlay y los menús se ven por debajo del
1740/// video.
1741///
1742/// Es un pase de pantalla completa (triángulo) que samplea el source y lo
1743/// emite con alpha-over. El factor de blend asume alpha **premultiplicado**
1744/// (lo que produce vello); si en pantalla los menús se ven con halos oscuros o
1745/// transparencia rara, exportar `LLIMPHI_OVERLAY_BLEND=straight` para usar
1746/// alpha recto sin recompilar.
1747pub struct OverlayCompositor {
1748    pipeline: wgpu::RenderPipeline,
1749    sampler: wgpu::Sampler,
1750    bind_layout: wgpu::BindGroupLayout,
1751    /// Uniform con el rect destino. Persistente: se reescribe por composite en
1752    /// vez de crear un buffer por cuadro.
1753    uniforms: wgpu::Buffer,
1754}
1755
1756impl OverlayCompositor {
1757    pub fn new(device: &wgpu::Device) -> Self {
1758        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1759            label: Some("llimphi-overlay-composite"),
1760            source: wgpu::ShaderSource::Wgsl(OVERLAY_COMPOSITE_WGSL.into()),
1761        });
1762        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1763            label: Some("llimphi-overlay-bgl"),
1764            entries: &[
1765                wgpu::BindGroupLayoutEntry {
1766                    binding: 0,
1767                    visibility: wgpu::ShaderStages::FRAGMENT,
1768                    ty: wgpu::BindingType::Texture {
1769                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
1770                        view_dimension: wgpu::TextureViewDimension::D2,
1771                        multisampled: false,
1772                    },
1773                    count: None,
1774                },
1775                wgpu::BindGroupLayoutEntry {
1776                    binding: 1,
1777                    visibility: wgpu::ShaderStages::FRAGMENT,
1778                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
1779                    count: None,
1780                },
1781                wgpu::BindGroupLayoutEntry {
1782                    binding: 2,
1783                    visibility: wgpu::ShaderStages::VERTEX,
1784                    ty: wgpu::BindingType::Buffer {
1785                        ty: wgpu::BufferBindingType::Uniform,
1786                        has_dynamic_offset: false,
1787                        min_binding_size: None,
1788                    },
1789                    count: None,
1790                },
1791            ],
1792        });
1793        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1794            label: Some("llimphi-overlay-pl"),
1795            bind_group_layouts: &[Some(&bind_layout)],
1796            immediate_size: 0,
1797        });
1798        // Alpha-over. `src_factor` distingue premultiplicado (One) de recto
1799        // (SrcAlpha); el resto es siempre OneMinusSrcAlpha.
1800        let straight = std::env::var("LLIMPHI_OVERLAY_BLEND")
1801            .map(|v| v.trim().eq_ignore_ascii_case("straight"))
1802            .unwrap_or(false);
1803        let color_src = if straight {
1804            wgpu::BlendFactor::SrcAlpha
1805        } else {
1806            wgpu::BlendFactor::One
1807        };
1808        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1809            label: Some("llimphi-overlay-pipe"),
1810            layout: Some(&pipeline_layout),
1811            vertex: wgpu::VertexState {
1812                module: &shader,
1813                entry_point: Some("vs"),
1814                buffers: &[],
1815                compilation_options: Default::default(),
1816            },
1817            fragment: Some(wgpu::FragmentState {
1818                module: &shader,
1819                entry_point: Some("fs"),
1820                targets: &[Some(wgpu::ColorTargetState {
1821                    format: INTERMEDIATE_FORMAT,
1822                    blend: Some(wgpu::BlendState {
1823                        color: wgpu::BlendComponent {
1824                            src_factor: color_src,
1825                            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
1826                            operation: wgpu::BlendOperation::Add,
1827                        },
1828                        alpha: wgpu::BlendComponent {
1829                            src_factor: wgpu::BlendFactor::One,
1830                            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
1831                            operation: wgpu::BlendOperation::Add,
1832                        },
1833                    }),
1834                    write_mask: wgpu::ColorWrites::ALL,
1835                })],
1836                compilation_options: Default::default(),
1837            }),
1838            primitive: wgpu::PrimitiveState::default(),
1839            depth_stencil: None,
1840            multisample: wgpu::MultisampleState::default(),
1841            multiview_mask: None,
1842            cache: None,
1843        });
1844        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
1845            label: Some("llimphi-overlay-sampler"),
1846            ..Default::default()
1847        });
1848        // 4 floats: rect destino en NDC (x0, y0, x1, y1). Ver el WGSL.
1849        let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
1850            label: Some("llimphi-overlay-uniforms"),
1851            size: 16,
1852            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1853            mapped_at_creation: false,
1854        });
1855        OverlayCompositor {
1856            pipeline,
1857            sampler,
1858            bind_layout,
1859            uniforms,
1860        }
1861    }
1862
1863    /// Compone `source` (overlay con fondo transparente) sobre `target` (la
1864    /// intermedia), preservando el contenido previo del target (LoadOp::Load)
1865    /// y mezclando con alpha. Graba un render pass en `encoder`.
1866    ///
1867    /// `source` cubre el target entero. Para pegar una textura **más chica** en
1868    /// su lugar del frame —una capa over rasterizada sólo en su bounding box—
1869    /// usar [`Self::composite_rect`].
1870    pub fn composite(
1871        &self,
1872        device: &wgpu::Device,
1873        queue: &wgpu::Queue,
1874        encoder: &mut wgpu::CommandEncoder,
1875        target: &wgpu::TextureView,
1876        source: &wgpu::TextureView,
1877    ) {
1878        // Rect destino = NDC completo: el quad cubre todo, igual que antes.
1879        self.composite_ndc(device, queue, encoder, target, source, [-1.0, 1.0, 1.0, -1.0]);
1880    }
1881
1882    /// Compone `source` sobre `target` **restringido a `dst`** (x, y, w, h en px
1883    /// del frame, con `viewport = (ancho, alto)`), estirando la textura fuente
1884    /// para llenarlo. Es el camino que hace rentable rasterizar la capa over en
1885    /// una textura del tamaño de lo que dibuja: sin esto habría que rasterizar
1886    /// —y componer— el frame entero para pintar una barra de 5 px de alto.
1887    pub fn composite_rect(
1888        &self,
1889        device: &wgpu::Device,
1890        queue: &wgpu::Queue,
1891        encoder: &mut wgpu::CommandEncoder,
1892        target: &wgpu::TextureView,
1893        source: &wgpu::TextureView,
1894        dst: [f32; 4],
1895        viewport: (u32, u32),
1896    ) {
1897        let (vw, vh) = (viewport.0.max(1) as f32, viewport.1.max(1) as f32);
1898        // px → NDC. La Y se invierte: en pantalla crece hacia abajo, en NDC
1899        // hacia arriba.
1900        let ndc = [
1901            dst[0] / vw * 2.0 - 1.0,
1902            1.0 - dst[1] / vh * 2.0,
1903            (dst[0] + dst[2]) / vw * 2.0 - 1.0,
1904            1.0 - (dst[1] + dst[3]) / vh * 2.0,
1905        ];
1906        self.composite_ndc(device, queue, encoder, target, source, ndc);
1907    }
1908
1909    fn composite_ndc(
1910        &self,
1911        device: &wgpu::Device,
1912        queue: &wgpu::Queue,
1913        encoder: &mut wgpu::CommandEncoder,
1914        target: &wgpu::TextureView,
1915        source: &wgpu::TextureView,
1916        ndc: [f32; 4],
1917    ) {
1918        let mut bytes = [0u8; 16];
1919        for (i, v) in ndc.iter().enumerate() {
1920            bytes[i * 4..(i + 1) * 4].copy_from_slice(&v.to_ne_bytes());
1921        }
1922        // El uniform se escribe por la queue; el pass de abajo lo lee en el
1923        // mismo submit.
1924        queue.write_buffer(&self.uniforms, 0, &bytes);
1925        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1926            label: Some("llimphi-overlay-bg"),
1927            layout: &self.bind_layout,
1928            entries: &[
1929                wgpu::BindGroupEntry {
1930                    binding: 0,
1931                    resource: wgpu::BindingResource::TextureView(source),
1932                },
1933                wgpu::BindGroupEntry {
1934                    binding: 1,
1935                    resource: wgpu::BindingResource::Sampler(&self.sampler),
1936                },
1937                wgpu::BindGroupEntry {
1938                    binding: 2,
1939                    resource: self.uniforms.as_entire_binding(),
1940                },
1941            ],
1942        });
1943        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1944            label: Some("llimphi-overlay-composite-pass"),
1945            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1946                view: target,
1947                resolve_target: None,
1948                depth_slice: None,
1949                ops: wgpu::Operations {
1950                    load: wgpu::LoadOp::Load,
1951                    store: wgpu::StoreOp::Store,
1952                },
1953            })],
1954            depth_stencil_attachment: None,
1955            timestamp_writes: None,
1956            occlusion_query_set: None,
1957            multiview_mask: None,
1958        });
1959        pass.set_pipeline(&self.pipeline);
1960        pass.set_bind_group(0, &bind_group, &[]);
1961        pass.draw(0..6, 0..1);
1962    }
1963}
1964
1965/// Pase que samplea la textura de overlay y la emite para alpha-over. Un quad
1966/// (dos triángulos) sobre el rect destino que viene por uniform en NDC —
1967/// `[-1, 1, 1, -1]` es el viewport entero, el caso clásico. La UV recorre la
1968/// textura fuente completa, así que una scratch del tamaño del bounding box de
1969/// la capa over aterriza justo donde va.
1970const OVERLAY_COMPOSITE_WGSL: &str = r#"
1971struct VsOut {
1972    @builtin(position) pos: vec4<f32>,
1973    @location(0) uv: vec2<f32>,
1974};
1975
1976// x0, y0 = esquina superior izquierda; x1, y1 = inferior derecha. En NDC.
1977struct Uniforms { dst: vec4<f32> };
1978@group(0) @binding(2) var<uniform> u: Uniforms;
1979
1980@vertex
1981fn vs(@builtin(vertex_index) vi: u32) -> VsOut {
1982    var uvs = array<vec2<f32>, 6>(
1983        vec2<f32>(0.0, 0.0),
1984        vec2<f32>(1.0, 0.0),
1985        vec2<f32>(1.0, 1.0),
1986        vec2<f32>(0.0, 0.0),
1987        vec2<f32>(1.0, 1.0),
1988        vec2<f32>(0.0, 1.0),
1989    );
1990    let q = uvs[vi];
1991    var out: VsOut;
1992    out.pos = vec4<f32>(
1993        mix(u.dst.x, u.dst.z, q.x),
1994        mix(u.dst.y, u.dst.w, q.y),
1995        0.0,
1996        1.0,
1997    );
1998    out.uv = q;
1999    return out;
2000}
2001
2002@group(0) @binding(0) var src_tex: texture_2d<f32>;
2003@group(0) @binding(1) var src_samp: sampler;
2004
2005@fragment
2006fn fs(in: VsOut) -> @location(0) vec4<f32> {
2007    return textureSample(src_tex, src_samp, in.uv);
2008}
2009"#;
2010
2011/// Gaussian backdrop blur sobre la intermediate (la textura donde vello pinta
2012/// la UI). El compositor empuja dos render passes separables (horizontal +
2013/// vertical) restringidas por scissor al rect del nodo `.backdrop_blur(sigma)`,
2014/// usando una textura scratch interna del mismo tamaño que la intermediate.
2015///
2016/// **Pipeline**: vs = triángulo grande full-screen (clip-space), fs = suma
2017/// ponderada de N samples a lo largo de `direction`, pesos Gauss `exp(-i²/2σ²)`.
2018/// El bind group lleva la textura source + sampler bilinear + UBO con
2019/// `(direction, pixel_size, sigma, radius)`. El scissor recorta el output al
2020/// rect del nodo; el resto del target queda intacto (LoadOp::Load).
2021///
2022/// **Coste**: una pasada por dirección por nodo blur, ~`2*radius+1` taps por
2023/// pixel del rect. Para `sigma=8` (radius=24), ~49 taps/pixel — barato si el
2024/// rect es pequeño (chrome), pesado si es full-screen. v1: sin cap dinámico,
2025/// se asume que el caller no abusa.
2026///
2027/// **Limitaciones v1**:
2028/// - Un scratch full-screen alocado por compositor; resize sigue al `Surface`.
2029/// - `radius` cap en 32 — sigmas > ~10 se ven menos suaves (clip de cola).
2030/// - Bordes del rect: clamp-to-edge (sampler) → los pixeles fuera del rect
2031///   que se muestrean en la cola del Gauss salen como espejo del borde. En
2032///   un viewport razonable la diferencia es invisible; documentado.
2033pub struct BlurCompositor {
2034    pipeline: wgpu::RenderPipeline,
2035    sampler: wgpu::Sampler,
2036    bind_layout: wgpu::BindGroupLayout,
2037    scratch: Option<BlurScratch>,
2038}
2039
2040struct BlurScratch {
2041    _texture: wgpu::Texture,
2042    view: wgpu::TextureView,
2043    width: u32,
2044    height: u32,
2045}
2046
2047/// Layout en GPU del UBO del blur. Debe coincidir con el `BlurParams` del WGSL.
2048/// Padding explícito al final para llegar a múltiplo de 16 bytes (alignment
2049/// estándar de uniformes en wgpu).
2050#[repr(C)]
2051#[derive(Clone, Copy)]
2052struct BlurUniforms {
2053    direction: [f32; 2],
2054    pixel_size: [f32; 2],
2055    sigma: f32,
2056    radius: f32,
2057    _pad: [f32; 2],
2058}
2059
2060const BLUR_UBO_SIZE: u64 = std::mem::size_of::<BlurUniforms>() as u64;
2061const BLUR_MAX_RADIUS: f32 = 32.0;
2062
2063impl BlurCompositor {
2064    pub fn new(device: &wgpu::Device) -> Self {
2065        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
2066            label: Some("llimphi-blur-shader"),
2067            source: wgpu::ShaderSource::Wgsl(BLUR_WGSL.into()),
2068        });
2069        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2070            label: Some("llimphi-blur-bgl"),
2071            entries: &[
2072                wgpu::BindGroupLayoutEntry {
2073                    binding: 0,
2074                    visibility: wgpu::ShaderStages::FRAGMENT,
2075                    ty: wgpu::BindingType::Texture {
2076                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
2077                        view_dimension: wgpu::TextureViewDimension::D2,
2078                        multisampled: false,
2079                    },
2080                    count: None,
2081                },
2082                wgpu::BindGroupLayoutEntry {
2083                    binding: 1,
2084                    visibility: wgpu::ShaderStages::FRAGMENT,
2085                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
2086                    count: None,
2087                },
2088                wgpu::BindGroupLayoutEntry {
2089                    binding: 2,
2090                    visibility: wgpu::ShaderStages::FRAGMENT,
2091                    ty: wgpu::BindingType::Buffer {
2092                        ty: wgpu::BufferBindingType::Uniform,
2093                        has_dynamic_offset: false,
2094                        min_binding_size: None,
2095                    },
2096                    count: None,
2097                },
2098            ],
2099        });
2100        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2101            label: Some("llimphi-blur-pl"),
2102            bind_group_layouts: &[Some(&bind_layout)],
2103            immediate_size: 0,
2104        });
2105        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2106            label: Some("llimphi-blur-pipe"),
2107            layout: Some(&pipeline_layout),
2108            vertex: wgpu::VertexState {
2109                module: &shader,
2110                entry_point: Some("vs"),
2111                buffers: &[],
2112                compilation_options: Default::default(),
2113            },
2114            fragment: Some(wgpu::FragmentState {
2115                module: &shader,
2116                entry_point: Some("fs"),
2117                targets: &[Some(wgpu::ColorTargetState {
2118                    format: INTERMEDIATE_FORMAT,
2119                    // El blur OVERWRITE el rect; no necesita alpha-over. El
2120                    // resultado del Gauss es opaco si los pixeles muestreados
2121                    // lo son (la intermediate tiene UI + background opaco).
2122                    blend: None,
2123                    write_mask: wgpu::ColorWrites::ALL,
2124                })],
2125                compilation_options: Default::default(),
2126            }),
2127            primitive: wgpu::PrimitiveState::default(),
2128            depth_stencil: None,
2129            multisample: wgpu::MultisampleState::default(),
2130            multiview_mask: None,
2131            cache: None,
2132        });
2133        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
2134            label: Some("llimphi-blur-sampler"),
2135            address_mode_u: wgpu::AddressMode::ClampToEdge,
2136            address_mode_v: wgpu::AddressMode::ClampToEdge,
2137            address_mode_w: wgpu::AddressMode::ClampToEdge,
2138            mag_filter: wgpu::FilterMode::Linear,
2139            min_filter: wgpu::FilterMode::Linear,
2140            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
2141            ..Default::default()
2142        });
2143        BlurCompositor {
2144            pipeline,
2145            sampler,
2146            bind_layout,
2147            scratch: None,
2148        }
2149    }
2150
2151    /// Aplica un blur Gaussiano sobre `target` en el rect dado (coords pixel
2152    /// del viewport). Si el rect cae fuera del viewport, no hace nada. Usa
2153    /// un scratch interno del mismo tamaño que el viewport — se aloca lazy y
2154    /// se reusa entre frames; se recrea si el viewport cambió.
2155    ///
2156    /// `sigma` controla el ancho del kernel. ~`σ=4` da "frosted glass" suave,
2157    /// `σ=16` un blur fuerte. El radius efectivo se cap a [`BLUR_MAX_RADIUS`].
2158    pub fn blur(
2159        &mut self,
2160        device: &wgpu::Device,
2161        queue: &wgpu::Queue,
2162        encoder: &mut wgpu::CommandEncoder,
2163        target: &wgpu::TextureView,
2164        viewport: (u32, u32),
2165        rect: (f32, f32, f32, f32),
2166        sigma: f32,
2167    ) {
2168        let (vw, vh) = viewport;
2169        if vw == 0 || vh == 0 || sigma <= 0.0 {
2170            return;
2171        }
2172        let (rx, ry, rw, rh) = rect;
2173        // Clamp scissor al viewport (un rect fuera del viewport pifia el
2174        // RenderPass).
2175        let x0 = rx.max(0.0) as u32;
2176        let y0 = ry.max(0.0) as u32;
2177        let x1 = (rx + rw).min(vw as f32).max(0.0) as u32;
2178        let y1 = (ry + rh).min(vh as f32).max(0.0) as u32;
2179        if x1 <= x0 || y1 <= y0 {
2180            return;
2181        }
2182        let scissor = (x0, y0, x1 - x0, y1 - y0);
2183
2184        // Scratch del tamaño del viewport. Si cambió, recrear.
2185        let need_new = match &self.scratch {
2186            Some(s) => s.width != vw || s.height != vh,
2187            None => true,
2188        };
2189        if need_new {
2190            let texture = device.create_texture(&wgpu::TextureDescriptor {
2191                label: Some("llimphi-blur-scratch"),
2192                size: wgpu::Extent3d {
2193                    width: vw,
2194                    height: vh,
2195                    depth_or_array_layers: 1,
2196                },
2197                mip_level_count: 1,
2198                sample_count: 1,
2199                dimension: wgpu::TextureDimension::D2,
2200                format: INTERMEDIATE_FORMAT,
2201                usage: wgpu::TextureUsages::TEXTURE_BINDING
2202                    | wgpu::TextureUsages::RENDER_ATTACHMENT,
2203                view_formats: &[],
2204            });
2205            let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
2206            self.scratch = Some(BlurScratch {
2207                _texture: texture,
2208                view,
2209                width: vw,
2210                height: vh,
2211            });
2212        }
2213        let scratch_view = &self.scratch.as_ref().expect("scratch creado arriba").view;
2214
2215        let radius = (sigma * 3.0).ceil().min(BLUR_MAX_RADIUS);
2216        let pixel_size = [1.0 / vw as f32, 1.0 / vh as f32];
2217        let ubo_h_data = BlurUniforms {
2218            direction: [1.0, 0.0],
2219            pixel_size,
2220            sigma,
2221            radius,
2222            _pad: [0.0, 0.0],
2223        };
2224        let ubo_v_data = BlurUniforms {
2225            direction: [0.0, 1.0],
2226            pixel_size,
2227            sigma,
2228            radius,
2229            _pad: [0.0, 0.0],
2230        };
2231        // UBOs por llamada (ver nota en `ColorFilterCompositor::apply`): varios
2232        // blurs en el mismo submit con sigmas distintos no deben aliasar un UBO
2233        // compartido (ganaría el último). Buffers frescos por llamada (32 bytes).
2234        let ubo_h = device.create_buffer(&wgpu::BufferDescriptor {
2235            label: Some("llimphi-blur-ubo-h"),
2236            size: BLUR_UBO_SIZE,
2237            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2238            mapped_at_creation: false,
2239        });
2240        let ubo_v = device.create_buffer(&wgpu::BufferDescriptor {
2241            label: Some("llimphi-blur-ubo-v"),
2242            size: BLUR_UBO_SIZE,
2243            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2244            mapped_at_creation: false,
2245        });
2246        queue.write_buffer(&ubo_h, 0, bytemuck_cast(&ubo_h_data));
2247        queue.write_buffer(&ubo_v, 0, bytemuck_cast(&ubo_v_data));
2248
2249        // Pass 1: target → scratch (horizontal).
2250        let bg_h = device.create_bind_group(&wgpu::BindGroupDescriptor {
2251            label: Some("llimphi-blur-bg-h"),
2252            layout: &self.bind_layout,
2253            entries: &[
2254                wgpu::BindGroupEntry {
2255                    binding: 0,
2256                    resource: wgpu::BindingResource::TextureView(target),
2257                },
2258                wgpu::BindGroupEntry {
2259                    binding: 1,
2260                    resource: wgpu::BindingResource::Sampler(&self.sampler),
2261                },
2262                wgpu::BindGroupEntry {
2263                    binding: 2,
2264                    resource: ubo_h.as_entire_binding(),
2265                },
2266            ],
2267        });
2268        {
2269            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2270                label: Some("llimphi-blur-pass-h"),
2271                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2272                    view: scratch_view,
2273                    resolve_target: None,
2274                    depth_slice: None,
2275                    ops: wgpu::Operations {
2276                        // No nos importa qué hay fuera del scissor: el segundo
2277                        // pase sólo lee dentro del scissor también.
2278                        load: wgpu::LoadOp::Load,
2279                        store: wgpu::StoreOp::Store,
2280                    },
2281                })],
2282                depth_stencil_attachment: None,
2283                timestamp_writes: None,
2284                occlusion_query_set: None,
2285                multiview_mask: None,
2286            });
2287            pass.set_pipeline(&self.pipeline);
2288            pass.set_bind_group(0, &bg_h, &[]);
2289            pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
2290            pass.draw(0..3, 0..1);
2291        }
2292
2293        // Pass 2: scratch → target (vertical), preservando lo fuera del scissor.
2294        let bg_v = device.create_bind_group(&wgpu::BindGroupDescriptor {
2295            label: Some("llimphi-blur-bg-v"),
2296            layout: &self.bind_layout,
2297            entries: &[
2298                wgpu::BindGroupEntry {
2299                    binding: 0,
2300                    resource: wgpu::BindingResource::TextureView(scratch_view),
2301                },
2302                wgpu::BindGroupEntry {
2303                    binding: 1,
2304                    resource: wgpu::BindingResource::Sampler(&self.sampler),
2305                },
2306                wgpu::BindGroupEntry {
2307                    binding: 2,
2308                    resource: ubo_v.as_entire_binding(),
2309                },
2310            ],
2311        });
2312        {
2313            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2314                label: Some("llimphi-blur-pass-v"),
2315                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2316                    view: target,
2317                    resolve_target: None,
2318                    depth_slice: None,
2319                    ops: wgpu::Operations {
2320                        load: wgpu::LoadOp::Load,
2321                        store: wgpu::StoreOp::Store,
2322                    },
2323                })],
2324                depth_stencil_attachment: None,
2325                timestamp_writes: None,
2326                occlusion_query_set: None,
2327                multiview_mask: None,
2328            });
2329            pass.set_pipeline(&self.pipeline);
2330            pass.set_bind_group(0, &bg_v, &[]);
2331            pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
2332            pass.draw(0..3, 0..1);
2333        }
2334    }
2335}
2336
2337/// Aplica una **matriz de color 4×5** (CSS `filter: brightness/contrast/
2338/// grayscale/sepia/saturate/invert/hue-rotate/opacity`) sobre un rect de la
2339/// intermediate. Espejo de [`BlurCompositor`] pero con un fragment shader que
2340/// multiplica cada píxel por la matriz: `out = M·rgba + bias`, clampeado a
2341/// `[0,1]`. Dos pases (target→scratch aplicando la matriz, scratch→target
2342/// copia identidad) por la misma razón que el blur: un render pass no puede
2343/// leer y escribir la misma textura. Fase 7.1233.
2344pub struct ColorFilterCompositor {
2345    pipeline: wgpu::RenderPipeline,
2346    sampler: wgpu::Sampler,
2347    bind_layout: wgpu::BindGroupLayout,
2348    scratch: Option<BlurScratch>,
2349}
2350
2351/// UBO de la matriz de color. 5 `vec4` (filas R/G/B/A + bias) = 80 bytes,
2352/// múltiplo de 16. Debe coincidir con `ColorParams` del WGSL.
2353#[repr(C)]
2354#[derive(Clone, Copy)]
2355struct ColorUniforms {
2356    r: [f32; 4],
2357    g: [f32; 4],
2358    b: [f32; 4],
2359    a: [f32; 4],
2360    bias: [f32; 4],
2361}
2362
2363const COLOR_UBO_SIZE: u64 = std::mem::size_of::<ColorUniforms>() as u64;
2364
2365/// La matriz identidad (copia sin cambios), usada en el segundo pase.
2366const COLOR_IDENTITY: ColorUniforms = ColorUniforms {
2367    r: [1.0, 0.0, 0.0, 0.0],
2368    g: [0.0, 1.0, 0.0, 0.0],
2369    b: [0.0, 0.0, 1.0, 0.0],
2370    a: [0.0, 0.0, 0.0, 1.0],
2371    bias: [0.0, 0.0, 0.0, 0.0],
2372};
2373
2374impl ColorFilterCompositor {
2375    pub fn new(device: &wgpu::Device) -> Self {
2376        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
2377            label: Some("llimphi-color-filter-shader"),
2378            source: wgpu::ShaderSource::Wgsl(COLOR_WGSL.into()),
2379        });
2380        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2381            label: Some("llimphi-color-filter-bgl"),
2382            entries: &[
2383                wgpu::BindGroupLayoutEntry {
2384                    binding: 0,
2385                    visibility: wgpu::ShaderStages::FRAGMENT,
2386                    ty: wgpu::BindingType::Texture {
2387                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
2388                        view_dimension: wgpu::TextureViewDimension::D2,
2389                        multisampled: false,
2390                    },
2391                    count: None,
2392                },
2393                wgpu::BindGroupLayoutEntry {
2394                    binding: 1,
2395                    visibility: wgpu::ShaderStages::FRAGMENT,
2396                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
2397                    count: None,
2398                },
2399                wgpu::BindGroupLayoutEntry {
2400                    binding: 2,
2401                    visibility: wgpu::ShaderStages::FRAGMENT,
2402                    ty: wgpu::BindingType::Buffer {
2403                        ty: wgpu::BufferBindingType::Uniform,
2404                        has_dynamic_offset: false,
2405                        min_binding_size: None,
2406                    },
2407                    count: None,
2408                },
2409            ],
2410        });
2411        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2412            label: Some("llimphi-color-filter-pl"),
2413            bind_group_layouts: &[Some(&bind_layout)],
2414            immediate_size: 0,
2415        });
2416        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2417            label: Some("llimphi-color-filter-pipe"),
2418            layout: Some(&pipeline_layout),
2419            vertex: wgpu::VertexState {
2420                module: &shader,
2421                entry_point: Some("vs"),
2422                buffers: &[],
2423                compilation_options: Default::default(),
2424            },
2425            fragment: Some(wgpu::FragmentState {
2426                module: &shader,
2427                entry_point: Some("fs"),
2428                targets: &[Some(wgpu::ColorTargetState {
2429                    format: INTERMEDIATE_FORMAT,
2430                    // OVERWRITE el rect, igual que el blur — el resultado de la
2431                    // matriz reemplaza el píxel.
2432                    blend: None,
2433                    write_mask: wgpu::ColorWrites::ALL,
2434                })],
2435                compilation_options: Default::default(),
2436            }),
2437            primitive: wgpu::PrimitiveState::default(),
2438            depth_stencil: None,
2439            multisample: wgpu::MultisampleState::default(),
2440            multiview_mask: None,
2441            cache: None,
2442        });
2443        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
2444            label: Some("llimphi-color-filter-sampler"),
2445            address_mode_u: wgpu::AddressMode::ClampToEdge,
2446            address_mode_v: wgpu::AddressMode::ClampToEdge,
2447            address_mode_w: wgpu::AddressMode::ClampToEdge,
2448            mag_filter: wgpu::FilterMode::Nearest,
2449            min_filter: wgpu::FilterMode::Nearest,
2450            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
2451            ..Default::default()
2452        });
2453        ColorFilterCompositor {
2454            pipeline,
2455            sampler,
2456            bind_layout,
2457            scratch: None,
2458        }
2459    }
2460
2461    /// Aplica la matriz de color `matrix` (4×5 row-major: por fila
2462    /// `[c0, c1, c2, c3, bias]`, salida R/G/B/A) sobre `target` en el rect dado
2463    /// (coords pixel del viewport). Fuera del viewport no hace nada. Usa un
2464    /// scratch del tamaño del viewport (lazy, reusado entre frames).
2465    pub fn apply(
2466        &mut self,
2467        device: &wgpu::Device,
2468        queue: &wgpu::Queue,
2469        encoder: &mut wgpu::CommandEncoder,
2470        target: &wgpu::TextureView,
2471        viewport: (u32, u32),
2472        rect: (f32, f32, f32, f32),
2473        matrix: [f32; 20],
2474    ) {
2475        let (vw, vh) = viewport;
2476        if vw == 0 || vh == 0 {
2477            return;
2478        }
2479        let (rx, ry, rw, rh) = rect;
2480        let x0 = rx.max(0.0) as u32;
2481        let y0 = ry.max(0.0) as u32;
2482        let x1 = (rx + rw).min(vw as f32).max(0.0) as u32;
2483        let y1 = (ry + rh).min(vh as f32).max(0.0) as u32;
2484        if x1 <= x0 || y1 <= y0 {
2485            return;
2486        }
2487        let scissor = (x0, y0, x1 - x0, y1 - y0);
2488
2489        let need_new = match &self.scratch {
2490            Some(s) => s.width != vw || s.height != vh,
2491            None => true,
2492        };
2493        if need_new {
2494            let texture = device.create_texture(&wgpu::TextureDescriptor {
2495                label: Some("llimphi-color-filter-scratch"),
2496                size: wgpu::Extent3d {
2497                    width: vw,
2498                    height: vh,
2499                    depth_or_array_layers: 1,
2500                },
2501                mip_level_count: 1,
2502                sample_count: 1,
2503                dimension: wgpu::TextureDimension::D2,
2504                format: INTERMEDIATE_FORMAT,
2505                usage: wgpu::TextureUsages::TEXTURE_BINDING
2506                    | wgpu::TextureUsages::RENDER_ATTACHMENT,
2507                view_formats: &[],
2508            });
2509            let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
2510            self.scratch = Some(BlurScratch {
2511                _texture: texture,
2512                view,
2513                width: vw,
2514                height: vh,
2515            });
2516        }
2517        let scratch_view = &self.scratch.as_ref().expect("scratch creado arriba").view;
2518
2519        // El [f32;20] viene por filas de 5 (`[c0,c1,c2,c3,bias]`); lo partimos
2520        // en 4 vec4 de coeficientes + un vec4 de bias para el UBO.
2521        let apply = ColorUniforms {
2522            r: [matrix[0], matrix[1], matrix[2], matrix[3]],
2523            g: [matrix[5], matrix[6], matrix[7], matrix[8]],
2524            b: [matrix[10], matrix[11], matrix[12], matrix[13]],
2525            a: [matrix[15], matrix[16], matrix[17], matrix[18]],
2526            bias: [matrix[4], matrix[9], matrix[14], matrix[19]],
2527        };
2528        // UBOs **por llamada**: varias `apply` en el mismo encoder/submit
2529        // comparten cola; `write_buffer` se aplica una vez antes de los command
2530        // buffers (gana el último valor escrito), así que un UBO compartido haría
2531        // que todas las pasadas leyeran la última matriz. Buffers frescos por
2532        // llamada evitan ese alias (80 bytes c/u, despreciable).
2533        let ubo_apply = device.create_buffer(&wgpu::BufferDescriptor {
2534            label: Some("llimphi-color-filter-ubo-apply"),
2535            size: COLOR_UBO_SIZE,
2536            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2537            mapped_at_creation: false,
2538        });
2539        let ubo_copy = device.create_buffer(&wgpu::BufferDescriptor {
2540            label: Some("llimphi-color-filter-ubo-copy"),
2541            size: COLOR_UBO_SIZE,
2542            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2543            mapped_at_creation: false,
2544        });
2545        queue.write_buffer(&ubo_apply, 0, bytemuck_cast(&apply));
2546        queue.write_buffer(&ubo_copy, 0, bytemuck_cast(&COLOR_IDENTITY));
2547
2548        // Pass 1: target → scratch (aplica la matriz).
2549        let bg_apply = device.create_bind_group(&wgpu::BindGroupDescriptor {
2550            label: Some("llimphi-color-filter-bg-apply"),
2551            layout: &self.bind_layout,
2552            entries: &[
2553                wgpu::BindGroupEntry {
2554                    binding: 0,
2555                    resource: wgpu::BindingResource::TextureView(target),
2556                },
2557                wgpu::BindGroupEntry {
2558                    binding: 1,
2559                    resource: wgpu::BindingResource::Sampler(&self.sampler),
2560                },
2561                wgpu::BindGroupEntry {
2562                    binding: 2,
2563                    resource: ubo_apply.as_entire_binding(),
2564                },
2565            ],
2566        });
2567        {
2568            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2569                label: Some("llimphi-color-filter-pass-apply"),
2570                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2571                    view: scratch_view,
2572                    resolve_target: None,
2573                    depth_slice: None,
2574                    ops: wgpu::Operations {
2575                        load: wgpu::LoadOp::Load,
2576                        store: wgpu::StoreOp::Store,
2577                    },
2578                })],
2579                depth_stencil_attachment: None,
2580                timestamp_writes: None,
2581                occlusion_query_set: None,
2582                multiview_mask: None,
2583            });
2584            pass.set_pipeline(&self.pipeline);
2585            pass.set_bind_group(0, &bg_apply, &[]);
2586            pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
2587            pass.draw(0..3, 0..1);
2588        }
2589
2590        // Pass 2: scratch → target (copia identidad), preservando lo de afuera.
2591        let bg_copy = device.create_bind_group(&wgpu::BindGroupDescriptor {
2592            label: Some("llimphi-color-filter-bg-copy"),
2593            layout: &self.bind_layout,
2594            entries: &[
2595                wgpu::BindGroupEntry {
2596                    binding: 0,
2597                    resource: wgpu::BindingResource::TextureView(scratch_view),
2598                },
2599                wgpu::BindGroupEntry {
2600                    binding: 1,
2601                    resource: wgpu::BindingResource::Sampler(&self.sampler),
2602                },
2603                wgpu::BindGroupEntry {
2604                    binding: 2,
2605                    resource: ubo_copy.as_entire_binding(),
2606                },
2607            ],
2608        });
2609        {
2610            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2611                label: Some("llimphi-color-filter-pass-copy"),
2612                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2613                    view: target,
2614                    resolve_target: None,
2615                    depth_slice: None,
2616                    ops: wgpu::Operations {
2617                        load: wgpu::LoadOp::Load,
2618                        store: wgpu::StoreOp::Store,
2619                    },
2620                })],
2621                depth_stencil_attachment: None,
2622                timestamp_writes: None,
2623                occlusion_query_set: None,
2624                multiview_mask: None,
2625            });
2626            pass.set_pipeline(&self.pipeline);
2627            pass.set_bind_group(0, &bg_copy, &[]);
2628            pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
2629            pass.draw(0..3, 0..1);
2630        }
2631    }
2632}
2633
2634/// "bytemuck" minimal sin dep: convierte `&T` a `&[u8]`. Sólo para POD repr(C)
2635/// — usado para escribir los UBOs del blur con `queue.write_buffer`.
2636fn bytemuck_cast<T: Copy>(v: &T) -> &[u8] {
2637    unsafe {
2638        std::slice::from_raw_parts(
2639            v as *const T as *const u8,
2640            std::mem::size_of::<T>(),
2641        )
2642    }
2643}
2644
2645/// Separable Gaussian, una dirección por pase. El vs es el mismo triángulo
2646/// grande del overlay; el fs samplea `2*radius+1` taps a lo largo de
2647/// `direction*pixel_size`. Pesos `exp(-i²/2σ²)` normalizados por la suma —
2648/// independiente del radius por si quedó cortada la cola.
2649const BLUR_WGSL: &str = r#"
2650struct VsOut {
2651    @builtin(position) pos: vec4<f32>,
2652    @location(0) uv: vec2<f32>,
2653};
2654
2655@vertex
2656fn vs(@builtin(vertex_index) vi: u32) -> VsOut {
2657    var corners = array<vec2<f32>, 3>(
2658        vec2<f32>(-1.0, -1.0),
2659        vec2<f32>( 3.0, -1.0),
2660        vec2<f32>(-1.0,  3.0),
2661    );
2662    let xy = corners[vi];
2663    var out: VsOut;
2664    out.pos = vec4<f32>(xy, 0.0, 1.0);
2665    out.uv = vec2<f32>((xy.x + 1.0) * 0.5, (1.0 - xy.y) * 0.5);
2666    return out;
2667}
2668
2669struct BlurParams {
2670    direction: vec2<f32>,
2671    pixel_size: vec2<f32>,
2672    sigma: f32,
2673    radius: f32,
2674    _pad: vec2<f32>,
2675};
2676
2677@group(0) @binding(0) var src_tex: texture_2d<f32>;
2678@group(0) @binding(1) var src_samp: sampler;
2679@group(0) @binding(2) var<uniform> params: BlurParams;
2680
2681@fragment
2682fn fs(in: VsOut) -> @location(0) vec4<f32> {
2683    let dir = params.direction * params.pixel_size;
2684    let r = i32(params.radius);
2685    let two_sigma_sq = 2.0 * params.sigma * params.sigma;
2686    var acc = vec4<f32>(0.0);
2687    var weight_sum = 0.0;
2688    for (var i = -r; i <= r; i = i + 1) {
2689        let fi = f32(i);
2690        let w = exp(-(fi * fi) / two_sigma_sq);
2691        acc = acc + textureSample(src_tex, src_samp, in.uv + dir * fi) * w;
2692        weight_sum = weight_sum + w;
2693    }
2694    return acc / weight_sum;
2695}
2696"#;
2697
2698/// Matriz de color 4×5: `out = M·rgba + bias`, clampeado a `[0,1]`. El vs es el
2699/// mismo triángulo grande; el fs hace 4 `dot` (una fila por canal) más el bias.
2700const COLOR_WGSL: &str = r#"
2701struct VsOut {
2702    @builtin(position) pos: vec4<f32>,
2703    @location(0) uv: vec2<f32>,
2704};
2705
2706@vertex
2707fn vs(@builtin(vertex_index) vi: u32) -> VsOut {
2708    var corners = array<vec2<f32>, 3>(
2709        vec2<f32>(-1.0, -1.0),
2710        vec2<f32>( 3.0, -1.0),
2711        vec2<f32>(-1.0,  3.0),
2712    );
2713    let xy = corners[vi];
2714    var out: VsOut;
2715    out.pos = vec4<f32>(xy, 0.0, 1.0);
2716    out.uv = vec2<f32>((xy.x + 1.0) * 0.5, (1.0 - xy.y) * 0.5);
2717    return out;
2718}
2719
2720struct ColorParams {
2721    r: vec4<f32>,
2722    g: vec4<f32>,
2723    b: vec4<f32>,
2724    a: vec4<f32>,
2725    bias: vec4<f32>,
2726};
2727
2728@group(0) @binding(0) var src_tex: texture_2d<f32>;
2729@group(0) @binding(1) var src_samp: sampler;
2730@group(0) @binding(2) var<uniform> params: ColorParams;
2731
2732@fragment
2733fn fs(in: VsOut) -> @location(0) vec4<f32> {
2734    let c = textureSample(src_tex, src_samp, in.uv);
2735    var o: vec4<f32>;
2736    o.r = dot(params.r, c) + params.bias.r;
2737    o.g = dot(params.g, c) + params.bias.g;
2738    o.b = dot(params.b, c) + params.bias.b;
2739    o.a = dot(params.a, c) + params.bias.a;
2740    return clamp(o, vec4<f32>(0.0), vec4<f32>(1.0));
2741}
2742"#;
2743
2744impl Surface for WinitSurface {
2745    fn size(&self) -> (u32, u32) {
2746        (self.config.width, self.config.height)
2747    }
2748
2749    fn resize(&mut self, width: u32, height: u32) {
2750        let (w, h) = (width.max(1), height.max(1));
2751        // **Sin cambio de tamaño NO se toca nada.** `RawSurface` y
2752        // `CanvasSurface` ya tenían este guard; `WinitSurface` —la que usan
2753        // TODAS las apps de escritorio y Android— era la única sin él, y eso
2754        // dejaba abierta la puerta a un `resize(w, h)` con las medidas que ya
2755        // tenía puestas: reconfigurar el swapchain y recrear las dos texturas
2756        // intermedias por gusto.
2757        //
2758        // Encontrado en metal el 2026-07-27 (Redmi Note 13 Pro, Mali-G57): el
2759        // camino de recuperación de `handle_redraw` llama exactamente a
2760        // `surface.resize(surface.size())` cuando `acquire` falla, así que sin
2761        // guard eso es un bucle cerrado —falla → resize → redraw → falla—.
2762        // Medido en logcat: **41.335 reconfiguraciones de swapchain en 13 s**,
2763        // ~10 MB de textura por vuelta, hasta que la creación empieza a
2764        // devolver texturas inválidas y la app se queda en negro (o muere, si
2765        // su device no tiene handler no-fatal). El guard corta el bucle en el
2766        // punto más bajo, valga quien valga el caller.
2767        if self.config.width == w && self.config.height == h {
2768            return;
2769        }
2770        // Las texturas PRIMERO, y recién si las dos salieron bien se toca el
2771        // resto. Así un fallo no deja el estado a medio aplicar: la surface
2772        // sigue configurada al tamaño viejo, que es el de las texturas que
2773        // quedan puestas, y el frame siguiente es coherente aunque salga con
2774        // las medidas anteriores. Instalar una textura **inválida** —lo que
2775        // hacía antes— es la pantalla negra perpetua.
2776        let (nueva, overlay_nueva) = match (
2777            create_intermediate_checked(&self.device, w, h),
2778            create_intermediate_checked(&self.device, w, h),
2779        ) {
2780            (Ok(a), Ok(b)) => (a, b),
2781            (a, b) => {
2782                let e = a.err().or_else(|| b.err()).unwrap_or_default();
2783                eprintln!(
2784                    "llimphi-hal: no se pudo crear la textura intermedia {w}x{h} ({e}); \
2785                     me quedo en {}x{}",
2786                    self.config.width, self.config.height
2787                );
2788                return;
2789            }
2790        };
2791        self.config.width = w;
2792        self.config.height = h;
2793        if let Err(e) = configure_checked(&self.device, self.vk(), &self.config) {
2794            eprintln!("llimphi-hal: configure en resize falló (surface perdida?): {e}");
2795        }
2796        (self.intermediate, self.intermediate_view) = nueva;
2797        (self.overlay, self.overlay_view) = overlay_nueva;
2798    }
2799
2800    fn acquire(&mut self) -> Result<Frame, SurfaceError> {
2801        // El `present` anterior descubrió que el device está perdido: no tiene
2802        // sentido pedir un frame más contra un device muerto. Se cobra acá para
2803        // entrar por el camino de recuperación que ya existe.
2804        if self.device_perdido {
2805            return Err(SurfaceError::DeviceLost);
2806        }
2807        // Gancho de verificación: fuerza un DeviceLost sintético cada N cuadros
2808        // (env `LLIMPHI_SIMULATE_DEVICE_LOSS`) para ejercitar el rebuild sin un
2809        // reset de output real. No-op sin la env.
2810        if simulate_device_loss_trip() {
2811            return Err(SurfaceError::DeviceLost);
2812        }
2813        // Ver la nota sobre `Suboptimal` en el `acquire` de `RawSurface`.
2814        use wgpu::CurrentSurfaceTexture as Estado;
2815        let texture = match self.vk().get_current_texture() {
2816            Estado::Success(t) | Estado::Suboptimal(t) => t,
2817            // El camino winit tampoco tenía recuperación: un `Outdated`/`Lost`
2818            // (hipo del compositor, cambio de VT) se propagaba y la app saltaba
2819            // el frame para siempre en el peor caso. Reconfiguramos; si no basta,
2820            // recreamos la surface desde la ventana; si ESO falla, el device
2821            // quedó irrecuperable → DeviceLost (rebuild del stack GPU completo).
2822            e @ (Estado::Outdated | Estado::Lost) => {
2823                if let Err(msg) = configure_checked(&self.device, self.vk(), &self.config) {
2824                    match self.recreate() {
2825                        Ok(()) => eprintln!(
2826                            "llimphi-hal: WinitSurface RECREADA tras {e:?} (reconfigure falló: {msg})"
2827                        ),
2828                        Err(msg2) => {
2829                            eprintln!(
2830                                "llimphi-hal: reconfigurar tras {e:?} falló ({msg}); recrear también falló ({msg2}) → DeviceLost"
2831                            );
2832                            return Err(SurfaceError::DeviceLost);
2833                        }
2834                    }
2835                }
2836                match self.vk().get_current_texture() {
2837                    Estado::Success(t) | Estado::Suboptimal(t) => t,
2838                    // Sigue rota tras reconfigurar+recrear → device irrecuperable.
2839                    Estado::Lost | Estado::Outdated => return Err(SurfaceError::DeviceLost),
2840                    Estado::Timeout => return Err(SurfaceError::Timeout),
2841                    Estado::Occluded => return Err(SurfaceError::Occluded),
2842                    otro => return Err(SurfaceError::Other(format!("{otro:?}"))),
2843                }
2844            }
2845            Estado::Timeout => return Err(SurfaceError::Timeout),
2846            // Ventana tapada: no es falla. Ver [`SurfaceError::Occluded`].
2847            Estado::Occluded => return Err(SurfaceError::Occluded),
2848            otro => return Err(SurfaceError::Other(format!("{otro:?}"))),
2849        };
2850        let surface_view = texture
2851            .texture
2852            .create_view(&wgpu::TextureViewDescriptor::default());
2853        // `TextureView` envuelve un Arc — clonar es atomic-incref, no
2854        // recrea la vista. La intermedia sólo cambia en `resize`.
2855        Ok(Frame {
2856            surface_texture: texture,
2857            surface_view,
2858            intermediate_view: self.intermediate_view.clone(),
2859            overlay_view: self.overlay_view.clone(),
2860            width: self.config.width,
2861            height: self.config.height,
2862        })
2863    }
2864
2865    /// Blitea la intermedia al swapchain y presenta.
2866    ///
2867    /// **El `present` va CAPTURADO**, como `configure` y `acquire`. Era el único
2868    /// de los tres sin red: `wgpu::SurfaceTexture::present` trata sus errores
2869    /// como fatales pase lo que pase —ni el handler no-fatal del device lo
2870    /// salva—, así que un device perdido acá **paniquea**, y el unwind arrastra
2871    /// destructores de wgpu-hal que paniquean a su vez
2872    /// (`Trying to destroy a SurfaceAcquireSemaphores that is still in use by a
2873    /// SurfaceTexture` → `panic in a destructor during cleanup` → **abort**).
2874    ///
2875    /// Medido en Android el 2026-07-28: el device se pierde en el primer frame
2876    /// y la app **se mata ahí**, antes de que el runtime pueda contar fallos ni
2877    /// llamar a `rebuild_gpu_after_device_loss` (0 fallos y 0 rebuilds
2878    /// registrados en el log — no llegaba a ejecutarse nada). Capturando, la
2879    /// pérdida se convierte en una bandera que el próximo `acquire` cobra como
2880    /// [`SurfaceError::DeviceLost`], que es el camino de recuperación **que ya
2881    /// existía** y nunca se estaba alcanzando.
2882    fn present(&mut self, frame: Frame, hal: &Hal) {
2883        let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
2884            label: Some("llimphi-blit"),
2885        });
2886        self.blitter.copy(
2887            &hal.device,
2888            &mut encoder,
2889            &frame.intermediate_view,
2890            &frame.surface_view,
2891        );
2892        hal.queue.submit(std::iter::once(encoder.finish()));
2893        // **No presentar sobre un device muerto.** Un `load` relajado, nada de
2894        // `pop_error_scope` por frame (eso poll‑ea la GPU y sería un peaje en la
2895        // ruta caliente de todas las apps). Si el device ya se perdió, soltamos
2896        // el `SurfaceTexture` sin presentar y dejamos la marca: el próximo
2897        // `acquire` devuelve `DeviceLost` y el runtime rehace el stack.
2898        if DEVICE_PERDIDO_GLOBAL.load(std::sync::atomic::Ordering::Relaxed) {
2899            self.device_perdido = true;
2900            eprintln!(
2901                "llimphi-hal: device perdido — no presento este frame; \
2902                 DeviceLost en el próximo acquire"
2903            );
2904            // **El `SurfaceTexture` se filtra a propósito.** Con el device
2905            // perdido, wgpu 27 paniquea de forma fatal por los DOS caminos:
2906            // `present()` (wgpu_core.rs:3858) y su `Drop` (wgpu_core.rs:3867),
2907            // y ninguno es atajable —no pasan por error-scope ni por el handler
2908            // no-fatal—. Medido en Android: saltear el present movía el panic al
2909            // destructor, con la misma cascada (`SurfaceAcquireSemaphores still
2910            // in use` → `panic in a destructor` → **abort**). Olvidarla evita
2911            // los dos. El costo es un handle huérfano en un stack GPU que
2912            // estamos por rehacer entero; el beneficio es que el proceso vive.
2913            std::mem::forget(frame.surface_texture);
2914            return;
2915        }
2916        frame.surface_texture.present();
2917    }
2918}
2919
2920#[cfg(all(test, not(target_arch = "wasm32")))]
2921mod tests {
2922    use super::*;
2923
2924    /// Un `Hal` headless, o `None` si esta máquina no tiene con qué (CI sin GPU
2925    /// ni lavapipe). Sin esto el test fallaría por falta de hardware, que no es
2926    /// lo que queremos certificar.
2927    fn hal_o_skip() -> Option<Hal> {
2928        pollster::block_on(Hal::new_headless(false)).ok()
2929    }
2930
2931    /// Una textura intermedia de tamaño imposible **devuelve `Err`**; no
2932    /// paniquea ni entrega una textura inválida.
2933    ///
2934    /// Es la regresión de Android/Mali del 2026-07-27: `create_texture` fallaba,
2935    /// wgpu devolvía una textura **inválida**, y el `create_view` inmediato
2936    /// emitía `Texture with 'llimphi-intermediate' label is invalid` — un error
2937    /// derivado que tapaba el original. Según el handler del device, el mismo
2938    /// bug mataba la app (default fatal de wgpu) o la dejaba viva pintando en
2939    /// negro para siempre (handler no-fatal). Con la creación capturada, el
2940    /// caller se entera y conserva las texturas que funcionaban.
2941    #[test]
2942    fn textura_intermedia_imposible_da_error_y_no_paniquea() {
2943        let Some(hal) = hal_o_skip() else {
2944            eprintln!("sin GPU headless en esta máquina: test omitido");
2945            return;
2946        };
2947        let limite = hal.device.limits().max_texture_dimension_2d;
2948        let r = create_intermediate_checked(&hal.device, limite + 1, 64);
2949        assert!(
2950            r.is_err(),
2951            "pasarse del límite de textura 2D ({limite}) tiene que dar Err, no una textura inválida"
2952        );
2953        // El mensaje es el de wgpu, no uno derivado del `create_view`: es el que
2954        // dice de verdad qué pasó.
2955        let msg = r.unwrap_err();
2956        assert!(
2957            !msg.contains("create_view"),
2958            "el error tiene que ser el de la creación, no el derivado de la vista: {msg}"
2959        );
2960    }
2961
2962    /// Y el camino bueno sigue funcionando: un tamaño sano devuelve `Ok` con la
2963    /// textura del tamaño pedido. Sin esto, un `create_intermediate_checked` que
2964    /// devolviera `Err` SIEMPRE pasaría el test de arriba.
2965    #[test]
2966    fn textura_intermedia_de_tamano_sano_sale_ok() {
2967        let Some(hal) = hal_o_skip() else {
2968            eprintln!("sin GPU headless en esta máquina: test omitido");
2969            return;
2970        };
2971        let (tex, _view) =
2972            create_intermediate_checked(&hal.device, 320, 240).expect("320x240 tiene que salir");
2973        assert_eq!((tex.width(), tex.height()), (320, 240));
2974    }
2975}