TrendShift 딥다이브 · 2026-07-07 · Daily #11

riddle 딥다이브
— 종이가 잉크를 "마시고" 손글씨로 답한다: e-ink 태블릿을 마법 일기장으로 바꾸는 Rust 앱

riddlereMarkable Paper Pro e-ink 태블릿에 펜으로 글씨를 쓰면, 2.8초 뒤 페이지가 그 잉크를 스르륵 빨아들이고, 손으로 쓴 글씨를 통째로 사진 찍어 비전 LLM에 보낸 뒤, 그 답을 마치 보이지 않는 손이 쓰듯 흘림체로 한 획씩 그려 넣는 앱이다. 키보드도, 채팅창도, 백라이트도 없다 — 오직 종이 위에 나타났다 사라지는 잉크뿐. 해리 포터의 톰 리들의 일기장을 현실 하드웨어에서 재현한 인터페이스 실험이다. 겉보기는 마법이지만 속은 raw evdev 입력 · Zhang-Suen 골격화 · 벤더 e-ink 파형 엔진 직접 구동 · 의존성 최소화한 스트리밍 LLM 호출이 촘촘히 맞물린 Rust/C++ 코드다. (저장소: MaximeRivest/riddle · 제작자 Maxime Rivest · Rust 82% / C·C++ 14% · MIT(2026) · 대상 reMarkable Paper Pro(aarch64) · TrendShift 오늘 Daily 11위 · New 2026)
목차
  1. 프로젝트 한줄 요약
  2. 왜 주목받는가
  3. 기술 스택 전체 지도
  4. 아키텍처 심화 분석
  5. 디렉토리 구조 해부
  6. 학습 포인트 (기술별)
  7. 하드웨어/시스템 요구사항
  8. 직접 해볼 수 있는 실습 과제
  9. 관련 기술 심화 학습 로드맵
  10. 핵심 키워드 사전
  11. 참고 링크

1프로젝트 한줄 요약

이 저장소가 대체 무엇인가.

riddlereMarkable Paper Pro라는 전자잉크(e-ink) 태블릿을 위한 "마법 일기장" 앱이다. 스타일러스로 페이지에 글을 쓰고 잠깐(약 2.8초) 손을 멈추면, 앱이 그 잉크를 종이 속으로 흩어 없애고, 화면에 쓰인 손글씨를 이미지 한 장으로 만들어 비전(vision) LLM에게 보낸다. 그러면 모델의 답이 흘림체 손글씨로 한 획씩 종이 위에 나타났다가, 잠시 뒤 다시 스르륵 사라진다.

한 줄 개념
손으로 쓰면 손으로 답하는 종이 (handwriting-in, handwriting-out)
입력은 내가 쓴 진짜 손글씨, 출력은 모델이 쓴 가짜 손글씨. 그 사이에 텍스트 상자도, 전송 버튼도, 말풍선도 없다. 매체(종이+펜)가 곧 인터페이스다.
비유로 이해하기

"톰 리들의 일기장 — 잉크를 먹고, 보이지 않는 손이 답을 쓰는 종이"

해리 포터에서 톰 리들의 일기장은 글을 쓰면 그 잉크를 빨아들이고, 보이지 않는 손이 흘림체로 답을 써 준다. riddle은 정확히 그 장면을 실제 e-ink 태블릿에서 구현한다.

다만 답을 쓰는 '손'은 마법이 아니라 비전 LLM이고, "잉크를 마시는" 연출은 픽셀을 해시로 하나씩 지우는 용해(dissolve) 애니메이션이다. 페르소나도 실제로 "50년간 이 일기장에 보존된 톰 마볼로 리들의 기억"으로 프롬프트에 박혀 있다.

코드상 라이선스는 관대한 MIT(저작권 2026 Maxime Rivest)이며, 클론 시점 최신 커밋은 2026-07-06, 전체 커밋 11개의 초기 프로젝트다. 언어 구성은 Rust 82% · C 7.7% · C++ 6.1% · Shell 4.2%로, 앱 본체(Rust) 약 2,900줄 + 화면 장악용 C/C++(quill) 약 1,500줄 규모다. TrendShift에는 "New 2026"으로 오늘 Daily 11위에 올라 있다.

먼저 짚고 갈 것 — 이 문서의 초점
"신기한 장난감"이 아니라 "저수준 시스템 프로그래밍의 압축 교재"

"AI가 손글씨로 답한다"는 겉모습이다. 이 레포의 진짜 가치는 raw evdev 입력 파싱 · 폰트 래스터화→골격화(Zhang-Suen)→획 추적 · e-ink 파형/부분갱신 제어 · dlsym 심볼 인터포지션으로 벤더 라이브러리 버퍼 가로채기 · SDK 없이 손으로 쓴 base64/JSON/SSE 스트리밍 LLM 호출 · Rust 상태기계 + mpsc 채널3,000줄 남짓한 한 저장소에서 통째로 볼 수 있다는 점이다. 작지만 밀도가 대단히 높다.

