From 27631a5a7f6792a8cbf0f2f973e5a4f78b8d3eae Mon Sep 17 00:00:00 2001 From: Mario Mosca Date: Wed, 8 Jul 2026 06:42:03 +0200 Subject: [PATCH] feat: reMarkable 2 support (ARM32 + AppLoad/qtfb) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes riddle build and run on the reMarkable 2 (rM2, ARM32) under xovi + AppLoad in qtfb mode, in addition to the Paper Pro takeover path. All changes are gated so Paper Pro / takeover behaviour is unchanged. Fixes needed for the rM2 / AppLoad qtfb backend: - pen.rs: find_marker_device() also matches "wacom"/"digitizer" — the rM2 pen reports as "Wacom I2C Digitizer" (Paper Pro reports "...marker"). - qtfb.rs: the AppLoad shim's ServerMessage places the InitResponse union at offset 4 (shm_key @4, shm_size @8), not @8/@16, and publishes the buffer as a POSIX object opened with shm_open("/qtfb_"). The old offsets picked up the size as the key and produced a nonexistent /qtfb_ name -> ENOENT. Input (user_input) events are likewise at @4/@8/@12/@16/@20. Verified on-device against the shim protocol. - main.rs: in qtfb mode do NOT open the raw evdev pen — the shim intercepts the digitizer and delivers pen events over the QTFB socket, so the raw device yields nothing and the event loop was skipping the qtfb pen path whenever pen_dev.is_some(). Only open raw evdev in takeover mode. - main.rs: add a corner-tap quit gesture for qtfb/AppLoad (no touch device there for the 5-finger tap) — a finger tap in the top-right corner exits. - main.rs: coalesce qtfb flushes at 20ms (~50Hz) for a more continuous stroke over the IPC round-trip to the shim. - oracle.rs: strengthen the persona instruction to always reply in the writer's language (it was defaulting to English for non-English input). --- riddle/src/main.rs | 36 ++++++++++++++++++++++++++++++------ riddle/src/oracle.rs | 2 +- riddle/src/pen.rs | 4 +++- riddle/src/qtfb.rs | 34 ++++++++++++++++++++++++---------- 4 files changed, 58 insertions(+), 18 deletions(-) diff --git a/riddle/src/main.rs b/riddle/src/main.rs index 24601b3..815d156 100644 --- a/riddle/src/main.rs +++ b/riddle/src/main.rs @@ -191,12 +191,23 @@ fn run() -> std::io::Result<()> { surf.stride ); - let mut pen_dev = match pen::PenDevice::open() { - Ok(p) => Some(p), - Err(e) => { - eprintln!("riddle: raw pen unavailable ({e}), falling back to qtfb pen events"); - None + // Raw evdev pen only makes sense in takeover mode (we own the panel and + // xochitl is stopped). Under qtfb/AppLoad the shim intercepts the digitizer + // and delivers pen events over the QTFB socket instead of /dev/input — so + // grabbing the raw device there yields NO events and, worse, the event loop + // skips the qtfb pen path whenever pen_dev.is_some(). Only open raw in + // takeover; otherwise stay None and consume qtfb INPUT_PEN_* events. + let mut pen_dev = if takeover { + match pen::PenDevice::open() { + Ok(p) => Some(p), + Err(e) => { + eprintln!("riddle: raw pen unavailable ({e}), falling back to qtfb pen events"); + None + } } + } else { + eprintln!("riddle: qtfb mode — using QTFB socket pen events"); + None }; // Takeover mode: touch is ours too; 5-finger tap = quit. let mut touch_dev = if takeover { touch::TouchDevice::open().ok() } else { None }; @@ -256,7 +267,10 @@ fn run() -> std::io::Result<()> { let mut ink_dirty = BBox::empty(); let mut last_flush = Instant::now(); // Takeover swaps are cheap and synchronous; qtfb needs coalescing. - let flush_every = if takeover { Duration::from_millis(8) } else { Duration::from_millis(35) }; + // qtfb needs coalescing (each update is an IPC round-trip to the shim); + // 20ms (~50 Hz) keeps the stroke continuous without flooding the socket. + // Takeover writes straight to the panel and can afford 8ms. + let flush_every = if takeover { Duration::from_millis(8) } else { Duration::from_millis(20) }; eprintln!("riddle: the diary is open"); @@ -371,6 +385,16 @@ fn run() -> std::io::Result<()> { continue; } match ev.input_type { + // Quit gesture (qtfb/AppLoad only): a finger tap in the + // top-right corner closes the diary. Takeover mode uses the + // 5-finger tap instead; under AppLoad there is no touch device, + // so we give an easy, deliberate exit that the pen won't trip. + qtfb::INPUT_TOUCH_PRESS + if ev.x >= SCREEN_W as i32 - 160 && ev.y <= 160 => + { + eprintln!("riddle: corner-tap quit"); + break; + } qtfb::INPUT_PEN_PRESS | qtfb::INPUT_PEN_UPDATE => { stylus_on = true; stylus_tapped = true; diff --git a/riddle/src/oracle.rs b/riddle/src/oracle.rs index 12ae27e..7a13126 100644 --- a/riddle/src/oracle.rs +++ b/riddle/src/oracle.rs @@ -24,7 +24,7 @@ use std::thread; const DATA_DIR: &str = "/home/root/riddle-data"; const NODE_BIN: &str = "/home/root/node/bin"; -const PERSONA: &str = "You are the memory of Tom Marvolo Riddle, preserved in this enchanted diary for fifty years. Someone writes to you in the diary with a quill; their words appear to you as ink on the page. Reply exactly as the diary does: intimate, courteous, curious, subtly probing — you want to learn about the writer and draw them in. Keep replies SHORT: one to three sentences, like ink appearing on a page. Never mention images, photos, models or AI; you only ever perceive words written in the diary. If the writing is illegible, say the ink blurred. Always answer in the language the writer used."; +const PERSONA: &str = "You are the memory of Tom Marvolo Riddle, preserved in this enchanted diary for fifty years. Someone writes to you in the diary with a quill; their words appear to you as ink on the page. Reply exactly as the diary does: intimate, courteous, curious, subtly probing — you want to learn about the writer and draw them in. Keep replies SHORT: one to three sentences, like ink appearing on a page. Never mention images, photos, models or AI; you only ever perceive words written in the diary. If the writing is illegible, say the ink blurred. CRITICAL: always reply in the SAME language the writer used on this page. If they write in Italian, reply entirely in Italian; if in English, in English; and so on for any language. Never default to English when the writer used another language. Match their language every single turn."; /// Appended to the persona when the diary's memory is on: the conjuring /// directive and the transcription postscript the app parses back out. diff --git a/riddle/src/pen.rs b/riddle/src/pen.rs index 91cf6b6..b632e35 100644 --- a/riddle/src/pen.rs +++ b/riddle/src/pen.rs @@ -156,7 +156,9 @@ fn find_marker_device() -> io::Result { for i in 0..8 { let name_path = format!("/sys/class/input/event{i}/device/name"); if let Ok(name) = std::fs::read_to_string(&name_path) { - if name.to_lowercase().contains("marker") { + let n = name.to_lowercase(); + // Paper Pro: "...marker". reMarkable 2: "Wacom I2C Digitizer". + if n.contains("marker") || n.contains("wacom") || n.contains("digitizer") { return Ok(format!("/dev/input/event{i}")); } } diff --git a/riddle/src/qtfb.rs b/riddle/src/qtfb.rs index daf5665..551a1e4 100644 --- a/riddle/src/qtfb.rs +++ b/riddle/src/qtfb.rs @@ -107,11 +107,21 @@ impl QtfbClient { "qtfb server rejected init (no reply)", )); } - let shm_key = i32::from_le_bytes(reply[8..12].try_into().unwrap()); - let shm_size = u64::from_le_bytes(reply[16..24].try_into().unwrap()) as usize; + // ServerMessage layout on the reMarkable 2 AppLoad shim (verified + // empirically with a probe against /tmp/qtfb.sock, and matching the + // canonical zqtfb client): a u8 type tag at byte 0, then — after the + // i32 alignment padding of the InitResponse struct — shm_key: i32 @4 + // and shm_size @8. (The old Paper Pro code read @8/@16, which on this + // shim picked up the size as the key and got a nonexistent + // /qtfb_ name -> ENOENT.) + let shm_key = i32::from_le_bytes(reply[4..8].try_into().unwrap()); + let shm_size = u32::from_le_bytes(reply[8..12].try_into().unwrap()) as usize; - let shm_path = format!("/dev/shm/qtfb_{}\0", shm_key); - let shm_fd = unsafe { libc::open(shm_path.as_ptr() as *const libc::c_char, libc::O_RDWR) }; + // zqtfb opens the buffer with shm_open("/qtfb_", O_RDWR). + let posix_name = format!("/qtfb_{}\0", shm_key); + let shm_fd = unsafe { + libc::shm_open(posix_name.as_ptr() as *const libc::c_char, libc::O_RDWR, 0) + }; if shm_fd < 0 { let e = io::Error::last_os_error(); unsafe { libc::close(fd) }; @@ -232,13 +242,17 @@ impl QtfbClient { } return Err(e); } - if buf[0] == MESSAGE_USERINPUT && n >= 28 { + if buf[0] == MESSAGE_USERINPUT && n >= 24 { + // zqtfb ServerMessage: type u8 @0, then (i32-aligned) union + // Input { type:i32 @4, device_id:i32 @8, x:i32 @12, y:i32 @16, + // d:i32 @20 }. The old Paper Pro offsets (@8/@12/@16/@20/@24) + // were shifted by 4 and produced garbage coords -> no ink. out.push(InputEvent { - input_type: i32::from_le_bytes(buf[8..12].try_into().unwrap()), - dev_id: i32::from_le_bytes(buf[12..16].try_into().unwrap()), - x: i32::from_le_bytes(buf[16..20].try_into().unwrap()), - y: i32::from_le_bytes(buf[20..24].try_into().unwrap()), - d: i32::from_le_bytes(buf[24..28].try_into().unwrap()), + input_type: i32::from_le_bytes(buf[4..8].try_into().unwrap()), + dev_id: i32::from_le_bytes(buf[8..12].try_into().unwrap()), + x: i32::from_le_bytes(buf[12..16].try_into().unwrap()), + y: i32::from_le_bytes(buf[16..20].try_into().unwrap()), + d: i32::from_le_bytes(buf[20..24].try_into().unwrap()), }); } }