tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Definitions

⎇ main llimphi-hal/src/lib.rs Rust · 3197 lines · 141.7 KB History Who touched what raw

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