주의 — 사실 확인
GitHub 웹 페이지의 README·별점은 낡았을 수 있다

분석 중 확인한 바로, GitHub 웹 렌더 페이지는 클론한 소스보다 오래된 README를 보여줬다(별점 0·릴리스 없음으로 표시). 이 문서의 버전·구조·코드는 전부 shallow clone한 실제 소스(HEAD 2026-07-06) 기준이며, 별점 수치는 신뢰할 수 없어 인용하지 않는다. 프로젝트 자체가 초기 단계이고 단일 기종(reMarkable Paper Pro)에서만 검증됐다는 점도 감안하자.

2왜 주목받는가

트렌딩 이유와, "그냥 챗봇 쓰기"와의 결정적 차이.

① "또 하나의 챗봇"이 아니라 인터페이스 실험

riddle의 전제는 챗봇을 떠올리게 하는 모든 요소를 지우는 것이다 — 텍스트 상자, 전송 버튼, 메시지 버블, 화면 발광까지. 남는 것은 종이 위의 잉크뿐이다. LLM을 "마법 아날로그 물건"처럼 느끼게 만드는 것이 목표이고, 매체(e-ink+손글씨) 자체가 제품이다. 요즘 쏟아지는 "AI 래퍼" 중에서도 방향이 완전히 다르다.

비교

일반 AI 앱 = 키보드로 치고, 버튼 누르고, 말풍선으로 읽는다(도구를 의식한다). riddle = 펜으로 쓰면 종이가 알아서 먹고, 답이 손글씨로 배어 나온다(도구를 잊는다).

② 입력도 출력도 '텍스트'가 아니다 — 손글씨 in, 손글씨 out

모델은 내가 타이핑한 글자를 받지 않는다. 내 손글씨를 찍은 PNG 이미지를 비전 모델이 "읽는다". 답 역시 화면에 텍스트로 뿌리는 게 아니라, Dancing Script 폰트를 1픽셀 골격으로 얇게 깎아 획 순서대로 그려 "보이지 않는 손이 쓰는" 것처럼 애니메이션한다. 입출력 양쪽 모두 사람의 필기라는 점이 이 프로젝트의 정체성이다.

③ 지연(latency)을 애니메이션 뒤에 숨긴다

e-ink는 느리고 LLM도 느리다. riddle은 이걸 연출로 가린다. 페이지가 커밋되는 즉시 LLM 요청을 딴 스레드에서 쏘아 놓고, 그동안 "잉크를 마시는" 14단계 용해 + 맥동하는 생각 표시 애니메이션을 돌린다. 응답은 문장 단위로 스트리밍되어, 모델이 답을 다 만들기 전에 이미 첫 문장을 쓰기 시작한다. 체감 지연이 크게 줄어드는 영리한 설계다.

④ 벤더 UI를 우회해 e-ink 파형 엔진을 직접 몬다

가장 빠른 펜-투-글라스 응답을 얻기 위해, riddle의 "takeover(장악)" 모드는 reMarkable 기본 UI(xochitl)를 멈추고 벤더의 독점 e-ink 엔진 libqsgepaper.so를 직접 구동한다. 이를 위해 dlsym(RTLD_NEXT)로 Qt의 QImage 생성자를 가로채 엔진 내부 프레임버퍼를 낚아채는 기법(epfb.cpp)을 쓴다. "리버스 엔지니어링 없이 이 기기에서 얻을 수 있는 가장 낮은 지연의 서드파티 드로잉 경로"라고 저자가 설명할 정도다.

배움 관점 핵심
"이 레포에서 무엇을 얻는가"

손글씨 일기장이라는 용도는 부차적이다. 진짜 가치는 커널 레벨 입력 장치 다루기 · 고전 이미지처리(세선화·획 추적) · 임베디드 디스플레이 내부(파형·부분갱신) · ELF 심볼 인터포지션 · SDK 없는 스트리밍 LLM 통합 · 안전한 기기 장악/복원 UX실제 동작하는 소형 코드로 한 번에 관찰할 수 있다는 것이다.

3기술 스택 전체 지도

무엇으로 만들어졌나 — 계층별로. (Cargo.toml·Cargo.lock·build 스크립트에서 실제 확인)

큰 그림 — 두 개의 하위 프로젝트

