//! **Llimphi directo al framebuffer del kernel**, sin compositor y sin GPU. //! //! Es el hueco que `llimphi-hal` declara en su primera línea —*«framebuffer //! directo del kernel wawa (TODO)»*— resuelto para Linux: la cáscara que //! convierte una `View` en bytes **ya existe** (`llimphi-wawa`, escrita para //! el kernel de wawa y explícitamente agnóstica del hardware: *«recibe un //! `&mut [u8]` y su stride; quién mapeó esa memoria es problema de afuera»*). //! Este crate es el «afuera»: consigue esa memoria de un dumb buffer DRM. //! //! # Para qué existe //! //! Para que el medio de instalación abra una interfaz **gráfica** sin arrastrar //! Mesa, ni Vulkan, ni un compositor, ni un rootfs. `SDD-CAMINOS.md` §1 dice //! gráfica siempre, y §3.4 daba por hecho que eso costaba un sistema de archivos //! con bibliotecas de video. No: cuesta este archivo. //! //! La cuenta, medida: el instalador gráfico sobre wgpu enlaza libc y carga //! Mesa/Vulkan en tiempo de ejecución — un initramfs tendría que llevarlas. Por //! este camino no hay nada que cargar: todo se resuelve en CPU con `vello_cpu`, //! que es el mismo pintor que ya certifica `check-llimphi-cpu-vs-gpu.sh` contra //! el camino de GPU. //! //! # El bucle //! //! [`correr`] es el ciclo Elm completo sobre esta superficie: pinta, espera una //! tecla, la pasa por `on_key`, actualiza el modelo y vuelve a pintar. //! **Repinta sólo cuando algo cambió** — un instalador que redibuja sesenta //! veces por segundo una pantalla quieta gasta batería y no gana nada. //! //! # Lo que este crate NO hace //! //! - **No hace modeset.** Se usa el modo **que ya está puesto**, que es la regla //! del arranque sin parpadeo: cambiar de modo es lo que hace parpadear, y el //! modo bueno lo eligió el firmware. //! # El puntero (2026-10-09) //! //! Acá decía «**no tiene mouse**… en un medio de instalación el teclado //! alcanza». No alcanzaba: el operador lo reportó tres veces, y la tercera fue //! *«todavía no hay mouse y no sé por qué lo sigues negando»*. Tenía razón —y la //! razón escrita acá era una racionalización: el hit-test ya existía //! (`Cascara::click`) y lo único que faltaba era leer el aparato y dibujar la //! flecha. //! //! Hoy se lee el movimiento **relativo** y los botones (ver [`entrada`]), el //! cursor lo dibuja esta superficie —no la aplicación, que se lo podría //! olvidar— y un click se resuelve contra el `Mounted` del cuadro que está //! **en la pantalla**, no contra uno nuevo. Lo que sigue faltando es el panel //! táctil por `EV_ABS`, que necesita el rango de cada eje y eso sí es un //! `ioctl`. //! - **No usa `llimphi_ui::App`.** Ese trait vive en un crate que arrastra winit //! y wgpu por su perfil normal, que es exactamente lo que este camino existe //! para no traer. Acá el contrato es [`Programa`], que es el mismo ciclo sin //! el `Handle` de concurrencia: un instalador no tiene workers. pub mod entrada; // Lo que hace falta para escribir una pantalla, servido desde acá: el DSL de la // vista, el layout y los colores. Es lo mismo que hace `llimphi-ui` con winit y // wgpu — quien usa la superficie no debería tener que averiguar de qué crate // sale cada tipo. pub use llimphi_compositor::{Mounted, View}; pub use llimphi_cpu::{kurbo, peniko}; pub use llimphi_entrada::{Key, KeyEvent, Modifiers, NamedKey}; pub use llimphi_layout::taffy; use std::os::fd::{AsFd, BorrowedFd}; use llimphi_entrada::KeyState; use entrada::{Boton, Evento}; use drm::buffer::DrmFourcc; use drm::control::{connector, crtc, Device as ControlDevice}; use llimphi_wawa::{Cascara, FormatoPixel}; /// El nodo DRM abierto. Envoltorio mínimo: el crate `drm` pide `AsFd` y sus dos /// traits marcadores. pub struct Nodo(std::fs::File); impl AsFd for Nodo { fn as_fd(&self) -> BorrowedFd<'_> { self.0.as_fd() } } impl drm::Device for Nodo {} impl ControlDevice for Nodo {} #[derive(Debug)] pub enum ErrorDrm { /// Ningún `/dev/dri/cardN` se pudo abrir. En un initramfs suele ser que el /// módulo del driver todavía no cargó. SinNodo, /// Hay nodo pero **ninguna pantalla conectada**. No es un error del /// programa: es una máquina sin monitor, y quien llama decide qué hacer. SinPantalla, /// La pantalla la tiene otro proceso: en DRM hay **un solo master** por /// nodo, y el que no lo es recibe `EACCES` al presentar —no al abrir—, así /// que el diagnóstico aparece tarde y disfrazado de permiso de archivo. /// /// Medido en QEMU el 2026-09-28 arrancando el medio: `arje-splash` estaba /// pintando la chakana y esto salía como «Permission denied (os error 13)», /// que manda a mirar dueños y modos de `/dev/dri/card0` — donde no hay nada /// que arreglar. El splash tiene protocolo para soltarla /// (`arje-splash/src/handoff.rs`: el cliente manda READY, el splash suelta /// el master y contesta RELEASED); quien vaya a pintar tiene que hablarlo. Ocupada, Io(std::io::Error), } impl std::fmt::Display for ErrorDrm { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ErrorDrm::SinNodo => write!(f, "no encontré ningún nodo de video (/dev/dri/card*)"), ErrorDrm::SinPantalla => write!(f, "hay video pero ninguna pantalla conectada"), ErrorDrm::Ocupada => write!( f, "la pantalla la tiene otro proceso (DRM master) — hay que pedirle que la suelte" ), ErrorDrm::Io(e) => write!(f, "{e}"), } } } impl From for ErrorDrm { fn from(e: std::io::Error) -> Self { ErrorDrm::Io(e) } } /// Una pantalla lista para recibir vistas. pub struct Pantalla { nodo: Nodo, crtc: crtc::Handle, conector: connector::Handle, modo: drm::control::Mode, cascara: Cascara, /// 🔴 **Un solo búfer, reusado.** Acá se creaba un dumb buffer y un /// framebuffer **en cada cuadro** y no se destruía ninguno: `DumbBuffer` no /// tiene `Drop` —se libera con `destroy_dumb_buffer`, que lo consume— y un /// `framebuffer::Handle` es un número, no un recurso con dueño. O sea que /// cada cuadro dejaba 4 MB de memoria de video y un objeto del kernel. /// /// Con teclado no se notaba: un instalador pinta algunas decenas de cuadros /// en toda su vida. Con el puntero sí — mover el mouse pinta en cada gesto, /// y eso convierte una fuga despreciable en quedarse sin memoria de video /// mientras alguien elige dónde instalar. /// En un `Option` **sólo para poder devolverlo**: `destroy_dumb_buffer` lo /// consume, y de un `&mut self` en `Drop` no se puede sacar un valor sin /// dejar algo en su lugar. `DumbBuffer` no tiene `Default`. buffer: Option, fb: drm::control::framebuffer::Handle, /// ¿Ya se le dijo al CRTC que muestre este framebuffer? Se hace **una vez**: /// después se escribe en el mismo búfer que la pantalla está leyendo, y un /// `set_crtc` por cuadro es un modeset de más por cuadro. presentado: bool, /// Dónde está el cursor, si hay alguno. Lo dibuja **esta superficie** y no /// la aplicación: un cursor que cada pantalla tiene que acordarse de pintar /// es un cursor que falta en alguna. cursor: Option<(f32, f32)>, } impl Pantalla { /// Abre la primera pantalla conectada y prepara la cáscara a su medida. /// /// Recorre `card0`, `card1`… porque **cuál es el bueno cambia**: en una /// máquina con `simpledrm` el 0 lo toma el framebuffer heredado del firmware /// y lo suelta recién cuando carga el driver de verdad. Es el mismo criterio /// que ya usa `arje-splash`. pub fn abrir() -> Result { for n in 0..8 { let ruta = format!("/dev/dri/card{n}"); let Ok(f) = std::fs::OpenOptions::new().read(true).write(true).open(&ruta) else { continue; }; let nodo = Nodo(f); let Ok(recursos) = nodo.resource_handles() else { continue }; for &c in recursos.connectors() { let Ok(info) = nodo.get_connector(c, false) else { continue }; if info.state() != connector::State::Connected { continue; } let Some(&modo) = info.modes().first() else { continue }; // El CRTC que ya está manejando este conector, si lo hay: se // reusa el que el firmware dejó puesto en vez de elegir otro. let Some(&crtc) = recursos.crtcs().first() else { continue }; let (w, h) = modo.size(); let cascara = Cascara::new(w, h); // El búfer y su framebuffer, una vez y para siempre. Si no se // pueden crear no hay pantalla que ofrecer, así que el error // sale acá y no en el primer cuadro. let buffer = nodo .create_dumb_buffer((w as u32, h as u32), DrmFourcc::Xrgb8888, 32) .map_err(traducir)?; let fb = nodo.add_framebuffer(&buffer, 24, 32).map_err(traducir)?; return Ok(Pantalla { nodo, crtc, conector: c, modo, cascara, buffer: Some(buffer), fb, presentado: false, cursor: None, }); } return Err(ErrorDrm::SinPantalla); } Err(ErrorDrm::SinNodo) } /// Cuánto mide, en píxeles. pub fn tamano(&self) -> (u16, u16) { self.modo.size() } /// Dónde poner el cursor. `None` lo saca. pub fn mover_cursor(&mut self, donde: Option<(f32, f32)>) { self.cursor = donde; } /// Pinta una vista y la muestra. **Un cuadro, sin bucle.** /// /// Devuelve el `Mounted` de ESE cuadro, que es contra el que hay que /// resolver un click: el hit-test del cuadro que está en la pantalla, no de /// uno nuevo. Antes se descartaba (`let _ = …`) y por eso el acierto de clic /// existía y no se podía usar. pub fn pintar(&mut self, vista: View) -> Result, ErrorDrm> { let montado = self.cascara.fotograma(vista); self.volcar()?; Ok(montado) } /// Vuelve a mostrar **el mismo cuadro**, con el cursor donde esté ahora. /// /// Es el camino de mover el mouse: la cáscara guarda su lienzo rasterizado, /// así que mover el cursor no obliga a montar la vista, calcular el layout /// ni rasterizar de nuevo — sólo a copiar los píxeles que ya están y estampar /// la flecha encima. Es la diferencia entre un cursor que sigue la mano y /// uno que va a saltos. pub fn repintar(&mut self) -> Result<(), ErrorDrm> { self.volcar() } /// El `Msg` de lo que haya bajo `(x, y)` en el cuadro de `montado`. pub fn click(&self, montado: &Mounted, x: f32, y: f32) -> Option { self.cascara.click(montado, x, y) } /// Copia el lienzo al búfer de video, estampa el cursor y lo muestra. fn volcar(&mut self) -> Result<(), ErrorDrm> { let (w, _) = self.modo.size(); let stride = w as usize * 4; { let Some(buffer) = self.buffer.as_mut() else { return Ok(()) }; let mut mapa = self.nodo.map_dumb_buffer(buffer).map_err(traducir)?; // `Bgra8` y no `Rgba8`: `XRGB8888` en memoria little-endian son los // bytes B, G, R, X. Equivocarse acá no da error — da una pantalla // con los colores cambiados, que es peor. self.cascara.volcar(mapa.as_mut(), stride, FormatoPixel::Bgra8); if let Some((x, y)) = self.cursor { let (w, h) = self.modo.size(); estampar_cursor(mapa.as_mut(), stride, w, h, x, y); } } if !self.presentado { // **Sin modeset**: se usa el modo que ya está puesto. Cambiarlo es lo // que hace parpadear, y el bueno lo eligió el firmware. self.nodo .set_crtc(self.crtc, Some(self.fb), (0, 0), &[self.conector], Some(self.modo)) .map_err(traducir)?; self.presentado = true; return Ok(()); } // 🔴 **Escribir el búfer NO es presentarlo.** // // Con un búfer reusado, escribir los píxeles del cuadro nuevo en la memoria que el CRTC // está leyendo **no los muestra**: un driver de pantalla virtual (lo que hay en QEMU, y lo // que hay en cualquier máquina sin GPU dedicada) copia del búfer a su propia superficie y // sólo lo hace cuando se le avisa qué cambió. Sin el aviso, la pantalla se queda con el // primer cuadro para siempre. // // Lo encontró el banco el 2026-10-09, y es el defecto que esta vuelta estuvo más cerca de // llegarle al operador: la versión anterior creaba un framebuffer nuevo por cuadro y hacía // `set_crtc` cada vez —eso empujaba el cuadro, y de paso fugaba 4 MB por vuelta—, así que // al arreglar la fuga se rompió la presentación. **Los números de CPU no lo habrían // visto**: el programa pintaba, montaba la vista y contaba sus píxeles igual que siempre, // sólo que del otro lado de la frontera no pasaba nada. Se vio porque el instrumento mira // el panel: la flecha del cursor dibujada y quieta en el centro. // // `dirty_framebuffer` es ese aviso. Si el driver no lo implementa contesta error, y ahí el // camino es volver a `set_crtc` con el MISMO framebuffer — que no asigna nada, así que la // fuga no vuelve. let todo = [drm::control::ClipRect::new(0, 0, w, self.modo.size().1)]; if self.nodo.dirty_framebuffer(self.fb, &todo).is_err() { self.nodo .set_crtc(self.crtc, Some(self.fb), (0, 0), &[self.conector], Some(self.modo)) .map_err(traducir)?; } Ok(()) } } impl Drop for Pantalla { /// Devuelve lo que se le pidió al kernel. No es cosmético: sin esto, cada /// corrida del programa deja un framebuffer y 4 MB de memoria de video /// colgados hasta que el nodo se cierre del todo. fn drop(&mut self) { let _ = self.nodo.destroy_framebuffer(self.fb); // `destroy_dumb_buffer` consume el búfer: se lo saca del `Option`. if let Some(buffer) = self.buffer.take() { let _ = self.nodo.destroy_dumb_buffer(buffer); } } } /// La flecha del cursor, estampada **sobre los píxeles ya volcados**. /// /// Va acá y no en el árbol de la vista por dos razones: una aplicación no se /// puede olvidar de dibujarla, y un nodo más en el layout haría que el cursor /// participe del cálculo de tamaños — el puntero no es parte de la interfaz, va /// encima de ella. /// /// La forma es la flecha de siempre, escrita como texto para que se pueda leer y /// corregir sin un editor de imágenes: `#` es el borde oscuro y `.` el relleno /// claro. Borde **y** relleno porque un cursor de un solo color desaparece /// sobre el fondo de su mismo tono, y esta pantalla tiene fondos claros y /// oscuros a la vez. const CURSOR: [&str; 19] = [ "# ", "## ", "#.# ", "#..# ", "#...# ", "#....# ", "#.....# ", "#......# ", "#.......# ", "#........# ", "#.....#####", "#..#..# ", "#.# #..# ", "## #..# ", "# #..# ", " #..# ", " #.# ", " ### ", " ", ]; fn estampar_cursor(destino: &mut [u8], stride: usize, w: u16, h: u16, x: f32, y: f32) { let (x0, y0) = (x.max(0.0) as usize, y.max(0.0) as usize); for (dy, fila) in CURSOR.iter().enumerate() { let py = y0 + dy; if py >= h as usize { break; } for (dx, c) in fila.bytes().enumerate() { let px = x0 + dx; if px >= w as usize { break; } // BGRX, que es lo que espera `XRGB8888` en little-endian. let (b, g, r) = match c { b'#' => (20u8, 20u8, 20u8), b'.' => (245u8, 245u8, 245u8), _ => continue, }; let i = py * stride + px * 4; if i + 3 < destino.len() { destino[i] = b; destino[i + 1] = g; destino[i + 2] = r; destino[i + 3] = 255; } } } } /// La misma vista, pintada **a memoria** en vez de a una pantalla. /// /// Devuelve los píxeles en RGBA, uno por cada punto de `ancho × alto`. Es el /// mismo compositor y el mismo rasterizador que usa [`Pantalla::pintar`]: lo /// único que cambia es dónde caen los bytes. /// /// Existe para poder **mirar lo que se dibuja sin tener el hardware delante**. /// Sin esto, la única forma de ver esta cara era arrancar un medio en una /// máquina o en un emulador, y eso convierte cada ajuste de un color en una /// tanda de minutos. pub fn pintar_en_memoria(ancho: u16, alto: u16, vista: View) -> Vec { let mut cascara = Cascara::new(ancho, alto); let _ = cascara.fotograma(vista); let mut bytes = vec![0u8; ancho as usize * alto as usize * 4]; cascara.volcar(&mut bytes, ancho as usize * 4, FormatoPixel::Rgba8); bytes } /// El contrato de una aplicación sobre esta superficie. /// /// Es el ciclo Elm de siempre —modelo inmutable, un mensaje por vez— sin el /// `Handle` de `llimphi_ui::App`: la superficie no sabe de hilos, y no tiene /// por qué. /// /// Lo que sí hay es trabajo que tarda —medir los discos de la máquina son /// segundos— y cuya respuesta llega cuando ya se pintó otra cosa. Para eso está /// [`Programa::pendientes`]: el programa guarda sus resultados donde quiera y el /// bucle se los pide por vuelta. pub trait Programa { type Model; type Msg: Clone; fn init() -> Self::Model; fn update(model: Self::Model, msg: Self::Msg) -> Self::Model; fn view(model: &Self::Model) -> View; /// `None` ignora la tecla. Devolver `Some` es lo único que mueve el modelo. fn on_key(model: &Self::Model, ev: &KeyEvent) -> Option; /// `true` cuando hay que salir. Se pregunta después de cada mensaje. fn termino(_model: &Self::Model) -> bool { false } /// Mensajes que llegaron por fuera del teclado, para entregar ahora. /// /// El bucle la llama una vez por vuelta y aplica lo que devuelva. Un /// programa sin trabajo de fondo no la implementa; uno que lanza algo largo /// —medir los discos— devuelve acá el resultado cuando esté. /// /// **Devolver lo mismo dos veces es repetir el mensaje**: quien la /// implementa vacía su cola al contestar. fn pendientes(_model: &Self::Model) -> Vec { Vec::new() } } /// Un error de DRM con el caso del master dicho por su nombre. /// /// `EACCES` acá no es un problema de permisos del archivo —el nodo ya se abrió /// bien— sino la pantalla tomada por otro proceso. `EPERM` es el mismo caso en /// kernels que lo contestan así. fn traducir(io: std::io::Error) -> ErrorDrm { match io.raw_os_error() { Some(13) | Some(1) => ErrorDrm::Ocupada, _ => ErrorDrm::Io(io), } } /// Correr un programa hasta que diga que terminó. /// /// **Repinta sólo cuando el modelo cambió.** Un instalador pasa casi todo su /// tiempo esperando a que alguien lea la pantalla; redibujarla en un bucle /// cerrado calienta la máquina y no mejora nada. Cuando no hay teclas, se /// duerme un rato corto — lo suficiente para que una pulsación no se sienta /// demorada, y lo bastante como para no ocupar un núcleo. pub fn correr(pantalla: &mut Pantalla) -> Result<(), ErrorDrm> { let (w, h) = pantalla.tamano(); let mut teclados = entrada::Entradas::abrir(w, h); if teclados.vacio() { // Se dice y se sigue: una máquina sin teclado es un caso real, y lo que // corresponde es mostrar la pantalla, no negarse a arrancar. eprintln!("llimphi-drm: no encontré ningún dispositivo de entrada en /dev/input"); } let mut modelo = P::init(); let mut hay_que_pintar = true; // **Dos razones distintas para escribir la pantalla.** Un cuadro nuevo // monta la vista, calcula el layout y rasteriza; mover el cursor no cambia // nada de eso y sólo necesita volver a copiar lo que ya está pintado. Si // fueran la misma bandera, cada milímetro de mouse costaría un cuadro // entero y el cursor iría a saltos. let mut mover_el_cursor = false; // El `Mounted` del cuadro que está EN la pantalla. Es contra éste que se // resuelve un click — contra uno nuevo sería adivinar. let mut montado: Option> = None; if teclados.tiene_puntero() { pantalla.mover_cursor(Some(teclados.puntero())); } // Cada cuántas vueltas se vuelve a mirar `/dev/input`. Con 16 ms por vuelta, // 60 es un segundo: lo bastante seguido para que enchufar un teclado se // sienta inmediato, y lo bastante espaciado para que leer un directorio no // aparezca en ningún perfil. const VUELTAS_ENTRE_ESCANEOS: u32 = 60; let mut vueltas: u32 = 0; loop { vueltas = vueltas.wrapping_add(1); if vueltas % VUELTAS_ENTRE_ESCANEOS == 0 { // Los dispositivos no están todos al arrancar: un teclado USB // enumera de forma asíncrona y tarda segundos. Sin esto, la pantalla // queda pintada y sorda para siempre. let nuevos = teclados.refrescar(); if nuevos > 0 { eprintln!("llimphi-drm: {nuevos} dispositivo(s) de entrada nuevo(s)"); // Un mouse USB enumera tarde, igual que un teclado. Si apareció // ahora, el cursor tiene que aparecer con él. if teclados.tiene_puntero() && pantalla.cursor.is_none() { pantalla.mover_cursor(Some(teclados.puntero())); mover_el_cursor = true; } } } if hay_que_pintar { montado = Some(pantalla.pintar(P::view(&modelo))?); hay_que_pintar = false; mover_el_cursor = false; } else if mover_el_cursor { pantalla.repintar()?; mover_el_cursor = false; } // Primero lo que llegó de afuera: si el trabajo largo terminó mientras // la pantalla mostraba «midiendo…», lo que corresponde es mostrarlo // antes de atender una tecla que se apretó contra la pantalla vieja. for msg in P::pendientes(&modelo) { modelo = P::update(modelo, msg); hay_que_pintar = true; } if P::termino(&modelo) { return Ok(()); } for ev in teclados.leer() { match ev { Evento::Tecla(ev) => { // Sólo las pulsaciones mueven el modelo. Actuar también al // soltar haría que cada tecla cuente dos veces. if ev.state != KeyState::Pressed { continue; } if let Some(msg) = P::on_key(&modelo, &ev) { modelo = P::update(modelo, msg); hay_que_pintar = true; } } // **Mover el puntero NO es un mensaje.** El modelo no cambia, así // que no se le pregunta nada a la aplicación: se repinta el mismo // cuadro con la flecha en otro lado. Meterlo por `update` haría // que cada gesto del mouse pase por la lógica del instalador. Evento::Puntero { x, y } => { pantalla.mover_cursor(Some((x, y))); mover_el_cursor = true; } // **Al apretar, no al soltar**, igual que las teclas. Y sólo el // izquierdo: los otros dos no tienen significado en esta cara, y // hacer que el derecho elija sería una sorpresa. Evento::Boton { boton: Boton::Izquierdo, apretado: true, x, y } => { if let Some(m) = &montado { if let Some(msg) = pantalla.click(m, x, y) { modelo = P::update(modelo, msg); hay_que_pintar = true; } } } Evento::Boton { .. } => {} } } if P::termino(&modelo) { return Ok(()); } if !hay_que_pintar && !mover_el_cursor { std::thread::sleep(std::time::Duration::from_millis(16)); } } }