use crate::core::cache::HasSize;
use crate::types::{Color, Fixed};
use alloc::vec::Vec;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ColorFormat {
RGB565,
RGB565Swapped,
RGB888,
RGBA8888,
BGRA8888,
}
impl ColorFormat {
pub const fn bytes_per_pixel(self) -> usize {
match self {
Self::RGB565 | Self::RGB565Swapped => 2,
Self::RGB888 => 3,
Self::RGBA8888 | Self::BGRA8888 => 4,
}
}
pub fn pack(self, color: &Color) -> u32 {
match self {
Self::RGBA8888 => {
(color.r as u32)
| ((color.g as u32) << 8)
| ((color.b as u32) << 16)
| ((color.a as u32) << 24)
}
Self::BGRA8888 => {
(color.b as u32)
| ((color.g as u32) << 8)
| ((color.r as u32) << 16)
| ((color.a as u32) << 24)
}
Self::RGB888 => (color.r as u32) | ((color.g as u32) << 8) | ((color.b as u32) << 16),
Self::RGB565 => {
let px = ((color.r as u16 >> 3) << 11)
| ((color.g as u16 >> 2) << 5)
| (color.b as u16 >> 3);
px as u32
}
Self::RGB565Swapped => {
let px = ((color.r as u16 >> 3) << 11)
| ((color.g as u16 >> 2) << 5)
| (color.b as u16 >> 3);
((px >> 8) as u32) | (((px & 0xFF) as u32) << 8)
}
}
}
}
pub enum TexBuf<'a> {
Ref(&'a [u8]),
Mut(&'a mut [u8]),
Owned(Vec<u8>),
}
impl TexBuf<'_> {
pub fn as_slice(&self) -> &[u8] {
match self {
Self::Ref(s) => s,
Self::Mut(s) => s,
Self::Owned(v) => v,
}
}
pub fn as_mut_slice(&mut self) -> &mut [u8] {
match self {
Self::Ref(data) => {
*self = Self::Owned(data.to_vec());
match self {
Self::Owned(v) => v,
_ => unreachable!(),
}
}
Self::Mut(s) => s,
Self::Owned(v) => v,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AlphaMode {
#[default]
Opaque,
Blend,
}
pub struct Texture<'a> {
pub buf: TexBuf<'a>,
pub width: u16,
pub height: u16,
pub format: ColorFormat,
pub stride: usize,
pub alpha_mode: AlphaMode,
pub transient: bool,
}
impl HasSize for Texture<'_> {
fn cache_size(&self) -> usize {
self.buf.as_slice().len()
}
}
impl Clone for Texture<'static> {
fn clone(&self) -> Self {
let buf = match &self.buf {
TexBuf::Ref(s) => TexBuf::Ref(s),
TexBuf::Owned(v) => TexBuf::Owned(v.clone()),
TexBuf::Mut(_) => {
unreachable!("Texture<'static> with TexBuf::Mut is not constructible")
}
};
Self {
buf,
width: self.width,
height: self.height,
format: self.format,
stride: self.stride,
alpha_mode: self.alpha_mode,
transient: self.transient,
}
}
}
impl<'a> Texture<'a> {
pub const fn from_static(buf: &'a [u8], width: u16, height: u16, format: ColorFormat) -> Self {
let stride = width as usize * format.bytes_per_pixel();
Self {
buf: TexBuf::Ref(buf),
width,
height,
format,
stride,
alpha_mode: AlphaMode::Opaque,
transient: false,
}
}
pub fn new(buf: &'a mut [u8], width: u16, height: u16, format: ColorFormat) -> Self {
let stride = width as usize * format.bytes_per_pixel();
Self {
buf: TexBuf::Mut(buf),
width,
height,
format,
stride,
alpha_mode: AlphaMode::Opaque,
transient: false,
}
}
pub fn from_ref(buf: &'a [u8], width: u16, height: u16, format: ColorFormat) -> Self {
let stride = width as usize * format.bytes_per_pixel();
Self {
buf: TexBuf::Ref(buf),
width,
height,
format,
stride,
alpha_mode: AlphaMode::Opaque,
transient: false,
}
}
pub fn owned(width: u16, height: u16, format: ColorFormat) -> Self {
let stride = width as usize * format.bytes_per_pixel();
let buf = alloc::vec![0u8; stride * height as usize];
Self {
buf: TexBuf::Owned(buf),
width,
height,
format,
stride,
alpha_mode: AlphaMode::Opaque,
transient: false,
}
}
pub fn with_transient(mut self, transient: bool) -> Self {
self.transient = transient;
self
}
#[inline(always)]
fn offset(&self, x: i32, y: i32) -> Option<usize> {
if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 {
return None;
}
Some(y as usize * self.stride + x as usize * self.format.bytes_per_pixel())
}
#[inline(always)]
pub fn get_pixel(&self, x: i32, y: i32) -> Color {
let Some(i) = self.offset(x, y) else {
return Color::rgb(0, 0, 0);
};
let buf = self.buf.as_slice();
match self.format {
ColorFormat::RGBA8888 => Color::rgba(buf[i], buf[i + 1], buf[i + 2], buf[i + 3]),
ColorFormat::BGRA8888 => Color::rgba(buf[i + 2], buf[i + 1], buf[i], buf[i + 3]),
ColorFormat::RGB888 => Color::rgb(buf[i], buf[i + 1], buf[i + 2]),
ColorFormat::RGB565 => {
let lo = buf[i] as u16;
let hi = buf[i + 1] as u16;
let px = lo | (hi << 8);
Color::rgb(
((px >> 11) as u8) << 3,
(((px >> 5) & 0x3F) as u8) << 2,
((px & 0x1F) as u8) << 3,
)
}
ColorFormat::RGB565Swapped => {
let hi = buf[i] as u16;
let lo = buf[i + 1] as u16;
let px = lo | (hi << 8);
Color::rgb(
((px >> 11) as u8) << 3,
(((px >> 5) & 0x3F) as u8) << 2,
((px & 0x1F) as u8) << 3,
)
}
}
}
#[inline(always)]
pub fn set_pixel(&mut self, x: i32, y: i32, color: &Color) {
let Some(i) = self.offset(x, y) else { return };
let buf = self.buf.as_mut_slice();
match self.format {
ColorFormat::RGBA8888 => {
buf[i] = color.r;
buf[i + 1] = color.g;
buf[i + 2] = color.b;
buf[i + 3] = color.a;
}
ColorFormat::BGRA8888 => {
buf[i] = color.b;
buf[i + 1] = color.g;
buf[i + 2] = color.r;
buf[i + 3] = color.a;
}
ColorFormat::RGB888 => {
buf[i] = color.r;
buf[i + 1] = color.g;
buf[i + 2] = color.b;
}
ColorFormat::RGB565 | ColorFormat::RGB565Swapped => {
let px = ((color.r as u16 >> 3) << 11)
| ((color.g as u16 >> 2) << 5)
| (color.b as u16 >> 3);
let (b0, b1) = if self.format == ColorFormat::RGB565 {
(px as u8, (px >> 8) as u8)
} else {
((px >> 8) as u8, px as u8)
};
buf[i] = b0;
buf[i + 1] = b1;
}
}
}
#[inline(always)]
pub fn blend_pixel(&mut self, x: Fixed, y: Fixed, color: &Color, opa: u8) {
if opa == 0 {
return;
}
if x.is_integer() && y.is_integer() {
let a = ((color.a as u16) * (opa as u16) / 255) as u8;
self.blend_pixel_int(x.to_int(), y.to_int(), color, a);
return;
}
self.blend_pixel_subpixel(x, y, color, opa);
}
#[cold]
#[inline(never)]
fn blend_pixel_subpixel(&mut self, x: Fixed, y: Fixed, color: &Color, opa: u8) {
let ix = x.to_int();
let iy = y.to_int();
let fx = x.fract();
let fy = y.fract();
let lx = Fixed::ONE - fx;
let ty = Fixed::ONE - fy;
let nc = color.normalized();
let opa_norm = Fixed::from_int(opa as i32).map_range((0, 255), (Fixed::ZERO, Fixed::ONE));
let base_a = nc.a * opa_norm;
let to_alpha = |cov: Fixed| -> u8 { (base_a * cov).map01(255).to_int() as u8 };
self.blend_pixel_int(ix, iy, color, to_alpha(lx * ty));
self.blend_pixel_int(ix + 1, iy, color, to_alpha(fx * ty));
self.blend_pixel_int(ix, iy + 1, color, to_alpha(lx * fy));
self.blend_pixel_int(ix + 1, iy + 1, color, to_alpha(fx * fy));
}
#[inline(always)]
pub fn blend_pixel_int(&mut self, x: i32, y: i32, color: &Color, a: u8) {
if a == 0 {
return;
}
if a == 255 {
self.set_pixel(x, y, color);
return;
}
let dst = self.get_pixel(x, y);
let ia = 255 - a as u32;
let aa = a as u32;
let blend = |src: u8, dst: u8| -> u8 {
let sum = src as u32 * aa + dst as u32 * ia + 127;
((sum + (sum >> 8)) >> 8) as u8
};
let out_a = match self.alpha_mode {
AlphaMode::Opaque => 255,
AlphaMode::Blend => {
let src_a = a as u32;
let dst_a = dst.a as u32;
let inv = 255 - src_a;
let sum = src_a * 255 + dst_a * inv + 127;
((sum + (sum >> 8)) >> 8) as u8
}
};
let out = Color {
r: blend(color.r, dst.r),
g: blend(color.g, dst.g),
b: blend(color.b, dst.b),
a: out_a,
};
self.set_pixel(x, y, &out);
}
#[inline(always)]
pub fn composite_pixel_int(
&mut self,
x: i32,
y: i32,
color: &Color,
src_a: u8,
mode: crate::render::command::CompositeMode,
) {
if src_a == 0 {
return;
}
if matches!(mode, crate::render::command::CompositeMode::SourceOver) {
self.blend_pixel_int(x, y, color, src_a);
return;
}
let dst = self.get_pixel(x, y);
let aa = src_a as u32;
let ia = 255 - aa;
let fold = |src: u8, dst: u8| -> u8 {
let m = mode.blend_channel(src, dst) as u32;
let sum = m * aa + dst as u32 * ia + 127;
((sum + (sum >> 8)) >> 8) as u8
};
let out_a = match self.alpha_mode {
AlphaMode::Opaque => 255,
AlphaMode::Blend => {
let dst_a = dst.a as u32;
let sum = aa * 255 + dst_a * ia + 127;
((sum + (sum >> 8)) >> 8) as u8
}
};
let out = Color {
r: fold(color.r, dst.r),
g: fold(color.g, dst.g),
b: fold(color.b, dst.b),
a: out_a,
};
self.set_pixel(x, y, &out);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MirxLoadError {
Parse(mirx::ParseError),
UnsupportedFormat(mirx::ColorFormat),
NoImageChunk,
DimensionOverflow,
}
impl From<mirx::ParseError> for MirxLoadError {
fn from(err: mirx::ParseError) -> Self {
MirxLoadError::Parse(err)
}
}
impl From<core::num::TryFromIntError> for MirxLoadError {
fn from(_: core::num::TryFromIntError) -> Self {
MirxLoadError::DimensionOverflow
}
}
fn map_mirx_format(fmt: mirx::ColorFormat) -> Result<ColorFormat, MirxLoadError> {
match fmt {
mirx::ColorFormat::RGB565 => Ok(ColorFormat::RGB565),
mirx::ColorFormat::RGB565Swapped => Ok(ColorFormat::RGB565Swapped),
mirx::ColorFormat::RGB888 => Ok(ColorFormat::RGB888),
mirx::ColorFormat::XRGB8888 | mirx::ColorFormat::RGBA8888 => Ok(ColorFormat::RGBA8888),
mirx::ColorFormat::BGRA8888 => Ok(ColorFormat::BGRA8888),
other => Err(MirxLoadError::UnsupportedFormat(other)),
}
}
impl Texture<'static> {
pub fn from_mirx(bytes: &'static [u8]) -> Result<Self, MirxLoadError> {
let parsed = mirx::parse(bytes)?;
let (width, height, stride, format, main) = match parsed {
mirx::MirxFile::Flat(img) => (img.width, img.height, img.stride, img.format, img.main),
mirx::MirxFile::Chunk(file) => {
let img = file.primary_image.ok_or(MirxLoadError::NoImageChunk)?;
(img.width, img.height, img.stride, img.format, img.data)
}
};
let format = map_mirx_format(format)?;
let width: u16 = width.try_into()?;
let height: u16 = height.try_into()?;
Ok(Texture {
buf: TexBuf::Ref(main),
width,
height,
format,
stride: stride as usize,
alpha_mode: AlphaMode::Opaque,
transient: false,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TextureMeta {
pub width: u16,
pub height: u16,
pub format: ColorFormat,
}
impl HasSize for TextureMeta {
fn cache_size(&self) -> usize {
1
}
}
impl crate::core::resource::HasProbe for Texture<'static> {
type Meta = TextureMeta;
fn extract_meta(&self) -> TextureMeta {
TextureMeta {
width: self.width,
height: self.height,
format: self.format,
}
}
}
impl From<MirxLoadError> for crate::core::resource::LoadError {
fn from(err: MirxLoadError) -> Self {
crate::core::resource::LoadError::Failed(match err {
MirxLoadError::Parse(_) => "mirx parse failed",
MirxLoadError::UnsupportedFormat(_) => "mirx format not supported by mirui",
MirxLoadError::NoImageChunk => "mirx file has no IMAGE chunk",
MirxLoadError::DimensionOverflow => "mirx image dimensions exceed u16",
})
}
}
pub struct MirxLoader<F> {
fetch: F,
}
impl<F> MirxLoader<F>
where
F: Fn(&str) -> Option<&'static [u8]> + 'static,
{
pub fn new(fetch: F) -> Self {
Self { fetch }
}
}
impl<F> crate::core::resource::Loader<Texture<'static>> for MirxLoader<F>
where
F: Fn(&str) -> Option<&'static [u8]> + 'static,
{
fn try_load(&self, token: &str) -> Result<Texture<'static>, crate::core::resource::LoadError> {
let bytes = (self.fetch)(token).ok_or(crate::core::resource::LoadError::NotMine)?;
Texture::from_mirx(bytes).map_err(Into::into)
}
}
impl crate::core::resource::ResourceManager<Texture<'static>> {
pub fn add_mirx_bytes(
&self,
token: impl Into<alloc::borrow::Cow<'static, str>>,
bytes: &'static [u8],
) -> Result<(), MirxLoadError> {
use crate::core::resource::HasProbe;
let meta = Texture::from_mirx(bytes)?.extract_meta();
self.add_probed_factory(token, meta, move || Texture::from_mirx(bytes).ok());
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn argb8888_roundtrip() {
let mut buf = [0u8; 4];
let mut tex = Texture::new(&mut buf, 1, 1, ColorFormat::RGBA8888);
let c = Color::rgba(100, 200, 50, 255);
tex.set_pixel(0, 0, &c);
assert_eq!(tex.get_pixel(0, 0), c);
}
#[test]
fn bgra8888_roundtrip() {
let mut buf = [0u8; 4];
let mut tex = Texture::new(&mut buf, 1, 1, ColorFormat::BGRA8888);
let c = Color::rgba(100, 200, 50, 255);
tex.set_pixel(0, 0, &c);
assert_eq!(tex.get_pixel(0, 0), c);
assert_eq!(buf, [c.b, c.g, c.r, c.a]);
}
#[test]
fn bgra8888_pack_byte_order() {
let c = Color::rgba(0xAA, 0xBB, 0xCC, 0xDD);
let bgra = ColorFormat::BGRA8888.pack(&c);
assert_eq!(bgra & 0xFF, c.b as u32);
assert_eq!((bgra >> 8) & 0xFF, c.g as u32);
assert_eq!((bgra >> 16) & 0xFF, c.r as u32);
assert_eq!((bgra >> 24) & 0xFF, c.a as u32);
}
#[test]
fn rgb565_roundtrip() {
let mut buf = [0u8; 2];
let mut tex = Texture::new(&mut buf, 1, 1, ColorFormat::RGB565);
let c = Color::rgb(248, 252, 248); tex.set_pixel(0, 0, &c);
let got = tex.get_pixel(0, 0);
assert_eq!(got.r, c.r);
assert_eq!(got.g, c.g);
assert_eq!(got.b, c.b);
}
#[test]
fn blend_50_percent() {
let mut buf = [0u8; 4];
let mut tex = Texture::new(&mut buf, 1, 1, ColorFormat::RGBA8888);
tex.set_pixel(0, 0, &Color::rgb(0, 0, 0));
tex.blend_pixel(Fixed::ZERO, Fixed::ZERO, &Color::rgb(200, 100, 50), 128);
let got = tex.get_pixel(0, 0);
assert!((got.r as i32 - 100).abs() <= 1);
assert!((got.g as i32 - 50).abs() <= 1);
assert!((got.b as i32 - 25).abs() <= 1);
}
#[test]
fn blend_rgb565() {
let mut buf = [0u8; 2];
let mut tex = Texture::new(&mut buf, 1, 1, ColorFormat::RGB565);
tex.set_pixel(0, 0, &Color::rgb(0, 0, 0));
tex.blend_pixel(Fixed::ZERO, Fixed::ZERO, &Color::rgb(255, 255, 255), 255);
let got = tex.get_pixel(0, 0);
assert_eq!(got.r, 248);
assert_eq!(got.g, 252);
assert_eq!(got.b, 248);
}
#[test]
fn blend_subpixel_spreads_to_neighbors() {
let mut buf = [0u8; 4 * 4]; let mut tex = Texture::new(&mut buf, 2, 2, ColorFormat::RGBA8888);
tex.set_pixel(0, 0, &Color::rgb(0, 0, 0));
tex.set_pixel(1, 0, &Color::rgb(0, 0, 0));
tex.set_pixel(0, 1, &Color::rgb(0, 0, 0));
tex.set_pixel(1, 1, &Color::rgb(0, 0, 0));
tex.blend_pixel(Fixed::HALF, Fixed::HALF, &Color::rgb(255, 255, 255), 255);
let tl = tex.get_pixel(0, 0);
let tr = tex.get_pixel(1, 0);
let bl = tex.get_pixel(0, 1);
let br = tex.get_pixel(1, 1);
assert!(tl.r > 0, "top-left should have coverage");
assert!(tr.r > 0, "top-right should have coverage");
assert!(bl.r > 0, "bottom-left should have coverage");
assert!(br.r > 0, "bottom-right should have coverage");
let sum = tl.r as u16 + tr.r as u16 + bl.r as u16 + br.r as u16;
assert!(
(sum as i32 - 255).abs() <= 12,
"total coverage sum={sum} should be ~255 (±12 for 24.8 precision)"
);
}
use crate::core::resource::HasProbe;
fn build_flat_rgb565_2x1() -> &'static [u8] {
let input = mirx::FlatImageInput {
width: 2,
height: 1,
stride: 4,
format: mirx::ColorFormat::RGB565,
main: &[0xAA, 0xBB, 0xCC, 0xDD],
extra: None,
};
Box::leak(mirx::encode_flat(&input).into_boxed_slice())
}
#[test]
fn from_mirx_flat_rgb565_round_trip() {
let tex = Texture::from_mirx(build_flat_rgb565_2x1()).expect("flat parse");
assert_eq!(tex.width, 2);
assert_eq!(tex.height, 1);
assert_eq!(tex.format, ColorFormat::RGB565);
assert_eq!(tex.alpha_mode, AlphaMode::Opaque);
assert_eq!(tex.buf.as_slice(), &[0xAA, 0xBB, 0xCC, 0xDD]);
}
#[test]
fn from_mirx_zero_copy_borrow() {
let bytes = build_flat_rgb565_2x1();
let tex = Texture::from_mirx(bytes).unwrap();
let pix_ptr = tex.buf.as_slice().as_ptr();
let buf_ptr = bytes.as_ptr();
assert_eq!(pix_ptr as usize - buf_ptr as usize, 28);
}
#[test]
fn from_mirx_bad_magic_propagates_parse_error() {
let bad: &'static [u8] = Box::leak(Box::new([0u8; 8]));
assert!(matches!(
Texture::from_mirx(bad),
Err(MirxLoadError::Parse(mirx::ParseError::BadMagic))
));
}
#[test]
fn extract_meta_returns_geometry() {
let tex = Texture::from_mirx(build_flat_rgb565_2x1()).unwrap();
let meta = tex.extract_meta();
assert_eq!(meta.width, 2);
assert_eq!(meta.height, 1);
assert_eq!(meta.format, ColorFormat::RGB565);
}
use crate::core::cache::MaxSize;
use crate::core::resource::ResourceManager;
fn texture_manager() -> ResourceManager<Texture<'static>> {
let m = ResourceManager::<Texture<'static>>::new(
MaxSize::Bytes(1024),
Texture::from_mirx(build_flat_rgb565_2x1()).unwrap(),
);
m.enable_probes(
MaxSize::Count(8),
TextureMeta {
width: 0,
height: 0,
format: ColorFormat::RGB565,
},
);
m
}
#[test]
fn add_mirx_bytes_populates_probe_and_value() {
let m = texture_manager();
let bytes = build_flat_rgb565_2x1();
m.add_mirx_bytes("logo", bytes).expect("register ok");
assert_eq!(
m.probe("logo"),
Some(TextureMeta {
width: 2,
height: 1,
format: ColorFormat::RGB565,
})
);
let tex = m.resolve("logo");
assert_eq!(tex.width, 2);
assert_eq!(tex.format, ColorFormat::RGB565);
}
#[test]
fn add_mirx_bytes_rejects_bad_input() {
let m = texture_manager();
let bad: &'static [u8] = Box::leak(Box::new([0u8; 8]));
let err = m.add_mirx_bytes("bad", bad).unwrap_err();
assert!(matches!(
err,
MirxLoadError::Parse(mirx::ParseError::BadMagic)
));
}
#[test]
fn mirx_loader_resolves_via_chain() {
let m = texture_manager();
let bytes = build_flat_rgb565_2x1();
m.add_loader(MirxLoader::new(move |t| {
if t == "via-loader" { Some(bytes) } else { None }
}));
let tex = m.resolve("via-loader");
assert_eq!(tex.width, 2);
assert_eq!(tex.format, ColorFormat::RGB565);
}
#[test]
fn mirx_loader_returns_not_mine_when_fetch_misses() {
let m = texture_manager();
let bytes = build_flat_rgb565_2x1();
m.add_loader(MirxLoader::new(move |t| {
if t == "logo" { Some(bytes) } else { None }
}));
let tex = m.resolve("nope");
assert_eq!(tex.width, 2, "unhandled tokens fall through to fallback");
}
}