레포는 두 부분으로 나뉜다. riddle/(Rust)는 앱 본체 — 입력·상태기계·LLM·손글씨 합성·그리기를 담당한다. quill/(C/C++·Qt6)는 "화면 장악" 백엔드 — 벤더 e-ink 엔진을 감싸 libquill.so를 만든다. Rust 앱은 창(windowed) 모드에선 quill 없이도 돌지만, 최저 지연의 takeover 모드에선 quill을 FFI로 부른다.

riddle 앱 — Rust (edition 2021), 의존성 극단적 최소화

크레이트버전 (선언 → 잠금)역할
libc0.2 → 0.2.186raw open/read/ioctl — evdev 입력 장치를 직접 읽기
signal-hook0.3 → 0.3.18SIGTERM/SIGINT → 깨끗한 종료 플래그
png0.17 → 0.17.16손글씨 크롭을 흑백 PNG로 인코딩(오라클 전송용)
ab_glyph0.2 → 0.2.32Dancing Script 글리프를 픽셀 마스크로 래스터화
ureq2.10 → 2.12.1 (tls, no-default)순수 Rust HTTPS 클라이언트 — OpenAI 호환 오라클 호출
rustls / ring0.23.41 / 0.17.14 (전이)TLS를 OpenSSL·C 없이 — 자기완결 바이너리 + 크로스컴파일 용이
주목 — "없는 것"이 설계다
no serde_json · no base64 crate · no async runtime · no GUI framework
JSON은 oracle.rs에서 문자열 스캔으로 손수 파싱하고, base64도 직접 구현, 비동기 런타임 대신 OS 스레드 + std::sync::mpsc를 쓴다. "무거운 의존성 없이 단일 바이너리로" 라는 규율이 뚜렷하다. 릴리스 프로필도 strip=true, lto=true.

quill 백엔드 — C / C++ (Qt 6)

항목메모
언어/프레임워크C++ · Qt 6 (QImage/QRect)벤더 독점 libqsgepaper.so(e-ink 씬그래프 플러그인)에 링크
빌드 툴체인reMarkable "ferrari" SDK 3.26aarch64-remarkable-linux-gcc, glibc 2.38 sysroot
산출물libquill.so + 데모들scribble · map_demo · image_demo · gif_demo · drawlab 등

대상 하드웨어 & 배포

항목
기기reMarkable Paper Pro (코드명 ferrari, aarch64), OS 3.26~3.27, 화면 1620×2160
디지타이저Elan 마커, 좌표 최대 11180×15340, 압력 0~4096 (pen.rs)
실행 환경root 권한 + 개발자 모드, xovi + AppLoad 런처 위에서 구동
설치동반 툴 remagic(remagic install riddle)이 가장 쉬움 · 또는 프리빌드 zip을 scp · 또는 소스 크로스컴파일

4아키텍처 심화 분석

펜 입력 → LLM → 손글씨 출력까지, 하나의 이벤트 루프로.

전체 그림 — 상태기계 하나가 전부를 돌린다

앱 전체는 riddle/src/main.rs단일 이벤트 루프이고, enum State로 표현된 유한 상태기계(FSM)가 흐름을 지휘한다. 무거운 작업(LLM)은 별도 스레드에서 돌고, 결과는 std::sync::mpsc 채널로 루프에 되돌아온다.

┌────────────────────────────────────────────────────────────────┐ │ reMarkable Paper Pro (aarch64 · OS 3.26 · root 실행) │ │ │ │ [입력] raw evdev — 장치를 EVIOCGRAB로 독점 │ │ pen.rs 펜: x, y, 압력(0~4096), 펜/지우개(BTN_TOOL_RUBBER) │ │ touch.rs 5손가락 탭 = 종료 power.rs 전원키 = 절전 │ │ │ PenSample 스트림 │ │ ▼ │ │ [상태기계] main.rs run() 루프 (약 2ms 틱) │ │ Listening ─(2.8초 멈춤 + 펜 뗌 + 잉크 있음)→ 커밋 │ │ │ ink.rs: bbox 크롭 → 긴변 800px 축소 → 흑백 PNG │ │ ├──────────► oracle.ask(png, tx) ← 즉시 발사(딴 스레드) │ │ ▼ (애니메이션 전에 먼저 쏴서 지연을 가림) │ │ Drinking(14패스 용해) → Thinking(맥동) → Replying(획 그리기) │ │ ▼ ▲ 문장 단위 스트리밍(mpsc rx) │ │ Lingering → FadingReply → 다시 Listening │ │ │ Surface 쓰기 │ oracle 청크 │ │ ▼ ▼ │ │ [디스플레이] display.rs [오라클] oracle.rs │ │ QTFB_KEY 있으면 → qtfb.rs RIDDLE_OPENAI_KEY 있으면 │ │ AppLoad 창(SOCK_SEQPACKET → HttpOracle: ureq → │ │ + 공유메모리 FB, RGB565) /chat/completions SSE, │ │ 없으면 → Quill(takeover): data-URI PNG 스트림 │ │ FFI → libquill.so → 없으면 → PiOracle: │ │ libqsgepaper.so 파형엔진 상주 pi(Node) --mode rpc, │ │ (RGB32 보조 프레임버퍼) JSONL over stdio │ └────────────────────────────────────────────────────────────────┘
핵심 개념 — 완료 신호를 '채널을 닫아서' 보낸다
mpsc sender drop = "답변 끝"
oracle.ask(png, tx)는 답 조각들을 tx로 계속 흘려보내다가, 다 보내면 sender를 그냥 떨군다(drop). 수신 쪽은 Disconnected를 보고 "완료"를 안다. 별도 "끝" 메시지 타입 없이, 언어의 채널 의미론만으로 스트리밍 종료를 표현하는 의존성 없는 우아한 프로토콜이다.

