diff --git a/Cargo.lock b/Cargo.lock index 412b183..c7e85ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,9 +87,9 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -105,9 +105,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cc" -version = "1.2.63" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", "shlex", @@ -220,9 +220,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "funty" @@ -253,9 +253,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libremarkable" @@ -273,9 +273,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lz-fear" diff --git a/Cargo.toml b/Cargo.toml index 87a8f38..92f15c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,8 +4,13 @@ version = "1.5.0" authors = ["Rien Maertens "] edition = "2024" +[features] +default = [] +# Pen cursor overlay — requires libremarkable (armhf/rM2 only, not aarch64/Tatsu) +cursor = ["libremarkable"] + [dependencies] anyhow = "1.0" lz-fear = "0.2" clap = { version = "4.5", features = [ "derive" ] } -libremarkable = { version = "0.7", features = [ "input" ], default-features = false } +libremarkable = { version = "0.7", features = [ "input" ], default-features = false, optional = true } diff --git a/README.md b/README.md index 1a5fcb7..9b6810b 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ reMarkable screen sharing over SSH. [![rm1](https://img.shields.io/badge/rM1-supported-green)](https://remarkable.com/store/remarkable) [![rm2](https://img.shields.io/badge/rM2-supported-green)](https://remarkable.com/store/remarkable-2) +[![rmpaperpure](https://img.shields.io/badge/rM_Paper_Pure-supported-green)](docs/tatsu.md) ![A demo of reStream](extra/demo.gif) @@ -90,6 +91,8 @@ $ scp restream.arm.static root@10.11.99.1:/home/root/restream $ ssh root@10.11.99.1 'chmod +x /home/root/restream' ``` +> **Note for reMarkable Paper Pure users:** the Paper Pure uses an aarch64 processor and requires a separate binary. See [docs/tatsu.md](docs/tatsu.md) for build instructions. + ## Usage 1. Connect your reMarkable with the USB cable. @@ -179,6 +182,8 @@ If you want to play with the `restream` code, you will have to [install Rust](ht There are three ways of building the required restream binary for streaming the reMarkable framebuffer. For these approaches, the generated restream binary will be located under `target/armv7-unknown-linux-gnueabihf/release/restream`. +> **reMarkable Paper Pure:** requires an aarch64 binary instead. See [docs/tatsu.md](docs/tatsu.md). + - **Using nix flakes** With [Nix](https://nixos.org/guides/install-nix.html) installed you can create the development environment with `nix-shell` or (when using diff --git a/docs/tatsu.md b/docs/tatsu.md new file mode 100644 index 0000000..0316030 --- /dev/null +++ b/docs/tatsu.md @@ -0,0 +1,112 @@ +# reMarkable Paper Pure (Tatsu) Support + +## Device Overview + +| Property | Value | +|---|---| +| Product name | reMarkable Paper Pure | +| Internal codename | Tatsu | +| SoC | NXP i.MX 93 (aarch64, ARM Cortex-A55) | +| Kernel machine string | `reMarkable Tatsu` | +| OS version location | `/etc/os-release` → `IMG_VERSION` | +| Confirmed working on | firmware 3.28.0.172 | + +## Display Hardware + +| Property | Value | +|---|---| +| Panel | E Ink Carta™ 1300 | +| Physical resolution | 1872 × 1404 (landscape) | +| DRM driver | `eink-carta-panel` | +| DRM mode | 260×1408 (waveform output, not pixels) | + +The display is driven via DRM/KMS through `/dev/dri/card0`. The DRM mode +(260×1408) is the packed E Ink waveform output size, not the pixel resolution. + +## How It Works + +xochitl renders the UI into a **BGRA color buffer** in its own heap memory. This +is the same buffer its built-in screen share feature reads from. reStream reads +directly from this buffer via `/proc//mem`. + +### Finding the buffer + +The buffer is located by scanning `/proc//maps` for the first +anonymous `rw-p` mapping of 10–16 MB that appears immediately after the last +`/dev/dri/card0` entry. This is reliable across restarts as long as the firmware +version does not change. + +### Buffer layout + +The buffer stores the display content in **portrait orientation** (1404×1872), +regardless of how the device is held. The landscape content is rotated 90° CCW +into the portrait frame by xochitl. + +| Property | Value | +|---|---| +| Pixel format | BGRA (4 bytes/pixel) | +| Frame dimensions | 1404 × 1872 pixels | +| Stride | 1404 × 4 = 5616 bytes/row | +| Frame size | 1404 × 1872 × 4 = 10,513,152 bytes | + +### Skip offset + +The buffer contains a transparent (alpha=0) region before the visible content. +The skip offset is deliberately **non-row-aligned** so that reading wraps a fixed +number of columns into the next row, achieving a circular horizontal shift that +aligns the left edge of the canvas with the start of the output frame. This is +the same technique used for rM2. + +``` +skip = 468 blank rows × 5616 bytes/row + 340 transparent columns × 4 bytes + = 2,628,288 + 1,360 + = 2,629,648 bytes +``` + +The 340-column offset was confirmed by dumping the first row of content and +finding the first opaque pixel (`00 00 00 ff`) at byte offset 1360. + +## reStream.sh Parameters + +```sh +"reMarkable Tatsu") + width=1404 + height=1872 + bytes_per_pixel=4 + fb_file=":mem:" + pixel_format="bgra" + skip_offset=2629648 + dark_mode=false + if $landscape; then + extra_video_filters="transpose=2" + fi + landscape=false + ;; +``` + +`transpose=2` (90° CCW) rotates the portrait buffer back to landscape. +Use `-p` to stream in portrait orientation without any rotation. + +Note: `transpose=1` (the rM2 default via `landscape=true`) produces an +upside-down result on Tatsu — use `transpose=2` instead. + +## Building + +The binary must be built for `aarch64` (the Tatsu has no 32-bit compatibility): + +```sh +cross build --release --target=aarch64-unknown-linux-musl +scp target/aarch64-unknown-linux-musl/release/restream root@10.11.99.1:~/restream +``` + +The `--features cursor` build flag is **not supported** on Tatsu — it requires +`libremarkable` which is armhf-only. + +## DRM PRIME (investigated, not viable) + +DRM PRIME is a mechanism for sharing GPU memory buffers between processes via +file descriptors. On Tatsu, xochitl holds 17 GEM buffer handles for the E Ink +waveform pipeline. We investigated exporting these via `pidfd_getfd()` + +`DRM_IOCTL_PRIME_HANDLE_TO_FD`, but all 17 buffers contain E Ink waveform data +(electrical pulse sequences for the panel), not pixel data. The pixel data lives +in an anonymous heap mapping which PRIME cannot export. diff --git a/reStream.sh b/reStream.sh index 6cbce9a..f87972d 100755 --- a/reStream.sh +++ b/reStream.sh @@ -131,7 +131,11 @@ ssh_cmd() { } is_current_rm_firmware_version_ge() { - current_rm_firmware_version=$(ssh_cmd "grep 'REMARKABLE_RELEASE_VERSION=' /usr/share/remarkable/update.conf | cut -d '=' -f2") + # Try update.conf (rM1/rM2), fall back to /etc/os-release IMG_VERSION (Tatsu/newer) + current_rm_firmware_version=$(ssh_cmd "grep 'REMARKABLE_RELEASE_VERSION=' /usr/share/remarkable/update.conf 2>/dev/null | cut -d '=' -f2") + if [ -z "$current_rm_firmware_version" ]; then + current_rm_firmware_version=$(ssh_cmd "grep 'IMG_VERSION=' /etc/os-release 2>/dev/null | cut -d '=' -f2 | tr -d '\"'") + fi test "$(printf '%s\n' "$current_rm_firmware_version" "$1" | sort -rV | head -n 1)" = "$current_rm_firmware_version" } @@ -169,6 +173,21 @@ case "$rm_version" in skip_offset=8 ;; + "reMarkable Tatsu") + width=1404 + height=1872 + bytes_per_pixel=4 + fb_file=":mem:" + pixel_format="bgra" + skip_offset=2629648 + dark_mode=false + # The buffer is portrait (1404×1872). For landscape view (default), + # rotate 90° CCW. For portrait (-p), show as-is. + if $landscape; then + extra_video_filters="transpose=2" + fi + landscape=false + ;; "reMarkable 2.0") if ssh_cmd "[ -f /dev/shm/swtfb.01 ]"; then width=1404 @@ -313,6 +332,7 @@ set -e # stop if an error occurs restream_options="-h $height -w $width -b $bytes_per_pixel -f $fb_file -s $skip_offset" + if "$cursor"; then restream_options="$restream_options -c" fi diff --git a/src/main.rs b/src/main.rs index b9f78eb..88056e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,9 @@ use anyhow::{Context, Result}; use clap::Parser; use lz_fear::CompressionSettings; +#[cfg(feature = "cursor")] use libremarkable::cgmath; +#[cfg(feature = "cursor")] use libremarkable::input::{ev::EvDevContext, InputDevice, InputEvent, WacomEvent, WacomPen}; use std::default::Default; @@ -14,7 +16,9 @@ use std::fs::File; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; use std::net::{TcpListener, TcpStream}; use std::process::Command; -use std::sync::mpsc::{channel, Receiver}; +use std::sync::mpsc::channel; +#[cfg(feature = "cursor")] +use std::sync::mpsc::Receiver; use std::thread; use std::time::Duration; @@ -37,11 +41,11 @@ pub struct Opts { #[arg(long, name = "bytes", short = 'b')] bytes_per_pixel: usize, - /// File containing the framebuffer data. If this equals the string ':mem:' it will try to read the framebuffer from xochitl's process memory (rM2 only). + /// File containing the framebuffer data. If this equals the string ':mem:' it will try to read the framebuffer from xochitl's process memory. #[arg(long, name = "path", short = 'f')] file: String, - /// Show a cursor where the pen is hovering. + /// Show a cursor where the pen is hovering. Requires building with --features cursor. #[arg(long, name = "cursor", short = 'c')] show_cursor: bool, @@ -53,9 +57,13 @@ pub struct Opts { fn main() -> Result<()> { let ref opts: Opts = Opts::parse(); + if opts.show_cursor && !cfg!(feature = "cursor") { + eprintln!("Warning: cursor support requires building with --features cursor."); + } + let (file, offset) = if opts.file == ":mem:" { let pid = xochitl_pid()?; - let offset = rm2_fb_offset(pid, opts.skip)?; + let offset = fb_offset(pid, opts.skip)?; let mem = format!("/proc/{}/mem", pid); (mem, offset) } else { @@ -119,23 +127,52 @@ fn xochitl_pid() -> Result { } } -fn rm2_fb_offset(pid: usize, skip: usize) -> Result { +fn fb_offset(pid: usize, skip: usize) -> Result { let file = File::open(format!("/proc/{}/maps", &pid))?; - let line = BufReader::new(file) + let lines: Vec = BufReader::new(file) .lines() - .skip_while(|line| matches!(line, Ok(l) if !l.ends_with("/dev/fb0"))) - .skip(1) - .next() - .with_context(|| format!("No line containing /dev/fb0 in /proc/{}/maps file", pid))? - .with_context(|| format!("Error reading file /proc/{}/maps", pid))?; - - let addr = line - .split("-") - .next() - .with_context(|| format!("Error parsing line in /proc/{}/maps", pid))?; - - let address = usize::from_str_radix(addr, 16).context("Error parsing framebuffer address")?; - Ok(address + skip) + .collect::>()?; + + // rM1: framebuffer is the mapping immediately after /dev/fb0 + if let Some(pos) = lines.iter().position(|l| l.ends_with("/dev/fb0")) { + let line = lines.get(pos + 1) + .with_context(|| format!("No line after /dev/fb0 in /proc/{}/maps", pid))?; + let addr = line.split("-").next() + .with_context(|| format!("Error parsing line in /proc/{}/maps", pid))?; + let address = usize::from_str_radix(addr, 16) + .context("Error parsing framebuffer address")?; + return Ok(address + skip); + } + + // rM2 / Tatsu / Paper Pure: the BGRA color rendering buffer sits immediately + // after the last /dev/dri/card0 DRM waveform buffer. It is an anonymous + // rw-p mapping of ~10–16 MB. + if let Some(drm_last) = lines.iter().filter(|l| l.contains("/dev/dri/card0")).last() { + let drm_hi = drm_last.split('-').nth(1) + .and_then(|s| s.split_whitespace().next()) + .and_then(|s| usize::from_str_radix(s, 16).ok()) + .unwrap_or(0); + + if let Some(line) = lines.iter().find(|l| { + let parts: Vec<&str> = l.split_whitespace().collect(); + if parts.len() != 5 { return false; } + if let (Some(lo_s), Some(hi_s)) = (parts[0].split('-').next(), parts[0].split('-').nth(1)) { + if let (Ok(lo), Ok(hi)) = (usize::from_str_radix(lo_s, 16), usize::from_str_radix(hi_s, 16)) { + return lo >= drm_hi && (hi - lo) >= 10*1024*1024 && (hi - lo) <= 16*1024*1024; + } + } + false + }) { + let addr = line.split('-').next() + .context("Error parsing color buffer address")?; + let address = usize::from_str_radix(addr, 16) + .context("Error parsing color buffer address")?; + eprintln!("[rM] Color buffer at {:#x}", address); + return Ok(address + skip); + } + } + + Err(anyhow!("No framebuffer mapping found in /proc/{}/maps", pid)) } pub struct ReStreamer { @@ -143,13 +180,20 @@ pub struct ReStreamer { start: u64, cursor: usize, size: usize, + + #[cfg(feature = "cursor")] + show_cursor: bool, + #[cfg(feature = "cursor")] width: usize, + #[cfg(feature = "cursor")] height: usize, + #[cfg(feature = "cursor")] bytes_per_pixel: usize, - - show_cursor: bool, + #[cfg(feature = "cursor")] input_rx: Receiver, + #[cfg(feature = "cursor")] pen_pos: Option<(usize, usize)>, + #[cfg(feature = "cursor")] drawing: bool, } @@ -167,22 +211,34 @@ impl ReStreamer { let cursor = 0; let file = File::open(path)?; + #[cfg(feature = "cursor")] let (input_tx, input_rx) = channel::(); + #[cfg(feature = "cursor")] if show_cursor { EvDevContext::new(InputDevice::Wacom, input_tx).start(); } + #[cfg(not(feature = "cursor"))] + let _ = (width, height, bytes_per_pixel, show_cursor); + let mut streamer = ReStreamer { file, start: start, cursor, size, + #[cfg(feature = "cursor")] + show_cursor, + #[cfg(feature = "cursor")] width, + #[cfg(feature = "cursor")] height, + #[cfg(feature = "cursor")] bytes_per_pixel, - show_cursor, + #[cfg(feature = "cursor")] input_rx, + #[cfg(feature = "cursor")] pen_pos: None, + #[cfg(feature = "cursor")] drawing: false, }; streamer.next_frame()?; @@ -192,6 +248,7 @@ impl ReStreamer { pub fn next_frame(&mut self) -> std::io::Result<()> { self.file.seek(SeekFrom::Start(self.start))?; self.cursor = 0; + #[cfg(feature = "cursor")] if self.show_cursor { self.read_input(); } @@ -200,6 +257,7 @@ impl ReStreamer { } /// Read input events to figure out pen state and position. + #[cfg(feature = "cursor")] fn read_input(&mut self) { let mut down = self.pen_pos.is_some(); let mut pos = self.pen_pos.unwrap_or((0, 0)); @@ -233,6 +291,7 @@ impl ReStreamer { } /// Draw pen position into fb data, if necessary (in hover range and not drawing). + #[cfg(feature = "cursor")] fn draw_pen_position(&mut self, buf: &mut [u8]) { if let (false, Some((y, x))) = (self.drawing, self.pen_pos) { let flip = self.width > self.height; @@ -288,6 +347,7 @@ impl ReStreamer { } // Image of pen, given as (offset from pen position, number of pixels) +#[cfg(feature = "cursor")] static PEN_IMAGE: [(isize, isize); 17] = [ (0, 1), // 00000000100000000 (0, 1), // 00000000100000000 @@ -318,6 +378,7 @@ impl Read for ReStreamer { self.file.read(&mut buf[0..rest])? }; + #[cfg(feature = "cursor")] if self.show_cursor { self.draw_pen_position(&mut buf[0..bytes_read]); } @@ -329,3 +390,4 @@ impl Read for ReStreamer { Ok(bytes_read) } } +