🔒 Repository is read-only – file editing is disabled.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340
//! Wykrywanie monitorów przez DRM/KMS (przenośne — działa na dowolnym Linuksie z `/dev/dri`).
//!
//! To świadomie **szkic**: otwieramy karty DRM, odpytujemy KMS o podłączone konektory
//! i ich tryby, a wynik zwracamy jako model [`OutputInfo`]. Gdy DRM jest niedostępne
//! (np. w środowisku bez uprawnień albo w trybie nested), zwracamy `None` i kompozytor
//! korzysta z pojedynczego wyjścia syntetycznego.
//!
//! Skanowanie (page-flip, alokacja buforów, master) dochodzi później — tutaj chodzi o to,
//! by Selene wiedział, ile i jakich monitorów ma użyć.
use std::mem::MaybeUninit;
use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
use std::path::PathBuf;
use rustix::fs::{Mode, OFlags};
use rustix::mm::{mmap, munmap, MapFlags, ProtFlags};
use crate::monitors::{layout_horizontal, OutputInfo};
// — Kody ioctl KMS (`_IOWR`) —
const DRM_IOCTL_MODE_GETRESOURCES: u32 = 0xC040_64A0;
const DRM_IOCTL_MODE_GETCONNECTOR: u32 = 0xC050_64A7;
const DRM_IOCTL_MODE_GETENCODER: u32 = 0xC014_64A6;
const DRM_IOCTL_MODE_GETCRTC: u32 = 0xC068_64A1;
const DRM_IOCTL_MODE_SETCRTC: u32 = 0xC068_64A2;
/// `drm_mode_crtc_lut` (`sizeof` = 32 = 0x20).
const DRM_IOCTL_MODE_SETGAMMA: u32 = 0xC020_64A4;
const DRM_IOCTL_MODE_CREATE_DUMB: u32 = 0xC020_64B2;
const DRM_IOCTL_MODE_MAP_DUMB: u32 = 0xC010_64B3;
const DRM_IOCTL_MODE_ADDFB2: u32 = 0xC068_64B8;
const DRM_IOCTL_MODE_PAGE_FLIP: u32 = 0xC018_64B0;
/// `drm_mode_cursor` (`sizeof` = 28 = 0x1C).
const DRM_IOCTL_MODE_CURSOR: u32 = 0xC01C_64A3;
// — ioctle bez argumentu (`_IO`) —
const DRM_IOCTL_SET_MASTER: u32 = 0x0000_641E;
const DRM_IOCTL_DROP_MASTER: u32 = 0x0000_641F;
const DRM_MODE_CONNECTED: u32 = 1;
/// `DRM_MODE_TYPE_PREFERRED` (bit 3) — natywny tryb monitora.
const DRM_MODE_TYPE_PREFERRED: u32 = 1 << 3;
/// `DRM_MODE_PAGE_FLIP_EVENT` — proś o zdarzenie po zakończeniu flipu.
const DRM_MODE_PAGE_FLIP_EVENT: u32 = 1;
/// Cztery znaki `XRGB8888` (kolejność bajtów B,G,R,X).
const DRM_FORMAT_XRGB8888: u32 = 0x3432_5258;
/// Cztery znaki `XBGR8888` (kolejność bajtów R,G,B,X) — **taka sama jak wyjście
/// tiny-skia**, więc klatkę można skopiować bez przepisywania kanałów.
const DRM_FORMAT_XBGR8888: u32 = 0x3432_4258;
/// Cztery znaki `ARGB8888` (kolejność bajtów B,G,R,A) — format kursora.
const DRM_FORMAT_ARGB8888: u32 = 0x3432_5241;
/// `DRM_MODE_CURSOR_BO` — ustaw obraz kursora (albo wyłącz, gdy `handle` = 0).
const DRM_MODE_CURSOR_BO: u32 = 0x01;
/// `DRM_MODE_CURSOR_MOVE` — przesuń kursor bez zmiany obrazu.
const DRM_MODE_CURSOR_MOVE: u32 = 0x02;
/// `DRM_EVENT_FLIP_COMPLETE`.
const DRM_EVENT_FLIP_COMPLETE: u32 = 2;
/// `struct drm_mode_card_res`.
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct ModeCardRes {
fb_id_ptr: u64,
crtc_id_ptr: u64,
connector_id_ptr: u64,
encoder_id_ptr: u64,
count_fbs: u32,
count_crtcs: u32,
count_connectors: u32,
count_encoders: u32,
min_width: u32,
max_width: u32,
min_height: u32,
max_height: u32,
}
/// `struct drm_mode_get_connector`.
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct ModeGetConnector {
encoders_ptr: u64,
modes_ptr: u64,
props_ptr: u64,
prop_values_ptr: u64,
count_modes: u32,
count_props: u32,
count_encoders: u32,
encoder_id: u32,
connector_id: u32,
connector_type: u32,
connector_type_id: u32,
connection: u32,
mm_width: u32,
mm_height: u32,
subpixel: u32,
pad: u32,
}
/// `struct drm_mode_modeinfo`.
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct ModeInfo {
clock: u32,
hdisplay: u16,
hsync_start: u16,
hsync_end: u16,
htotal: u16,
hskew: u16,
vdisplay: u16,
vsync_start: u16,
vsync_end: u16,
vtotal: u16,
vscan: u16,
vrefresh: u32,
flags: u32,
type_: u32,
name: [u8; 32],
}
/// `struct drm_mode_get_encoder`.
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct ModeGetEncoder {
encoder_id: u32,
encoder_type: u32,
crtc_id: u32,
possible_crtcs: u32,
possible_clones: u32,
}
/// `struct drm_mode_crtc` (używany też przez `GETCRTC`/`SETCRTC`).
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct ModeCrtc {
set_connectors_ptr: u64,
count_connectors: u32,
crtc_id: u32,
fb_id: u32,
x: u32,
y: u32,
gamma_size: u32,
mode_valid: u32,
mode: ModeInfo,
}
/// `struct drm_mode_crtc_lut` — tablica korekcji gamma (night light).
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct ModeCrtcLut {
crtc_id: u32,
gamma_size: u32,
red: u64,
green: u64,
blue: u64,
}
/// `struct drm_mode_create_dumb`.
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct ModeCreateDumb {
height: u32,
width: u32,
bpp: u32,
flags: u32,
handle: u32,
pitch: u32,
size: u64,
}
/// `struct drm_mode_map_dumb`.
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct ModeMapDumb {
handle: u32,
pad: u32,
offset: u64,
}
/// `struct drm_mode_fb_cmd2`.
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct ModeFbCmd2 {
fb_id: u32,
width: u32,
height: u32,
pixel_format: u32,
flags: u32,
handles: [u32; 4],
pitches: [u32; 4],
offsets: [u32; 4],
modifier: [u64; 4],
}
/// `struct drm_mode_crtc_page_flip`.
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct ModePageFlip {
crtc_id: u32,
fb_id: u32,
flags: u32,
reserved: u32,
user_data: u64,
}
/// `struct drm_mode_cursor` — ustawienie/przesunięcie kursora sprzętowego.
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct ModeCursor {
flags: u32,
crtc_id: u32,
x: i32,
y: i32,
width: u32,
height: u32,
handle: u32,
}
/// Opakowanie na ioctl typu „read-write” z jednym struktem wejścia/wyjścia.
struct Rw<T, const OP: u32>(MaybeUninit<T>);
impl<T, const OP: u32> Rw<T, OP> {
fn new(value: T) -> Self {
Self(MaybeUninit::new(value))
}
}
unsafe impl<T: Copy, const OP: u32> rustix::ioctl::Ioctl for Rw<T, OP> {
type Output = T;
const OPCODE: rustix::ioctl::Opcode =
rustix::ioctl::Opcode::old(OP as rustix::ioctl::RawOpcode);
const IS_MUTATING: bool = true;
fn as_ptr(&mut self) -> *mut core::ffi::c_void {
self.0.as_mut_ptr().cast()
}
unsafe fn output_from_ptr(
_: rustix::ioctl::IoctlOutput,
extract: *mut core::ffi::c_void,
) -> rustix::io::Result<Self::Output> {
Ok(*(extract as *const T))
}
}
/// Wykonuje ioctl „read-write” i zwraca zmodyfikowany strukt.
fn ioctl_rw<T: Copy, const OP: u32, F: AsFd>(fd: F, value: T) -> rustix::io::Result<T> {
unsafe { rustix::ioctl::ioctl(fd, Rw::<T, OP>::new(value)) }
}
/// ioctl bez argumentu (np. `SET_MASTER`/`DROP_MASTER`).
struct NoArg<const OP: u32>;
unsafe impl<const OP: u32> rustix::ioctl::Ioctl for NoArg<OP> {
type Output = ();
const OPCODE: rustix::ioctl::Opcode =
rustix::ioctl::Opcode::old(OP as rustix::ioctl::RawOpcode);
const IS_MUTATING: bool = false;
fn as_ptr(&mut self) -> *mut core::ffi::c_void {
core::ptr::null_mut()
}
unsafe fn output_from_ptr(
_: rustix::ioctl::IoctlOutput,
_: *mut core::ffi::c_void,
) -> rustix::io::Result<()> {
Ok(())
}
}
fn ioctl_none<const OP: u32, F: AsFd>(fd: F) -> rustix::io::Result<()> {
unsafe { rustix::ioctl::ioctl(fd, NoArg::<OP>) }
}
/// Lista ścieżek kart DRM (`/dev/dri/card*`), posortowana.
fn card_paths() -> Vec<PathBuf> {
let mut cards: Vec<PathBuf> = std::fs::read_dir("/dev/dri")
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.map(|n| n.to_string_lossy().starts_with("card"))
.unwrap_or(false)
})
.collect();
cards.sort();
cards
}
/// Szczegółowy raport wykrywania (`SELENE_DRM_PROBE=1`).
///
/// Nie zmienia trybu i nie wymaga bycia DRM masterem — wypisuje wszystkie karty,
/// konektory i tryby zgłoszone przez EDID, razem z tym, który Selene by wybrał.
pub fn probe_report() -> String {
let mut out = String::new();
let mut used = false;
for card in card_paths() {
let Ok(fd) = rustix::fs::open(&card, OFlags::RDWR | OFlags::CLOEXEC, Mode::empty()) else {
continue;
};
let master = ioctl_none::<DRM_IOCTL_SET_MASTER, _>(&fd).is_ok();
if master {
let _ = ioctl_none::<DRM_IOCTL_DROP_MASTER, _>(&fd);
}
let Some(res) = fetch_resources(fd.as_fd()) else {
out.push_str(&format!(
"{}: master={} (nie można odczytać zasobów)\n",
card.display(),
master
));
continue;
};
let mut connected = 0usize;
let mut report = String::new();
for id in &res.connectors {
let Some((c, modes)) = read_connector(fd.as_fd(), *id) else {
continue;
};
let name = connector_name(c.connector_type, c.connector_type_id);
if c.connection != DRM_MODE_CONNECTED {
report.push_str(&format!(" {name}: disconnected\n"));
continue;
}
connected += 1;
report.push_str(&format!(
" {name}: connected, {}x{} mm, tryby={}\n",
c.mm_width,
c.mm_height,
modes.len()
));
for m in &modes {
let preferred = m.type_ & DRM_MODE_TYPE_PREFERRED != 0;
report.push_str(&format!(
" {}x{}@{} Hz{}\n",
m.hdisplay,
m.vdisplay,
m.vrefresh,
if preferred { " [PREFERRED]" } else { "" }
));
}
if let Some(best) = pick_best_mode(&modes) {
report.push_str(&format!(
" → wybrany: {}x{}@{} Hz{}\n",
best.hdisplay,
best.vdisplay,
best.vrefresh,
if best.type_ & DRM_MODE_TYPE_PREFERRED != 0 {
" [PREFERRED]"
} else {
""
}
));
}
}
out.push_str(&format!(
"{}: master={} konektory={} podłączone={}{}\n",
card.display(),
master,
res.connectors.len(),
connected,
if !used && connected > 0 {
" ← discover() użyje tej karty"
} else {
""
}
));
out.push_str(&report);
if connected > 0 {
used = true;
}
}
if out.is_empty() {
out.push_str("brak kart DRM w /dev/dri\n");
}
out
}
/// Odczytuje konektor wraz z pełną listą trybów (bez zmiany trybu).
fn read_connector(fd: BorrowedFd, id: u32) -> Option<(ModeGetConnector, Vec<ModeInfo>)> {
let c = ioctl_rw::<ModeGetConnector, DRM_IOCTL_MODE_GETCONNECTOR, _>(
fd,
ModeGetConnector {
connector_id: id,
..ModeGetConnector::default()
},
)
.ok()?;
let count = c.count_modes as usize;
if c.connection != DRM_MODE_CONNECTED || count == 0 {
return Some((c, Vec::new()));
}
let mut modes = vec![ModeInfo::default(); count];
let c = ioctl_rw::<ModeGetConnector, DRM_IOCTL_MODE_GETCONNECTOR, _>(
fd,
ModeGetConnector {
connector_id: id,
count_modes: count as u32,
modes_ptr: modes.as_mut_ptr() as u64,
..ModeGetConnector::default()
},
)
.ok()?;
Some((c, modes))
}
/// Wykrywa monitory. `None` oznacza, że DRM/KMS nie jest dostępne (użyj wyjścia nested).
///
/// Preferujemy kartę, na której **można zostać masterem** — dokładnie tę samą, którą
/// wybierze [`DrmBackend::open`]. Wcześniej braliśmy pierwszą kartę z podłączonymi
/// monitorami, więc na maszynie z dwoma GPU model wyjść mógł pochodzić z innej karty
/// niż faktyczny scanout.
pub fn discover() -> Option<Vec<OutputInfo>> {
let stats = std::env::var("SELENE_STATS").is_ok();
let mut fallback: Option<Vec<OutputInfo>> = None;
for card in card_paths() {
let Ok(fd) = rustix::fs::open(&card, OFlags::RDWR | OFlags::CLOEXEC, Mode::empty()) else {
continue;
};
let master = ioctl_none::<DRM_IOCTL_SET_MASTER, _>(&fd).is_ok();
if master {
let _ = ioctl_none::<DRM_IOCTL_DROP_MASTER, _>(&fd);
}
let Some(mut outs) = probe_card(fd.as_fd()) else {
continue;
};
if outs.is_empty() {
continue;
}
layout_horizontal(&mut outs);
if stats {
eprintln!(
"selene/drm: {} master={} wyjścia={}",
card.display(),
master,
outs.len()
);
}
if master {
return Some(outs);
}
fallback.get_or_insert(outs);
}
fallback
}
/// Odpytuje jedną kartę DRM o podłączone konektory.
fn probe_card(fd: BorrowedFd) -> Option<Vec<OutputInfo>> {
// Najpierw liczymy konektory (bez tablic).
let res =
ioctl_rw::<ModeCardRes, DRM_IOCTL_MODE_GETRESOURCES, _>(fd, ModeCardRes::default()).ok()?;
let count = res.count_connectors as usize;
if count == 0 {
return Some(Vec::new());
}
// Pobieramy identyfikatory konektorów.
let mut ids = vec![0u32; count];
let req = ModeCardRes {
count_connectors: count as u32,
connector_id_ptr: ids.as_mut_ptr() as u64,
..ModeCardRes::default()
};
ioctl_rw::<ModeCardRes, DRM_IOCTL_MODE_GETRESOURCES, _>(fd, req).ok()?;
let mut outs = Vec::new();
for id in ids {
if let Some(info) = probe_connector(fd, id) {
outs.push(info);
}
}
Some(outs)
}
/// Odpytuje jeden konektor: status, tryby, wybiera tryb natywny (lub pierwszy).
fn probe_connector(fd: BorrowedFd, id: u32) -> Option<OutputInfo> {
let c = ioctl_rw::<ModeGetConnector, DRM_IOCTL_MODE_GETCONNECTOR, _>(
fd,
ModeGetConnector {
connector_id: id,
..ModeGetConnector::default()
},
)
.ok()?;
if c.connection != DRM_MODE_CONNECTED {
return None;
}
let mode_count = c.count_modes as usize;
if mode_count == 0 {
return None;
}
let mut modes = vec![ModeInfo::default(); mode_count];
let req = ModeGetConnector {
connector_id: id,
count_modes: mode_count as u32,
modes_ptr: modes.as_mut_ptr() as u64,
..ModeGetConnector::default()
};
let c = ioctl_rw::<ModeGetConnector, DRM_IOCTL_MODE_GETCONNECTOR, _>(fd, req).ok()?;
let mode = pick_best_mode(&modes)?;
Some(OutputInfo {
name: connector_name(c.connector_type, c.connector_type_id),
width: mode.hdisplay as i32,
height: mode.vdisplay as i32,
refresh_hz: mode.vrefresh as i32,
scale: 1,
x: 0,
y: 0,
})
}
/// Wybiera tryb monitora: **najpierw tryb natywny z EDID** (`PREFERRED`), a dopiero
/// potem największa rozdzielczość i najwyższy `vrefresh`.
///
/// Wcześniej kolejność była odwrotna („największa rozdzielczość, potem PREFERRED”),
/// więc monitor o natywnym 1600×900, który dodatkowo zgłasza 1920×1080, dostawał
/// 1920×1080 — obraz był skalowany przez monitor i wyglądał na rozmyty.
fn pick_best_mode(modes: &[ModeInfo]) -> Option<&ModeInfo> {
modes.iter().max_by_key(|m| {
let area = m.hdisplay as u64 * m.vdisplay as u64;
let hz = m.vrefresh as u64;
let preferred = u64::from(m.type_ & DRM_MODE_TYPE_PREFERRED != 0);
(preferred, area, hz)
})
}
/// Nazwa konektora w stylu Wayland (np. `HDMI-A-1`, `eDP-1`).
fn connector_name(kind: u32, index: u32) -> String {
let base = match kind {
1 => "VGA",
2 => "DVI-I",
3 => "DVI-D",
4 => "DVI-A",
5 => "Composite",
6 => "LVDS",
7 => "Component",
8 => "DIN",
9 => "DP",
10 => "HDMI-A",
11 => "HDMI-B",
12 => "TV",
13 => "eDP",
14 => "Virtual",
15 => "DSI",
16 => "DPI",
17 => "Writeback",
_ => "Connector",
};
format!("{base}-{index}")
}
// ————————————————————————————————————————————————————————————————————————
// Scanout: realne wyjście na monitory przez KMS (dumb buffer + page-flip)
// ————————————————————————————————————————————————————————————————————————
/// Zasoby karty: identyfikatory konektorów, CRTC i enkoderów.
struct Resources {
connectors: Vec<u32>,
crtcs: Vec<u32>,
#[allow(dead_code)]
encoders: Vec<u32>,
}
fn fetch_resources(fd: BorrowedFd) -> Option<Resources> {
let res =
ioctl_rw::<ModeCardRes, DRM_IOCTL_MODE_GETRESOURCES, _>(fd, ModeCardRes::default()).ok()?;
let (cn, cc, ce) = (
res.count_connectors as usize,
res.count_crtcs as usize,
res.count_encoders as usize,
);
let (mut connectors, mut crtcs, mut encoders) =
(vec![0u32; cn], vec![0u32; cc], vec![0u32; ce]);
let req = ModeCardRes {
count_connectors: cn as u32,
connector_id_ptr: connectors.as_mut_ptr() as u64,
count_crtcs: cc as u32,
crtc_id_ptr: crtcs.as_mut_ptr() as u64,
count_encoders: ce as u32,
encoder_id_ptr: encoders.as_mut_ptr() as u64,
..ModeCardRes::default()
};
ioctl_rw::<ModeCardRes, DRM_IOCTL_MODE_GETRESOURCES, _>(fd, req).ok()?;
Some(Resources {
connectors,
crtcs,
encoders,
})
}
/// Bufor „dumb” (bez GPU): sterta pamięci zmapowana do renderowania i skanowania.
struct DumbBuffer {
fb_id: u32,
/// Uchwyt GEM (potrzebny m.in. do kursora sprzętowego).
handle: u32,
pitch: usize,
size: usize,
ptr: *mut u8,
}
// Bezpieczne: bufor należy wyłącznie do wątku wyjścia i nie jest współdzielony.
unsafe impl Send for DumbBuffer {}
impl Drop for DumbBuffer {
fn drop(&mut self) {
if !self.ptr.is_null() {
unsafe {
let _ = munmap(self.ptr.cast(), self.size);
}
}
}
}
/// Jedno wyjście DRM: konektor + CRTC + podwójny bufor.
struct DrmOutput {
name: String,
crtc_id: u32,
/// Rozmiar sprzętowej tablicy gamma (0 = brak wsparcia dla night lightu).
gamma_size: u32,
width: u32,
height: u32,
hz: u32,
buffers: [DumbBuffer; 2],
front: usize,
/// Czy `present` musi przepisywać kanały (bufor `XRGB8888` zamiast `XBGR8888`).
swizzle: bool,
/// Bufor obrazu kursora sprzętowego (tworzony leniwie).
cursor: Option<DumbBuffer>,
}
/// Powód, dla którego nie udało się otworzyć wyjścia DRM (do diagnostyki).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DrmError {
/// Inna sesja trzyma mastera DRM — nie odbieramy ekranu.
MasterBusy,
/// Nie udało się odczytać zasobów karty (`GETRESOURCES`).
Resources,
/// Nie udało się ustawić trybu na żadnym podłączonym monitorze.
NoOutputs,
}
/// Backend wyjścia DRM/KMS (jedna karta, wiele monitorów).
pub struct DrmBackend {
fd: OwnedFd,
outputs: Vec<DrmOutput>,
}
impl Drop for DrmBackend {
fn drop(&mut self) {
// Zwolnij bycie masterem, gdy kończymy.
let _ = ioctl_none::<DRM_IOCTL_DROP_MASTER, _>(&self.fd);
}
}
impl DrmBackend {
/// Otwiera kartę, przejmuje mastera i ustawia tryb na wszystkich podłączonych monitorach.
/// Zwraca `None`, gdy DRM jest niedostępne lub ktoś inny trzyma mastera (np. sesja użytkownika).
pub fn open() -> Result<Self, DrmError> {
let mut last = DrmError::NoOutputs;
for card in card_paths() {
let Ok(fd) = rustix::fs::open(&card, OFlags::RDWR | OFlags::CLOEXEC, Mode::empty())
else {
continue;
};
if ioctl_none::<DRM_IOCTL_SET_MASTER, _>(&fd).is_err() {
last = DrmError::MasterBusy; // inna sesja trzyma mastera
continue;
}
let Some(res) = fetch_resources(fd.as_fd()) else {
let _ = ioctl_none::<DRM_IOCTL_DROP_MASTER, _>(&fd);
last = DrmError::Resources;
continue;
};
let mut outputs = Vec::new();
let mut used_crtcs = Vec::new();
let mut skipped = 0usize;
for &conn in &res.connectors {
if let Some(out) = setup_output(fd.as_fd(), conn, &res, &mut used_crtcs) {
outputs.push(out);
} else {
skipped += 1;
}
}
if std::env::var("SELENE_STATS").is_ok() {
eprintln!(
"selene/drm: skonfigurowano {} wyjść (pominięto {} z {} złączy)",
outputs.len(),
skipped,
res.connectors.len()
);
}
if outputs.is_empty() {
let _ = ioctl_none::<DRM_IOCTL_DROP_MASTER, _>(&fd);
last = DrmError::NoOutputs;
continue;
}
return Ok(Self { fd, outputs });
}
Err(last)
}
/// `(nazwa, szerokość, wysokość, odświeżanie)` każdego wyjścia.
pub fn outputs(&self) -> Vec<(String, u32, u32, u32)> {
self.outputs
.iter()
.map(|o| (o.name.clone(), o.width, o.height, o.hz))
.collect()
}
/// Czy którykolwiek CRTC ma sprzętową tablicę gamma (night light).
pub fn supports_night_light(&self) -> bool {
self.outputs.iter().any(|o| o.gamma_size > 0)
}
/// Ustawia ciepłą korektę gamma na wszystkich wyjściach.
///
/// `percent` = 0 przywraca neutralną rampę, 100 to najcieplejszy wariant.
/// Zwraca `true`, jeśli udało się ustawić LUT na przynajmniej jednym CRTC.
pub fn set_night_light(&self, percent: u8) -> bool {
let strength = percent.min(100) as f64 / 100.0;
let mut any = false;
for out in &self.outputs {
if out.gamma_size == 0 {
continue;
}
if set_gamma_lut(self.fd.as_fd(), out.crtc_id, out.gamma_size, strength) {
any = true;
}
}
any
}
/// Wypycha klatkę (RGBA, wymiary = rozmiar wyjścia) i kolejkuje page-flip.
/// Po wywołaniu należy poczekać na [`DrmBackend::wait_flip`].
pub fn present(&mut self, idx: usize, rgba: &[u8]) -> bool {
let fd = &self.fd;
let Some(out) = self.outputs.get_mut(idx) else {
return false;
};
let back = (out.front + 1) & 1;
if out.swizzle {
swizzle_into(
out.buffers[back].ptr,
out.buffers[back].pitch,
rgba,
out.width as usize,
out.height as usize,
);
} else {
copy_rows_into(
out.buffers[back].ptr,
out.buffers[back].pitch,
rgba,
out.width as usize,
out.height as usize,
);
}
let req = ModePageFlip {
crtc_id: out.crtc_id,
fb_id: out.buffers[back].fb_id,
flags: DRM_MODE_PAGE_FLIP_EVENT,
reserved: 0,
user_data: idx as u64,
};
if ioctl_rw::<ModePageFlip, DRM_IOCTL_MODE_PAGE_FLIP, _>(fd, req).is_err() {
return false;
}
out.front = back;
true
}
/// Czeka na zakończenie page-flipu (zdarzenie z jądra).
pub fn wait_flip(&self) {
let mut buf = [0u8; 32];
loop {
match rustix::io::read(&self.fd, &mut buf) {
Ok(n) if n >= 8 => {
let ty = u32::from_ne_bytes([buf[0], buf[1], buf[2], buf[3]]);
if ty == DRM_EVENT_FLIP_COMPLETE {
return;
}
}
_ => return,
}
}
}
/// Ustawia obraz kursora sprzętowego na wyjściu `idx` w pozycji `(x, y)`.
///
/// `false`, gdy sterownik nie obsługuje kursora — wywołujący wraca do kursora
/// programowego (rysowanego w scenie).
pub fn set_cursor_image(
&mut self,
idx: usize,
rgba: &[u8],
w: u32,
h: u32,
x: i32,
y: i32,
) -> bool {
let fd = self.fd.as_fd();
let Some(out) = self.outputs.get_mut(idx) else {
return false;
};
if out.cursor.is_none() {
match create_dumb_fmt(fd, w, h, DRM_FORMAT_ARGB8888, false) {
Some(b) => out.cursor = Some(b),
None => return false,
}
}
let Some(cur) = out.cursor.as_mut() else {
return false;
};
swizzle_argb_into(cur.ptr, cur.pitch, rgba, w as usize, h as usize);
let req = ModeCursor {
flags: DRM_MODE_CURSOR_BO,
crtc_id: out.crtc_id,
x,
y,
width: w,
height: h,
handle: cur.handle,
};
ioctl_rw::<ModeCursor, DRM_IOCTL_MODE_CURSOR, _>(&self.fd, req).is_ok()
}
/// Przesuwa kursor sprzętowy na wyjściu `idx` (bez zmiany obrazu).
pub fn move_cursor(&mut self, idx: usize, x: i32, y: i32) -> bool {
let Some(crtc_id) = self.outputs.get(idx).map(|o| o.crtc_id) else {
return false;
};
let req = ModeCursor {
flags: DRM_MODE_CURSOR_MOVE,
crtc_id,
x,
y,
..ModeCursor::default()
};
ioctl_rw::<ModeCursor, DRM_IOCTL_MODE_CURSOR, _>(&self.fd, req).is_ok()
}
/// Wyłącza kursor sprzętowy na wyjściu `idx`.
pub fn hide_cursor(&mut self, idx: usize) -> bool {
let Some(crtc_id) = self.outputs.get(idx).map(|o| o.crtc_id) else {
return false;
};
let req = ModeCursor {
flags: DRM_MODE_CURSOR_BO,
crtc_id,
..ModeCursor::default()
};
ioctl_rw::<ModeCursor, DRM_IOCTL_MODE_CURSOR, _>(&self.fd, req).is_ok()
}
}
/// Konfiguruje jedno wyjście: tryb, bufor dumb, framebuffer i `SETCRTC`.
fn setup_output(
fd: BorrowedFd,
conn_id: u32,
res: &Resources,
used_crtcs: &mut Vec<u32>,
) -> Option<DrmOutput> {
let c = ioctl_rw::<ModeGetConnector, DRM_IOCTL_MODE_GETCONNECTOR, _>(
fd,
ModeGetConnector {
connector_id: conn_id,
..ModeGetConnector::default()
},
)
.ok()?;
if c.connection != DRM_MODE_CONNECTED {
return None;
}
let count = c.count_modes as usize;
if count == 0 {
return None;
}
let mut modes = vec![ModeInfo::default(); count];
let c = ioctl_rw::<ModeGetConnector, DRM_IOCTL_MODE_GETCONNECTOR, _>(
fd,
ModeGetConnector {
connector_id: conn_id,
count_modes: count as u32,
modes_ptr: modes.as_mut_ptr() as u64,
..ModeGetConnector::default()
},
)
.ok()?;
let mode = *pick_best_mode(&modes)?;
// Enkoder → CRTC. Po przejęciu ekranu CRTC po poprzedniej sesji bywa jeszcze
// zajęty (`set_crtc` kończy się `-EBUSY`) — dlatego próbujemy **kolejnych**
// kandydatów. Wcześniej poddawaliśmy się po pierwszej nieudanej próbie i losowo
// brakowało jednego monitora („działa tylko jeden ekran”).
let enc = ioctl_rw::<ModeGetEncoder, DRM_IOCTL_MODE_GETENCODER, _>(
fd,
ModeGetEncoder {
encoder_id: c.encoder_id,
..ModeGetEncoder::default()
},
)
.ok()?;
// Kolejność kandydatów: obecny CRTC enkodera → wg maski `possible_crtcs` → reszta karty.
let mut candidates: Vec<u32> = Vec::new();
if enc.crtc_id != 0 {
candidates.push(enc.crtc_id);
}
for (idx, &crtc) in res.crtcs.iter().enumerate() {
if (enc.possible_crtcs & (1 << idx)) != 0 {
candidates.push(crtc);
}
}
candidates.extend(res.crtcs.iter().copied());
let width = mode.hdisplay as u32;
let height = mode.vdisplay as u32;
// Preferujemy `XBGR8888`: pamięć to R,G,B,X, czyli dokładnie wyjście tiny-skia.
// `present` kopiuje wtedy wiersze bez przepisywania kanałów (oszczędność ~1–2 ms
// na klatkę 1080p). Gdy sterownik nie wspiera formatu — wracamy do `XRGB8888`.
let mut swizzle = false;
let make_pair = |fmt: u32| -> Option<(DumbBuffer, DumbBuffer)> {
Some((
create_dumb(fd, width, height, fmt)?,
create_dumb(fd, width, height, fmt)?,
))
};
let (b0, b1) = match make_pair(DRM_FORMAT_XBGR8888) {
Some(pair) => pair,
None => {
swizzle = true;
make_pair(DRM_FORMAT_XRGB8888)?
}
};
let mut crtc_id = None;
for cand in candidates {
if used_crtcs.contains(&cand) {
continue;
}
if set_crtc(fd, cand, b0.fb_id, conn_id, &mode) {
crtc_id = Some(cand);
break;
}
if std::env::var("SELENE_STATS").is_ok() {
eprintln!("selene/drm: CRTC {cand} zajęty — próbuję następny");
}
}
let crtc_id = crtc_id?;
// CRTC oznaczamy jako użyty **dopiero po udanym** `set_crtc`.
used_crtcs.push(crtc_id);
Some(DrmOutput {
name: connector_name(c.connector_type, c.connector_type_id),
crtc_id,
gamma_size: gamma_size(fd, crtc_id),
width,
height,
hz: mode.vrefresh,
buffers: [b0, b1],
front: 0,
swizzle,
cursor: None,
})
}
/// Tworzy bufor dumb, mapuje go i rejestruje jako framebuffer (scanout).
fn create_dumb(fd: BorrowedFd, width: u32, height: u32, format: u32) -> Option<DumbBuffer> {
create_dumb_fmt(fd, width, height, format, true)
}
/// Tworzy bufor dumb w zadanym formacie; `register_fb` dodaje też framebuffer
/// (niepotrzebne dla kursora, gdzie liczy się tylko uchwyt GEM).
fn create_dumb_fmt(
fd: BorrowedFd,
width: u32,
height: u32,
format: u32,
register_fb: bool,
) -> Option<DumbBuffer> {
let dumb = ioctl_rw::<ModeCreateDumb, DRM_IOCTL_MODE_CREATE_DUMB, _>(
fd,
ModeCreateDumb {
width,
height,
bpp: 32,
..ModeCreateDumb::default()
},
)
.ok()?;
let map = ioctl_rw::<ModeMapDumb, DRM_IOCTL_MODE_MAP_DUMB, _>(
fd,
ModeMapDumb {
handle: dumb.handle,
..ModeMapDumb::default()
},
)
.ok()?;
let ptr = unsafe {
mmap(
core::ptr::null_mut(),
dumb.size as usize,
ProtFlags::READ | ProtFlags::WRITE,
MapFlags::SHARED,
fd,
map.offset,
)
}
.ok()? as *mut u8;
let fb_id = if register_fb {
ioctl_rw::<ModeFbCmd2, DRM_IOCTL_MODE_ADDFB2, _>(
fd,
ModeFbCmd2 {
width,
height,
pixel_format: format,
handles: [dumb.handle, 0, 0, 0],
pitches: [dumb.pitch, 0, 0, 0],
..ModeFbCmd2::default()
},
)
.ok()?
.fb_id
} else {
0
};
Some(DumbBuffer {
fb_id,
handle: dumb.handle,
pitch: dumb.pitch as usize,
size: dumb.size as usize,
ptr,
})
}
/// Odczytuje rozmiar sprzętowej tablicy gamma CRTC (0 = brak wsparcia).
fn gamma_size(fd: BorrowedFd, crtc_id: u32) -> u32 {
ioctl_rw::<ModeCrtc, DRM_IOCTL_MODE_GETCRTC, _>(
fd,
ModeCrtc {
crtc_id,
..ModeCrtc::default()
},
)
.map(|c| c.gamma_size)
.unwrap_or(0)
}
/// Buduje neutralną lub ciepłą rampę gamma.
///
/// `strength` = 0 daje idealną przekątną (brak zmiany kolorów), a 1 najmocniej
/// tłumi kanał niebieski. Wartości mają pełny zakres 16 bitów na kanał.
fn gamma_ramp(size: u32, strength: f64) -> (Vec<u16>, Vec<u16>, Vec<u16>) {
let n = size as usize;
let mut red = vec![0u16; n];
let mut green = vec![0u16; n];
let mut blue = vec![0u16; n];
if n == 0 {
return (red, green, blue);
}
let strength = strength.clamp(0.0, 1.0);
let last = (n - 1).max(1) as f64;
for i in 0..n {
let level = i as f64 / last * 65535.0;
red[i] = level.round() as u16;
green[i] = (level * (1.0 - 0.10 * strength)).round() as u16;
blue[i] = (level * (1.0 - 0.38 * strength)).round() as u16;
}
(red, green, blue)
}
/// Wgrywa tablicę gamma na CRTC (`SETGAMMA`).
fn set_gamma_lut(fd: BorrowedFd, crtc_id: u32, size: u32, strength: f64) -> bool {
if size == 0 {
return false;
}
let (red, green, blue) = gamma_ramp(size, strength);
let lut = ModeCrtcLut {
crtc_id,
gamma_size: size,
red: red.as_ptr() as u64,
green: green.as_ptr() as u64,
blue: blue.as_ptr() as u64,
};
ioctl_rw::<ModeCrtcLut, DRM_IOCTL_MODE_SETGAMMA, _>(fd, lut).is_ok()
}
/// Ustawia tryb na CRTC (`SETCRTC`).
fn set_crtc(fd: BorrowedFd, crtc_id: u32, fb_id: u32, connector_id: u32, mode: &ModeInfo) -> bool {
let mut connectors = [connector_id];
let req = ModeCrtc {
set_connectors_ptr: connectors.as_mut_ptr() as u64,
count_connectors: 1,
crtc_id,
fb_id,
mode_valid: 1,
mode: *mode,
..ModeCrtc::default()
};
ioctl_rw::<ModeCrtc, DRM_IOCTL_MODE_SETCRTC, _>(fd, req).is_ok()
}
/// Przepisuje RGBA do bufora kursora w formacie `ARGB8888` (kolejność B,G,R,A).
fn swizzle_argb_into(dst: *mut u8, pitch: usize, rgba: &[u8], width: usize, height: usize) {
if dst.is_null() || rgba.len() < width * height * 4 {
return;
}
unsafe {
for y in 0..height {
let src = &rgba[y * width * 4..(y + 1) * width * 4];
let row = dst.add(y * pitch);
for x in 0..width {
let s = &src[x * 4..x * 4 + 4];
let d = row.add(x * 4);
*d = s[2];
*d.add(1) = s[1];
*d.add(2) = s[0];
*d.add(3) = s[3];
}
}
}
}
/// Przepisuje klatkę RGBA do bufora w formacie `XRGB8888` (kolejność B,G,R,X).
fn swizzle_into(dst: *mut u8, pitch: usize, rgba: &[u8], width: usize, height: usize) {
if dst.is_null() || rgba.len() < width * height * 4 {
return;
}
unsafe {
for y in 0..height {
let src = &rgba[y * width * 4..(y + 1) * width * 4];
let row_dst = std::slice::from_raw_parts_mut(dst.add(y * pitch) as *mut u32, width);
for (d, s) in row_dst.iter_mut().zip(src.chunks_exact(4)) {
*d = u32::from_ne_bytes([s[2], s[1], s[0], 0xff]);
}
}
}
}
/// Kopiuje klatkę RGBA do bufora `XBGR8888` (ta sama kolejność bajtów w pamięci)
/// wiersz po wierszu. Gdy `pitch` zgadza się z szerokością, to jeden `memcpy`.
fn copy_rows_into(dst: *mut u8, pitch: usize, rgba: &[u8], width: usize, height: usize) {
if dst.is_null() || rgba.len() < width * height * 4 {
return;
}
let row_bytes = width * 4;
unsafe {
if pitch == row_bytes {
std::ptr::copy_nonoverlapping(rgba.as_ptr(), dst, row_bytes * height);
} else {
for y in 0..height {
std::ptr::copy_nonoverlapping(
rgba.as_ptr().add(y * row_bytes),
dst.add(y * pitch),
row_bytes,
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connector_names_follow_wayland_convention() {
assert_eq!(connector_name(13, 1), "eDP-1");
assert_eq!(connector_name(10, 2), "HDMI-A-2");
assert_eq!(connector_name(9, 1), "DP-1");
assert_eq!(connector_name(999, 0), "Connector-0");
}
#[test]
fn ioctl_struct_sizes_match_kernel_padding() {
// Rozmiary z nagłówków DRM — sprawdzają poprawność `#[repr(C)]` na 64-bit.
assert_eq!(core::mem::size_of::<ModeCardRes>(), 64);
assert_eq!(core::mem::size_of::<ModeGetConnector>(), 80);
assert_eq!(core::mem::size_of::<ModeInfo>(), 68);
assert_eq!(core::mem::size_of::<ModeGetEncoder>(), 20);
assert_eq!(core::mem::size_of::<ModeCrtc>(), 104);
assert_eq!(core::mem::size_of::<ModeCreateDumb>(), 32);
assert_eq!(core::mem::size_of::<ModeMapDumb>(), 16);
assert_eq!(core::mem::size_of::<ModeFbCmd2>(), 104);
assert_eq!(core::mem::size_of::<ModePageFlip>(), 24);
assert_eq!(core::mem::size_of::<ModeCursor>(), 28);
}
#[test]
#[ignore = "benchmark reczny"]
fn bench_present_copy() {
let (w, h) = (1920usize, 1080usize);
let src = vec![7u8; w * h * 4];
let mut dst = vec![0u8; w * h * 4];
let n = 20;
let t = std::time::Instant::now();
for _ in 0..n {
copy_rows_into(
std::hint::black_box(dst.as_mut_ptr()),
w * 4,
std::hint::black_box(&src),
w,
h,
);
}
let _ = std::hint::black_box(dst[12345]);
println!(
"copy_rows_into (XBGR8888): {:.2} ms/klatke",
t.elapsed().as_secs_f64() * 1000.0 / n as f64
);
let t = std::time::Instant::now();
for _ in 0..n {
swizzle_into(
std::hint::black_box(dst.as_mut_ptr()),
w * 4,
std::hint::black_box(&src),
w,
h,
);
}
let _ = std::hint::black_box(dst[12345]);
println!(
"swizzle_into (XRGB8888): {:.2} ms/klatke",
t.elapsed().as_secs_f64() * 1000.0 / n as f64
);
}
#[test]
fn xrgb8888_fourcc_is_little_endian_bgrx() {
// 'X','R','2','4' — kontroluje kolejność bajtów przy przepisywaniu klatki.
assert_eq!(DRM_FORMAT_XRGB8888.to_le_bytes(), *b"XR24");
// 'A','R','2','4' — format kursora (z kanałem alfa).
assert_eq!(DRM_FORMAT_ARGB8888.to_le_bytes(), *b"AR24");
// 'X','B','2','4' — w pamięci R,G,B,X, czyli wprost wyjście tiny-skia.
assert_eq!(DRM_FORMAT_XBGR8888.to_le_bytes(), *b"XB24");
}
#[test]
fn copy_rows_matches_rgba_layout() {
// 2×2 RGBA → bufor XBGR8888: bajty bez zmian, tylko wiersze wg `pitch`.
let src: Vec<u8> = (0..16).collect();
let mut dst = vec![0u8; 16];
copy_rows_into(dst.as_mut_ptr(), 8, &src, 2, 2);
assert_eq!(dst, src);
// Większy `pitch` (padding w wierszu) — kopiowane tylko 8 bajtów na wiersz.
let mut dst = vec![0xAAu8; 24];
copy_rows_into(dst.as_mut_ptr(), 12, &src, 2, 2);
assert_eq!(&dst[0..8], &src[0..8]);
assert_eq!(&dst[12..20], &src[8..16]);
// Padding pozostaje nietknięty.
assert!(dst[8..12].iter().all(|&b| b == 0xAA));
}
fn mode(w: u16, h: u16, hz: u32, preferred: bool) -> ModeInfo {
ModeInfo {
hdisplay: w,
vdisplay: h,
vrefresh: hz,
type_: if preferred {
DRM_MODE_TYPE_PREFERRED
} else {
0
},
..ModeInfo::default()
}
}
#[test]
fn picks_highest_resolution_then_refresh() {
let modes = [
mode(1280, 720, 60, false),
mode(1920, 1080, 60, false),
mode(1920, 1080, 144, false),
];
let best = pick_best_mode(&modes).unwrap();
assert_eq!(
(best.hdisplay, best.vdisplay, best.vrefresh),
(1920, 1080, 144)
);
}
#[test]
fn native_mode_wins_over_larger_scale() {
// Monitor o natywnym 1600x900, który dodatkowo zgłasza 1920x1080 — bierzemy natywny.
let modes = [
mode(1920, 1080, 60, false),
mode(1600, 900, 60, true),
mode(1280, 720, 60, false),
];
let best = pick_best_mode(&modes).unwrap();
assert_eq!((best.hdisplay, best.vdisplay), (1600, 900));
}
#[test]
fn fallback_ignores_missing_preferred_flag() {
// Gdy EDID nie oznacza żadnego trybu jako natywny — bierzemy największy.
let modes = [mode(1280, 720, 60, false), mode(1920, 1080, 60, false)];
assert_eq!(pick_best_mode(&modes).unwrap().hdisplay, 1920);
}
#[test]
fn preferred_mode_breaks_ties() {
let modes = [mode(1920, 1080, 60, false), mode(1920, 1080, 60, true)];
assert!(pick_best_mode(&modes).unwrap().type_ & DRM_MODE_TYPE_PREFERRED != 0);
}
#[test]
fn drm_gamma_structs_match_kernel_abi() {
assert_eq!(std::mem::size_of::<ModeCrtcLut>(), 32);
assert_eq!(std::mem::size_of::<ModeCrtc>(), 104);
assert_eq!(DRM_IOCTL_MODE_GETCRTC, 0xC068_64A1);
assert_eq!(DRM_IOCTL_MODE_SETGAMMA, 0xC020_64A4);
}
#[test]
fn neutral_gamma_is_identity() {
let (r, g, b) = gamma_ramp(256, 0.0);
assert_eq!(r, g);
assert_eq!(g, b);
assert_eq!(r[0], 0);
assert_eq!(r[255], 65535);
}
#[test]
fn warm_gamma_reduces_blue_most() {
let (r, g, b) = gamma_ramp(256, 0.6);
assert_eq!(r[0], 0);
assert_eq!(r[255], 65535);
assert!(b[128] < g[128], "niebieski powinien być najciemniejszy");
assert!(
g[128] <= r[128],
"zielony nie powinien przebijać czerwonego"
);
// Biały punkt staje się cieplejszy — niebieski nie dobija do pełnej skali.
assert!(b[255] < 65535);
}
}