① 잉크 잡기 — Qt가 아니라 커널에서 직접

펜은 벤더 UI를 거치지 않고 커널 입력 장치를 날것으로 읽는다. 64비트에서 각 input_event는 24바이트(타임스탬프 16 + type 2 + code 2 + value 4)이고, SYN_REPORT 프레임 사이의 값을 모아 한 샘플로 방출한다:

for chunk in buf[..n].chunks_exact(24) {
    let etype = u16::from_le_bytes(chunk[16..18].try_into().unwrap());
    let code  = u16::from_le_bytes(chunk[18..20].try_into().unwrap());
    let value = i32::from_le_bytes(chunk[20..24].try_into().unwrap());
    match (etype, code) {
        (EV_ABS, ABS_PRESSURE)    => { self.pressure = value; }
        (EV_KEY, BTN_TOOL_RUBBER) => { self.tool = if value==1 {Eraser} else {Pen}; }
        (EV_SYN, SYN_REPORT)      => { /* 원좌표 → 화면좌표 스케일 후 방출 */
            x: raw_x * (SCREEN_W - 1) / DIGI_MAX_X, ... }
        _ => {}
    }
}

장치는 ioctl(fd, EVIOCGRAB, 1)독점(grab)해, 기본 UI가 같은 펜에 이중 반응하지 않게 막는다. 붓 반지름은 압력에 비례한다: let r = 2 + pressure * 3 / MAX_PRESSURE;

② "잉크를 마시는" 용해 효과 — 결정적 픽셀 해시

잉크가 사라지는 연출은 난수가 아니라 좌표 기반 해시로 만든다. 각 단계마다 "해시값이 현재 단계 이하인" 잉크 픽셀만 지우므로, 14패스에 걸쳐 안정적인 얼룩 패턴으로 증발한다:

fn px_hash(x: i32, y: i32) -> u32 {
    let mut h = (x as u32).wrapping_mul(0x9E3779B1)
              ^ (y as u32).wrapping_mul(0x85EBCA6B);
    h ^= h >> 13; h = h.wrapping_mul(0xC2B2AE35); h ^ (h >> 16)
}
// dissolve_pass: stage/stages 진행도에 따라 픽셀을 흰색으로
if surf.luma(x, y) < 250 && px_hash(x, y) % stages <= stage {
    surf.put_px(x, y, WHITE);   // 이 픽셀 지금 증발
}

③ 페이지를 모델로 — 크롭 후 축소한 흑백 PNG

이미지 픽셀 수가 비전 토큰 비용·지연을 좌우하므로, 잉크 bbox로 자른 뒤 긴 변이 800px 이하가 되도록 박스 다운스케일한다. 인코딩은 파일 크기보다 속도 우선(Compression::Fast, 그레이스케일):

let f = ((x1 - x0).max(y1 - y0)).div_ceil(800).max(2);  // 축소 배율
// f×f 블록의 휘도를 평균 → 8비트 그레이스케일 버퍼
enc.set_color(png::ColorType::Grayscale);
enc.set_compression(png::Compression::Fast);

④ LLM 호출 — SDK 없이 손으로 쓴 스트리밍 요청

기본 백엔드 HttpOracle는 손글씨 PNG를 base64 data-URI로 실어 /chat/completions스트리밍(SSE) 요청을 보낸다. 본문은 serde 없이 포맷 문자열로 직접 조립한다:

let body = format!(concat!(
  "{{\"model\":{},\"stream\":true,\"max_tokens\":{},",
  "\"messages\":[{{\"role\":\"system\",\"content\":{}}},",
  "{{\"role\":\"user\",\"content\":[",
  "{{\"type\":\"text\",\"text\":{}}},",
  "{{\"type\":\"image_url\",\"image_url\":{{\"url\":",
  "\"data:image/png;base64,{}\"}}}}]}}]}}"),
  json_quote(&model), max_tokens,
  json_quote(PERSONA), json_quote("Reply to what is written in the diary."), img);

let resp = ureq::post(&format!("{base}/chat/completions"))
    .set("Authorization", &format!("Bearer {key}"))
    .send_string(&body);          // SSE 라인(data: {...})을 한 줄씩 파싱

SSE 델타에서 choices[0].delta.content를 뽑고, sentence_cut()으로 한 문장이 완성될 때마다 채널로 넘겨 quill이 일찍 쓰기 시작하게 한다. 페르소나는 두 백엔드가 공유한다:

const PERSONA: &str = "You are the memory of Tom Marvolo Riddle,
preserved in this enchanted diary for fifty years. ...
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. ... Always answer in the language the writer used.";

대안 백엔드 PiOracleNode 프로세스(pi --mode rpc)를 계속 띄워둔 채 stdin/stdout으로 JSONL 이벤트를 주고받아, 매 턴 모델 지연만 지불한다(구독 인증 재사용).

⑤ 이 레포의 꽃 — 손글씨 합성 파이프라인

script.rs는 답변 텍스트를 그릴 수 있는 펜 경로로 바꾼다. 3단계다:

1) rasterize_line : ab_glyph로 Dancing Script 글리프를 채운 마스크(cov > 0.5)
2) thin           : Zhang-Suen 세선화 → 글자를 1픽셀 두께 골격으로
   //  이웃 흑점 수 b가 2..=6, 0→1 전이 a==1, 두 조건 c1·c2를 만족하면 제거
   if !(2..=6).contains(&b) { continue; }
   if a != 1 { continue; }
   if c1 && c2 { to_clear.push(idx(x, y)); }   // 두 위상 반복 → 골격만 남김
3) trace          : 골격을 순서 있는 폴리라인(획)으로 추적, 왼→오 정렬

그런 다음 main.rs::plan_reply()가 획을 화면 좌표로 매핑하며 줄마다 미세한 jitter()/흔들림을 더해 "사람이 쓴 듯"하게 만들고, State::Replying이 틱마다 약 26점씩 brush_line으로 그린다. 스트리밍으로 나중에 온 문장은 append_reply()그리는 도중에 아래로 이어 붙인다.

⑥ 가장 영리한 트릭 — 벤더 버퍼 가로채기(quill)

e-ink 엔진 libqsgepaper.so는 프레임버퍼를 내부 QImage로만 갖고 있어 공개 접근자가 없다. quill/src/epfb.cppdlsym(RTLD_NEXT)Qt의 QImage 생성자(맹글된 심볼)를 가로채, 초기화 구간에 엔진이 만드는 QImage들을 픽셀 포맷 인자로 구분해 기록한다:

extern "C" void _ZN6QImageC1EPhiixNS_6FormatE...(QImage *that, ...) {
    ORG(...);                 // 진짜 생성자를 먼저 호출
    org(that, ...);
    if (state != 1) return;
    if (param_5 == 4 || param_5 == 7) pointerTracker[that] = SWTCON_TypeA; // 보조 FB
    else if (param_5 == 0x18)         pointerTracker[that] = SWTCON_TypeB; // 메인 FB
}

quill_c.cpp가 이 보조 QImage의 raw bits()quill_buffer()로 노출하고, riddle이 여기에 RGB32를 직접 써넣은 뒤 EPFramebuffer::swapBuffers(rect, Mono, mode, flags)로 화면에 밀어낸다. 파형 모드: 0=최고속(잉크), 3=중간(텍스트/애니), 4=전체 플래시 클리어.

설계 포인트
"한 벌의 그리기 코드, 두 개의 픽셀 포맷"

surface.rsSurface는 qtfb의 RGB565(2바이트/픽셀)와 quill의 RGB32put_px에서 변환해 흡수한다. 덕분에 모든 드로잉 코드는 포맷을 몰라도 된다. 갱신은 fb.rs의 grow-only BBox(더티 렉트)로 바뀐 영역만, 그것도 8ms(takeover)/35ms(창) 간격으로 병합해 밀어낸다 — e-ink 부분 갱신이 비싸기 때문.

5디렉토리 구조 해부

약 3,000줄이 어디에 어떻게 놓였나. (괄호 안 숫자는 대략 줄 수)

riddle/ (레포 루트) ├── LICENSE (MIT) · README.md # 클론본이 최신 — 웹 렌더는 낡음 │ ├── riddle/ ── 앱 (Rust) ── │ ├── Cargo.toml / Cargo.lock # libc·png·ab_glyph·ureq·signal-hook │ ├── build.rs # takeover 피처 시 libquill/SDK 링크 플래그 │ ├── external.manifest.json # AppLoad 매니페스트 "The Diary" │ ├── settings.schema.json # remagic config가 렌더할 오라클 설정 폼 │ ├── oracle.env.example # API 키·모델 템플릿(키는 절대 커밋 안 함) │ ├── fonts/DancingScript.ttf # 답변 손글씨체(include_bytes!로 내장) + OFL │ ├── build-takeover.sh · scripts/{appload-launch,riddle-takeover,make-bundle}.sh │ └── src/ │ ├── main.rs (629) # 진입점·상태기계·메인루프·plan/append_reply │ ├── oracle.rs (580) # HttpOracle+PiOracle, 손수 base64/JSON/SSE, PERSONA │ ├── help.rs (354) # "?" 제스처 인식·안내 패널·절전 페이지 │ ├── qtfb.rs (279) # 창 백엔드: rm-appload 소켓 + 공유메모리 FB │ ├── script.rs (241) # ★ 손글씨 합성: 래스터→Zhang-Suen 세선화→획 추적 │ ├── surface.rs (192) # 프레임버퍼 드로잉, RGB565↔RGB32, luma │ ├── pen.rs (165) # raw evdev 디지타이저 파서(grab) │ ├── display.rs (142) # Display enum{Qtfb,Quill}, 런타임 선택, quill FFI │ ├── ink.rs (138) # 잉크 캡처·지우기·용해(px_hash)·크롭→PNG │ ├── power.rs (97) # 전원키 grab·절전 확인·resume 후 wifi_heal │ ├── touch.rs (71) # 멀티터치, 5손가락 탭 종료 │ └── fb.rs (31) # SCREEN_W/H 상수 + grow-only BBox │ └── quill/ ── 화면 장악 백엔드 (C/C++) ── ├── build.sh # 기기에서 libqsgepaper.so를 scp로 당겨 빌드 └── src/ ├── epframebuffer.h # 재구성한 EPFramebuffer 인터페이스 ├── epfb.cpp (117) # ★ 심볼 인터포지션 심(dlsym RTLD_NEXT) ├── quill_c.cpp (63) # 엔진 위의 C ABI(quill_init/buffer/swap…) └── scribble.c · map_demo.c · drawlab.c · image_demo.cpp · gif_demo.cpp # 데모

구조가 곧 설명서다. 한 파일 = 한 관심사로 깔끔히 쪼개져 있어(입력=pen/touch/power, 두뇌=main/oracle, 표현=script/surface/ink, 출력=display/qtfb), 3,000줄임에도 어디를 봐야 할지 즉시 안다. 표시한 script.rs(손글씨 합성)와 epfb.cpp(버퍼 가로채기)가 기술적으로 가장 배울 것이 많은 두 파일이다.

6학습 포인트 (기술별)

이 작은 코드베이스에서 훔쳐 배울 수 있는 것들.

① 리눅스 raw 입력 다루기

evdevinput_event 구조체를 손으로 파싱하고, EVIOCGRAB로 장치를 독점하며, /sys/class/input/*/device/name으로 펜·지우개·터치·전원 장치를 구분한다(pen.rs·touch.rs·power.rs). GUI 프레임워크를 건너뛰고 커널과 직접 대화하는 법의 표본.

② 고전 이미지처리 알고리즘 — 세선화·획 추적

script.rsZhang-Suen 세선화(두 위상 이웃 연결성 판정을 고정점까지 반복)와 골격 → 순서 있는 획 추적은, 그대로 떼어내 어떤 캔버스에도 쓸 수 있는 깔끔한 CS 연습이다. 폰트 커버리지 마스크 → 1픽셀 골격 → 애니메이션 가능한 획으로 이어지는 흐름을 통째로 본다.

③ SDK 없이 스트리밍 LLM 붙이기

OpenAI /chat/completions 비전 요청을 손으로 만들고, SSE 델타를 파싱하고, 문장 단위로 잘라 체감 지연을 줄이며, 두 번째 백엔드는 상주 서브프로세스와 stdio JSONL로 통신한다(oracle.rs). base64/JSON까지 직접 구현해 의존성을 극도로 얇게 유지하는 취향도 배운다.

④ Rust 상태기계 + 채널로 실시간 루프 짜기

enum State 전이, 크로스 스레드 스트리밍을 위한 mpsc, 그리고 "sender를 떨궈 완료를 알리는" 관용을 main.rs에서 실물로 본다. 비동기 런타임 없이 OS 스레드만으로 반응형 UI 루프를 구성하는 미니멀리즘.

⑤ e-ink / 임베디드 디스플레이 내부

파형 모드, 전체 vs 부분 갱신, 더티 렉트 병합, 그리고 하이라이트인 심볼 인터포지션(dlsym(RTLD_NEXT)) 심으로 벤더 라이브러리의 내부 버퍼를 가로채기(epfb.cpp). 고급이지만 주석이 잘 달려 있어 ELF 링킹·심볼 해석을 공부하기에 좋다.

⑥ 안전한 "기기 장악/복원" UX

기본 UI(xochitl)를 멈추고 화면을 뺏는 앱은 잘못되면 태블릿을 먹통으로 만든다. riddle은 transient systemd 유닛으로 분리 실행(런처가 죽어도 살아남게)하고, trap restore EXIT INT TERM으로 무슨 일이 있어도 xochitl을 되살리며, 절전은 커널 성공 카운터로 확인한다. "사용자 기기를 망가뜨리지 않기"라는 안전망 설계를 배운다.

7하드웨어/시스템 요구사항

돌리려면 / 빌드하려면 무엇이 필요한가.

구분요구사항메모
기기reMarkable Paper Pro ("ferrari", aarch64)OS 3.26~3.27, 개발자 모드 + root. 이 기종에서만 검증됨
런처xovi + AppLoadremagic로 설치가 가장 쉬움(개발자 모드·xovi·AppLoad 자동 세팅)
takeover 빌드libqsgepaper.so + ferrari SDK독점 라이브러리는 본인 기기에서 추출(재배포 안 됨), SDK는 glibc 2.38·Qt6 sysroot
오라클 (택1)HTTP 백엔드 권장RIDDLE_OPENAI_KEY 필수(비전 지원 엔드포인트). _BASE(기본 OpenAI)·_MODEL(기본 gpt-4o-mini)·_MAX_TOKENS(2000) 선택. OpenRouter/Gemini 프리셋 제공
오라클 (대안)pi 백엔드Node pi/home/root/node/bin/pi에 있어야. 키 없을 때 자동 사용
호스트(빌드 PC)x86_64 리눅스 + aarch64 크로스툴체인창 모드는 cargo build --release --target aarch64-unknown-linux-gnu
유용한 탈출구
화면 없이 오라클만 테스트: riddle --oracle-test 손글씨.png

기기·디스플레이 없이도 API 키·엔드포인트·모델이 제대로 붙는지 확인할 수 있다. PNG 한 장을 넣으면 스트리밍 답을 콘솔에 뿌린다. 알고리즘(세선화·오라클)은 하드웨어 독립이라 데스크톱에서 개발·검증이 가능하고, quill/takeover만 실제 태블릿+SDK+독점 라이브러리를 요구한다.

주의 — 기기 손상 위험
화면을 장악하는 앱이다 — 잘못 쓰면 태블릿이 먹통이 될 수 있다

README는 다른 기종/OS에서 브릭(먹통)될 수 있으니 SSH 탈출로(root@10.11.99.1)를 항상 열어두라고 경고한다. root로 돌고, 벤더 UI를 멈추고, 독점 라이브러리를 가로채는 만큼 실험적이다. 또한 커밋 이력이 11개뿐인 초기 프로젝트이므로, 재미로 배우기엔 훌륭하지만 "안정 제품"으로 기대하지는 말자.

8직접 해볼 수 있는 실습 과제

읽기만 하면 안 남는다 — 대부분 태블릿 없이 데스크톱에서 가능하다.

과제 ① 난이도: 입문

오라클 경로만 먼저 태워 보기

손글씨가 담긴 PNG 한 장을 준비하고, 비전 지원 API 키를 oracle.env에 넣은 뒤 riddle --oracle-test 손글씨.png를 돌린다(기기 불필요). 목표: "이미지 → 비전 LLM → 스트리밍 텍스트"라는 핵심 파이프의 절반을 손으로 확인.

과제 ② 난이도: 초급

Zhang-Suen 세선화 재현하기

파이썬으로 검은 글자 이미지를 하나 만들고, script.rs의 세선화 로직(이웃 흑점 수 2~6, 0→1 전이 1회 등)을 그대로 옮겨 1픽셀 골격을 뽑아 본다. 목표: "채워진 글자 → 뼈대"가 어떻게 계산되는지 알고리즘 감각 잡기.

과제 ③ 난이도: 중급

골격을 '획 순서'로 추적해 애니메이션

과제 ②의 골격을 입력으로, 끝점(차수 1)부터 시작해 폴리라인으로 잇는 trace를 구현하고, HTML canvas에 한 점씩 그려 "쓰는 것처럼" 재생해 본다. 목표: riddle의 State::Replying이 하는 일을 브라우저에서 재현.

과제 ④ 난이도: 고급

SSE를 손으로 파싱해 문장 스트리밍

OpenAI 호환 /chat/completionsstream:true로 요청하고, 응답의 data: {json} 라인에서 delta.content만 뽑아 문장이 끝날 때마다 출력한다(라이브러리 최소화). 목표: oracle.rssentence_cut 아이디어로 "체감 지연 줄이기"를 체득.

과제 ⑤ 난이도: 심화

새 오라클 백엔드 끼워 넣기

ask(png, tx)라는 동일 인터페이스 뒤에 세 번째 백엔드(예: 로컬 llama.cpp 서버)를 붙여 본다. HTTP/pi 두 구현을 참고해 스트리밍 청크 → 채널 → sender drop 계약만 지키면 나머지는 그대로 돈다. 목표: "인터페이스 하나, 구현 여럿" 확장 설계를 손으로 확인.

9관련 기술 심화 학습 로드맵

이 레포를 온전히 읽으려면 — 5주 코스.

주차주제무엇을 / 왜
1주차Rust 기초 + 스레드enum·match·소유권·std::thread·mpsc. riddle의 상태기계와 채널을 읽는 토대
2주차리눅스 입력/장치evdev·ioctl·/dev/input·input_event 구조체. 펜/터치/전원 입력의 원리
3주차이미지처리·폰트래스터화, 이진화, Zhang-Suen 세선화, 골격 추적. 손글씨 합성의 핵심
4주차HTTP/SSE·LLM API비전 메시지 포맷, base64 data-URI, SSE 스트리밍 파싱. 데이터 수집·응답의 심장
5주차ELF 링킹·임베디드 디스플레이동적 링킹·dlsym(RTLD_NEXT) 인터포지션, e-ink 파형·부분 갱신. 가장 난도 높은 quill
학습 순서 팁

어려운 5주차(quill/심볼 인터포지션)부터 파지 말자. 3주차(세선화)4주차(SSE)는 태블릿 없이 데스크톱에서 완주할 수 있어 성취감이 빠르다. 이 둘을 손에 익힌 뒤, 실제 기기가 있다면 그때 5주차의 화면 장악으로 넘어가는 게 좌절이 적다.

10핵심 키워드 사전

문서에 나온 용어를 한 번에.

용어
e-ink / 파형(waveform)
전자잉크 화면은 픽셀을 "어떤 전압 파형"으로 흔드느냐에 따라 속도·품질이 갈린다. 빠른 모드는 잔상이 남고, 전체 플래시는 깨끗하지만 느리다.
용어
evdev / EVIOCGRAB
리눅스의 입력 이벤트 인터페이스. EVIOCGRAB는 그 장치를 "나만 쓰겠다"고 독점하는 ioctl로, 기본 UI의 이중 반응을 막는다.
용어
Zhang-Suen 세선화(thinning)
채워진 도형을 1픽셀 두께 골격으로 줄이는 고전 알고리즘. 손글씨 글자를 "펜이 지나간 선"으로 바꿔 획 애니메이션을 가능케 한다.
용어
비전 LLM (vision LLM)
텍스트뿐 아니라 이미지를 입력으로 받는 모델. riddle은 손글씨 사진을 넣어 "읽게" 한다 — OCR을 따로 두지 않는다.
용어
SSE (Server-Sent Events)
서버가 data: ... 줄을 조금씩 흘려보내는 스트리밍 방식. 답 전체를 기다리지 않고 도착하는 대로 처리(=문장 단위로 쓰기 시작)할 수 있다.
용어
mpsc 채널 / sender drop
std::sync::mpsc는 여러 생산자 → 한 소비자 큐. riddle은 답 조각을 흘리다 보내는 쪽을 떨궈(drop) "끝"을 알린다 — 별도 종료 메시지가 필요 없다.
용어
심볼 인터포지션 / dlsym(RTLD_NEXT)
같은 이름의 함수를 먼저 정의해 원본 대신 내 것이 호출되게 가로채는 기법. RTLD_NEXT로 진짜 원본도 찾아 부를 수 있어, 엔진 내부 버퍼를 몰래 기록한다.
용어
FFI (Foreign Function Interface)
한 언어에서 다른 언어의 함수를 부르는 다리. riddle(Rust)이 unsafe extern "C"libquill.so(C/C++)의 함수를 호출한다.
용어
reMarkable Paper Pro / "ferrari"
이 앱의 대상 e-ink 태블릿(코드명 ferrari, aarch64). 화면 1620×2160, 압력 4096단계 스타일러스.
용어
xochitl / xovi / AppLoad / remagic
xochitl=reMarkable 기본 UI, xovi+AppLoad=서드파티 앱 런처 생태계, remagic=이 저자가 만든 설치 도우미(개발자 모드·런처 자동 세팅).

11참고 링크

더 깊이 파고들 곳.