Files
blekin/doc/plan/implementation.md
rob thijssen a60cee3f23 fix: reconcile implementation plan with decompiled Java source
Cross-referenced every wire-format claim against rc-src and corrected
several errors that would have caused stream desync during implementation:

- rect header is 4×u16 + i32 (12 bytes), not varints + u8
- ping payload is i32 (4 bytes), not 1 byte
- PointerEvent is 8 bytes (includes trailing extra_u16)
- ServerInit is 19 bytes, not 16
- pixel-format struct is variable-length, not fixed 25 bytes
- string primitive uses modified-UTF-8, not ISO-8859-1
- bandwidth probe is read-only (no echo response)
- Phase 1 uses sync Read traits (async deferred to Phase 2)
- corrected all stale line-number references in ac.java/aw.java

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 13:40:40 +03:00

468 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# blekin — implementation plan
A Rust proxy that translates the Belkin OmniView Remote IP Manager's e-RIC RFB
protocol (Peppercon LARA, served over a Java applet) into a modern HTML5 KVM
console. The proxy speaks plain TCP to the OmniView, decodes the proprietary
8bpp protocol to RGBA, and bridges to a Vite/React frontend over WebSocket.
All Java references are paths under `~/rc-src/nn/pp/rc/` from the CFR-decompiled
`rc.jar`. Line numbers reference the files as uploaded.
## Target architecture
```
Browser (Vite + React + TS)
│ WebSocket (binary, RGBA blits + input events)
blekin (Rust, tokio + axum)
│ HTTP session (cookie + APPLET_ID extraction)
│ TCP to OmniView:443 (e-RIC RFB)
Belkin OmniView Remote IP Manager
```
Workspace layout:
```
blekin/
├── Cargo.toml # workspace
├── crates/
│ ├── ericrfb/ # pure protocol library, no IO
│ ├── ericrfb-proxy/ # tokio service: HTTP login + TCP + WS bridge
│ └── ericrfb-frontend/ # Vite/React/TS canvas client
└── PLAN.md
```
## Protocol reference (one-page summary)
Every byte derives from `aw.java`, `ac.java`, and `ByteColorRFBRenderer.java`.
**Wire facts:**
- Read primitives from `h.java`: `h.new()` = u8, `h.try()` = i8, `h.int()` =
u16-BE, `h.char()` = i16-BE, `h.do()` = i32-BE, `h.byte()` = u16-length-
prefixed modified-UTF-8 string. Write primitives are u8 and u16-BE only.
- Varint (13 bytes) is defined in `aw.int()` at line 484. Used for Tight
compressed-stream lengths and a few other places — **not** for rectangle
headers.
- Pixel format is **8bpp RGB332** (mask 7 / 56 / 192). One byte per pixel on
the wire. Decoded to 24-bit RGB via a 256-entry lookup table.
- Four hardcoded sub-palettes referenced by Hextile/Tight: 1bpp B/W, 2bpp gray,
4bpp gray, 4bpp 16-color. See `ByteColorRFBRenderer.case()` at line 691.
- Four parallel zlib streams (`Inflater[4]`) for Tight + encoding 9, indexed by
bits 45 of the encoding control byte. State persists across rectangles —
rectangles **must not be skipped**.
**Handshake** (`aw.g()` at line 226):
| Step | Direction | Bytes | Source |
|------|-----------|-------|--------|
| 1 | C→S | 75 bytes: `"e-RIC AUTH=" + APPLET_ID`, zero-padded, ISO-8859-1 | line 259 |
| 2 | S→C | 1 byte status. `101 ('e')` = OK. `3` = error (read i32 error code, see `aw.a(int)` line 350) | line 264 |
| 3 | S→C | 15 bytes: `"-RIC RFB MM.NN\n"` (digits ASCII). Combined with the `e` from step 2, the full banner is `"e-RIC RFB MM.NN\n"` | `int(by)` line 395 |
| 4 | S→C | 1 byte sync (`w.new()`) | line 270 |
| 5 | S→C | 1 pad byte + modified-UTF-8 string (u16 length prefix + N bytes) = server name | `l()` line 413 |
| 6 | S→C | 1 byte sync | line 272 |
| 7 | S→C | Variable-length pixel-format struct: 1 byte flag + u16 int + u16 string-length + N bytes string | `i()` line 519 |
| 8 | C→S | 16 bytes: `"e-RIC RFB " + PROTOCOL_VERSION + "\n"` | `try()` line 405 |
| 9 | C→S | 2 bytes: `[shared_flag, port_id]` | `char()` line 425 |
| 10 | S→C | 1 byte sync | line 276 |
| 11 | S→C | 19 bytes ServerInit: `[bX(u8), w(u16), h(u16), bE(u8), a(u8), try(u8), a3(u8), x(u16), ao(u16), v(u16), bM(u8), bW(u8), bU(u8), 3 pad]` | `k()` line 435 |
After step 11, the client sends `SetEncodings` and a non-incremental
`FramebufferUpdateRequest`, then enters the message dispatch loop.
**Server-to-client message types** (from `ac.char()` switch at line 292 of
`ac.java`):
| Type | Reader | Purpose |
|------|--------|---------|
| 0 | `ac.f()``ac.e()` | FramebufferUpdate |
| 1 | — | SetColourMapEntries (rejected) |
| 2 | — | Bell |
| 3 | `aw.goto()` | ServerCutText |
| 7 | `aw.l()` | server-name update |
| 8 | `aw.e()` line 537 | pixel-format change (1 pad + 4×u8 + 8×u16 = 21 bytes) |
| 9 | `aw.else()` | layout/locale string |
| 16 | `aw.i()` | desktop resize + name |
| 17 | `aw.for()` | 2-byte ack (no-op) |
| 128 | `aw.k()` | mode change / framebuffer reinit |
| 131 | `aw.d()` | debug string |
| 132 | `aw.long()` | RFB-command channel: two strings (key, value) |
| 148 | `aw.b()` | **ping** — read 3 pad bytes + i32 payload, must respond with `aw.if(n)` (msg 149) |
| 150 | `aw.do()` | bandwidth measurement probe (1 pad + u16 length + N bytes, read-only, no response) |
| 161 | `aw.case()` | RDP/Host-Direct mode events |
**Per-rectangle encoding dispatch** (`ac.e()` at line 218 of `ac.java`, switch at line 226):
| Encoding | Renderer method | Notes |
|----------|-----------------|-------|
| 0 | `ByteColorRFBRenderer.if()` line 98 | Raw 8bpp, w×h bytes |
| 1 | `ByteColorRFBRenderer.a()` line 165 | CopyRect |
| 5 | `ByteColorRFBRenderer.int()` line 169 | Hextile (16×16, palette refs) |
| 7 | `ByteColorRFBRenderer.a()` line 244 → 324 | Tight-derived |
| 9 | `ByteColorRFBRenderer.do()` line 248 | IIP — defer |
| 10 | `ByteColorRFBRenderer.for()` line 109 | Raw with tile-interleave flag |
**Client-to-server messages** (writers in `aw.java`):
| Byte | Method | Format |
|------|--------|--------|
| 1 | `a(...)` line 572 | SetPixelFormat |
| 2 | `a(int[], int)` line 597 | SetEncodings |
| 3 | `a(...)` line 562 | FramebufferUpdateRequest |
| 4 | `a(byte)` line 655 | KeyEvent (partial — see Phase 5) |
| 5 | `a(false,...)` line 612 | PointerEvent: `[5, mask, x_u16, y_u16, extra_u16]` (8 bytes; extra=0 in absolute mode) |
| 7 | `a(String)` line 418 | ClientCutText |
| 149 | `if(int)` line 636 | PingResponse `[-107(=149 unsigned), 0,0,0, n_i32]` (8 bytes) |
---
## Phase 0 — Repo & scaffolding
**Goal:** Empty workspace compiles. CI runs `cargo test`.
- [ ] `cargo new --lib crates/ericrfb`
- [ ] `cargo new --bin crates/ericrfb-proxy`
- [ ] Vite scaffold for `crates/ericrfb-frontend` (`npm create vite@latest -- --template react-swc-ts`)
- [ ] Workspace `Cargo.toml` pinning `tokio = "1"`, `axum = "0.7"`, `bytes = "1"`,
`flate2 = "1"`, `tracing = "0.1"`, `anyhow`, `thiserror`
- [ ] `.envrc` / direnv with `RUST_LOG=ericrfb=debug,ericrfb_proxy=debug`
- [ ] Forgejo Actions workflow on git.lair.cafe: build + test + clippy
**Deliverable:** `cargo test` green on a stub test in each crate.
---
## Phase 1 — Protocol primitives (`ericrfb/src/proto.rs`)
**Goal:** Faithful Rust port of `h.java` + `aw.java` read/write helpers. No
network I/O — all operations on `&[u8]` / `Cursor<&[u8]>` (sync `Read`/`Write`
traits) so the codec is testable without tokio. Async wrappers added in Phase 2.
**Java references:**
- `h.java` — the low-level I/O adapter. Key methods:
- `h.new()` line 106 — read 1 byte as unsigned int (u8)
- `h.try()` line 91 — read 1 byte as signed (i8)
- `h.int()` line 138 — read 2 bytes big-endian as unsigned int (u16)
- `h.char()` line 121 — read 2 bytes big-endian as signed short (i16)
- `h.do()` line 172 — read 4 bytes big-endian as signed int (i32)
- `h.byte()` line 188 — read u16 length + N bytes of modified-UTF-8 string
- `aw.int()` line 484 — varint, 13 bytes, top bit = continuation. Used for
Tight compressed-stream lengths. Document with exact byte examples.
- `aw.new()` line 454 — `read_u8` (delegates to `h` with a byte-count update)
- `aw.f()` line 468 — rectangle header reader: `[x(u16), y(u16), w(u16),
h(u16), encoding(i32)]` = **12 bytes fixed**. Coords use `h.int()` (u16-BE),
encoding uses `h.do()` (i32-BE). **Do not confuse `this.w.int()` (h.int =
u16) with `aw.int()` (varint) — the obfuscated names collide.**
**Tasks:**
- [ ] `read_u8`, `read_i8`, `read_u16_be`, `read_i16_be`, `read_i32_be`
primitives matching `h.java`
- [ ] `read_varint` / `write_varint` with property tests
- [ ] `RectHeader { x: u16, y: u16, w: u16, h: u16, encoding: i32 }` (12 bytes)
- [ ] Modified-UTF-8 string reader (u16 length prefix + Java modified-UTF-8
body, per `h.byte()` line 188)
- [ ] Unit tests using fixed byte vectors — at least one for each primitive
**Deliverable:** `cargo test -p ericrfb proto::` covers every primitive used
later, with a property test ensuring `write_varint(n) → read_varint = n`
roundtrips for n ∈ [0, 2²²).
---
## Phase 2 — Handshake (`ericrfb/src/handshake.rs`)
**Goal:** Open a TCP socket to the OmniView, complete steps 111 of the table
above, return a `Session` containing `(width, height, pixel_format, name,
read_half, write_half)`.
**Java references:**
- `aw.g()` line 226 — the connect-and-handshake sequence in full
- `aw.int(byte)` line 395 — server-version banner parser
- `aw.try()` line 405 — client version reply (`"e-RIC RFB 01.11\n"`, exactly 16
bytes)
- `aw.char()` line 425 — 2-byte port-init message
- `aw.k()` line 435 — ServerInit reader
- `aw.i()` line 519 — pixel-format struct reader (variable-length: 1 byte flag +
u16 int + u16 string-length + N bytes)
- `aw.a(int)` line 350 — error code → string (1=no perm, 2=exclusive, 3=manually
rejected, 4=password disabled, 5=loopback, 6=auth failed, 7=port denied)
**Tasks:**
- [ ] `Config { host, port, applet_id, protocol_version, port_id, shared }`
- [ ] `connect(cfg) -> Result<Session>` walking all 11 steps with `tracing`
spans
- [ ] Error type mapping the auth status code via `aw.a(int)`'s table
- [ ] Integration test gated behind `OMNIVIEW_TEST_HOST` env var that exercises
the real device — skipped in normal CI
**Deliverable:** `cargo run --example handshake -- --host 10.3.0.130 --applet-id $TOKEN`
prints `Connected: name="Remote IP Manager", 640×480, RGB332` and exits cleanly.
First real-protocol milestone.
---
## Phase 3 — Session pump + Raw decoder (`ericrfb/src/codec/raw.rs`)
**Goal:** Receive a `FramebufferUpdate` containing only Raw rectangles, apply
to a `Framebuffer { width, height, pixels: Vec<u8> }` (8bpp), expand to RGBA via
LUT, write a PNG to disk.
**Java references:**
- `ac.char()` line 283 (of `ac.java`), switch at line 292 — server-msg dispatch
- `ac.f()` line 273 → `ac.e()` line 218 — FramebufferUpdate handler → per-rect
encoding dispatch (switch at line 226)
- `aw.null()` line 459 — FramebufferUpdate header (1 pad byte via `h.try()` +
u16 num_rects via `h.int()`)
- `aw.f()` line 468 — per-rectangle header (4×u16 + i32 = 12 bytes)
- `ByteColorRFBRenderer.if(x,y,w,h)` line 98 — Raw decoder (read w*h bytes,
blit at offset)
- `ByteColorRFBRenderer` constructor lines 5774 — RGB332 → 24bpp LUT
construction (`DirectColorModel(8, 7, 56, 192)`)
**Tasks:**
- [ ] `Framebuffer` struct with `apply_raw(rect, &[u8])`
- [ ] Static `RGB332_TO_RGBA: [u32; 256]` table generated at compile time via
`const fn` matching the Java mask layout
- [ ] Message-dispatch loop with `match` on type byte; only type 0 implemented,
everything else returns "unhandled type N" error
- [ ] Send `SetEncodings([0])` + `FramebufferUpdateRequest(full, incremental=false)`
- [ ] `examples/snapshot.rs` saves first frame as `frame.png`
**Deliverable:** `cargo run --example snapshot` produces a PNG of whatever the
KVM is showing — POST screen, BIOS, OS console, whatever. **This is the moment
the project stops being theoretical.**
---
## Phase 4 — Hextile + ping + extension messages (`ericrfb/src/codec/hextile.rs`)
**Goal:** Long-running session that stays connected for hours and renders a
sequence of frames. Tight not yet supported, but the simpler encodings carry
us a long way.
**Java references:**
- `ByteColorRFBRenderer.int()` line 169 — Hextile decoder. Walk the 16×16 grid;
for each tile read subencoding byte, then optionally bg/fg colors, optionally
subrect count + per-subrect coords. Pre-defined palettes don't apply here —
Hextile uses 1-byte direct color tokens only.
- `ByteColorRFBRenderer.a(srcX, srcY, x, y, w, h)` line 165 — CopyRect: trivial
source→dest blit within the framebuffer
- `aw.b()` line 629 — ping reader: 3 pad bytes (`h.try()` × 3) + i32 payload
(`h.do()`). Total 7 bytes.
- `aw.if(int)` line 636 — ping response writer: `[-107, 0, 0, 0, n_i32]` = 8
bytes. Echo the i32 payload from `aw.b()` back.
**Tasks:**
- [ ] Hextile decoder, with subrect bit-flag handling matching lines 192238
- [ ] CopyRect handler with overlap-safe `copy_within` semantics
- [ ] Wire up message types 2 (Bell, log only), 17 (no-op ack, reads 2 bytes per
`aw.for()` line 553), 131 (debug string, log), 132 (RFB command, log
key=value), 148 (ping → echo i32 payload back as msg 149), 150 (bandwidth
probe — read and discard per `aw.do()` line 642, no response needed)
- [ ] Type 16 (resize) + type 128 (mode change) trigger framebuffer
reallocation; emit a `Event::Resize(w, h)` to consumers
- [ ] `examples/record.rs`: 30-second session, save 1 PNG/sec to `out/`
**Deliverable:** A folder of timestamped PNGs from a real session showing
boot/login/whatever activity. Connection survives ≥ 1 hour without dropping
(verifies ping handling).
---
## Phase 5 — Tight decoder (`ericrfb/src/codec/tight.rs`)
**Goal:** Server defaults to Tight when bandwidth matters; without this, full
desktop refreshes are painfully slow. With Tight, video is fluid.
**Java references:**
- `ByteColorRFBRenderer.a(x,y,w,h)` line 244 → `a(... null, 0, 0, 0)` line 324
— Tight dispatcher. Read control byte `n13`. Top 4 bits = stream-reset flags
+ stream-id; bottom 4 bits = subencoding type.
- Subencoding 8 (line 348) — fill rect with 1-byte color
- Subencoding 15 (line 351) — fill rect with palette-indexed color (1-byte
palette selector 14 → palettes `C/x/L/I`, then 1-byte index)
- Subencodings 07 (line 380) — zlib-compressed pixel data, optional palette
filter (filter id 1 = palette mode, 2-color sub-palette from `C/x/L/I`)
- Subencodings 1013 (line 432) — reduced bit-depth packed (1/2/4 bpp)
- `aw.int()` is reused for compressed-stream length (line 297, 457)
- `case()` at line 691 of the renderer — the four hardcoded sub-palettes,
ported verbatim
**Tasks:**
- [ ] `ZlibStreams { streams: [Option<Decompress>; 4] }` with reset-on-flag
logic matching lines 336341
- [ ] Subencoding 8 (single-color fill)
- [ ] Subencoding 15 (palette-indexed fill)
- [ ] Subencoding 07 with optional filter byte: copy raw, palette-filtered
(1-bit packed when palette size = 2)
- [ ] Subencoding 1013: bit-unpacking via the predefined LUTs
(`ByteColorRFBRenderer.if()` at line 580 is the reference)
- [ ] Constants module with `PALETTE_2`, `PALETTE_4`, `PALETTE_GRAY16`,
`PALETTE_COLOR16` ported from lines 691735
**Deliverable:** SetEncodings advertises `[7, 5, 1, 0, -250]`. The example from
Phase 4 runs at recognizable framerate. Bandwidth on tcpdump drops by ≥5× vs.
Phase 4. Visual diff between Tight-decoded and Raw-snapshot frames is byte-
identical for static screens.
---
## Phase 6 — Input: keyboard and mouse (`ericrfb/src/input.rs`)
**Goal:** Send pointer events and keystrokes to the OmniView so the console is
actually usable.
**Java references:**
- `aw.a(boolean, int, int, int, int)` line 612 — PointerEvent writer.
Payload: `[5, button_mask, x_u16, y_u16, extra_u16]` = 8 bytes. `extra` is
always 0 in absolute mode. With `bl=true`, byte 0 becomes 147 for
"exclusive/relative" mode — ignore for v1.
- `MouseHandlerAbsolute.java` — confirms button-mask bit layout matches RFB
(bit 0 = left, bit 1 = middle, bit 2 = right, bit 3/4 = wheel up/down)
- `KeyEventHandler.java` + `nn.pp.rckbd.*` — the keyboard handler is more
involved than the 2-byte `aw.a(byte)` writer suggests. Read `KeyEventHandler`
to understand the multi-byte key sequences (HID-style, with separate
press/release bytes per the `KeyDef.java` model).
- `KbdLayout_104pc.java` — US layout scancode tables, used as default
- `KbdMapping_en.java` (in rcsoftkbd) — alternate mapping path
**Tasks:**
- [ ] `PointerEvent { mask: u8, x: u16, y: u16, extra: u16 }` writer (8 bytes;
extra=0 for absolute mode)
- [ ] Browser-side: capture `mousemove`/`mousedown`/`mouseup` → WS frames
- [ ] Initial keyboard: send raw scancodes via msg type 4 with the 104pc layout
table verbatim from `KbdLayout_104pc.java`
- [ ] Browser-side: `keydown`/`keyup` → JavaScript `KeyboardEvent.code` →
lookup → scancode → WS frame
- [ ] CtrlAltDel button in UI sends the hotkey sequence from the HTML param
`HOTKEYCODE_0` (`"36 f0 37 f0 4e "` = Ctrl down, Alt down, Del down,
Del up, Alt up, Ctrl up)
**Deliverable:** Type into BIOS, click in OS installer. The OmniView is
genuinely usable.
---
## Phase 7 — Proxy daemon (`crates/ericrfb-proxy`)
**Goal:** Long-running tokio service that holds a session per browser
connection. HTTP login flow extracts the APPLET_ID; WebSocket upgrade hands off
to the protocol pump.
**Java references:**
- `RemoteConsoleApplet.if(boolean)` line 312 of `RemoteConsoleApplet.java` —
shows where `APPLET_ID`, `PORT`, `HOST`, `PROTOCOL_VERSION` are pulled from
HTML `<param>`. Replicate by HTTP GET-ing `/title_app.asp` (or
whatever the login flow lands on) and parsing the params from the returned
HTML.
- The login HTTP flow itself is **not in the jar** — it's web UI. Capture it
with browser DevTools' Network tab.
**Tasks:**
- [ ] `axum` server: `POST /login` proxies credentials to OmniView, scrapes
cookies, reads `/title_app.asp`, extracts `APPLET_ID` and other params
- [ ] `GET /ws/console` upgrades to WebSocket, opens TCP to OmniView, runs
handshake, then runs a bidirectional pump:
- OmniView → decoder → RGBA blit messages → WS
- WS → input event → PointerEvent/KeyEvent → OmniView
- [ ] Static-files handler serving the built Vite frontend from `dist/`
- [ ] Configurable via `config.toml`: bind addr, OmniView host, Step CA cert
paths for mTLS-protecting the proxy itself
- [ ] systemd quadlet manifest for Podman deployment on whichever homelab host
cichlid eventually places it
**Deliverable:** `podman run blekin`, navigate to its URL, log in,
see the OmniView KVM in a browser tab. Works on any machine on the wireguard
mesh, no Java anywhere.
---
## Phase 8 — Frontend (`crates/ericrfb-frontend`)
**Goal:** Minimal, fast canvas-based renderer matching your stack preferences
(Vite, React, SWC, TS).
**No Java references** — the frontend is pure WS-protocol consumer.
**Tasks:**
- [ ] `Console.tsx`: `<canvas>` sized to framebuffer dimensions; resize handler
reallocates on `Event::Resize`
- [ ] `decoder.ts`: receive WS messages, dispatch on tag:
- `blit`: `ctx.putImageData(new ImageData(rgba, w, h), x, y)`
- `copy`: `ctx.drawImage(canvas, srcX, srcY, w, h, x, y, w, h)`
- `resize`: reallocate canvas
- `ping`: ignored (handled in proxy)
- [ ] `input.ts`: pointer + keyboard event capture, send as binary WS frames
- [ ] Toolbar: CtrlAltDel, full-screen, mouse-mode toggle
- [ ] Light/dark themes matching your other tools' aesthetic
**Deliverable:** A polished console UI that doesn't look like a 2005 Java
applet.
---
## Phase 9 — Optional: encoding 9 (IIP) and encoding 10
**Goal:** Performance parity with the original Java applet, only worth doing if
encoding 7 (Tight) doesn't cut it in practice.
**Java references:**
- `ByteColorRFBRenderer.do()` line 248 — encoding 9 dispatcher
- `ByteColorRFBRenderer.if(...)` line 490 + line 580 — the per-tile delta
application using the `t[]` cache
- `t.java` — the per-tile state class. Read this in full; it's stateful across
rectangles in a way the other encodings aren't.
- `ByteColorRFBRenderer.for(x,y,w,h)` line 109 — encoding 10 (Raw with tile
interleave); much simpler than encoding 9
**Approach:**
- [ ] Port `t.java` first as `tile_cache.rs`
- [ ] Encoding 10 is a quick win — basically Raw with a 16×16 deinterleave loop
- [ ] Encoding 9 needs the cache plumbed end-to-end. Get a known-good capture
from a real Java session as ground truth. Compare frame-by-frame to
validate.
**Deliverable:** Bandwidth and latency matching the original applet. May never
be needed.
---
## Phase 10 — Polish
- [ ] Virtual media (the Floppy/CD-ROM image redirection in the Belkin UI) —
separate sub-protocol, not in `ac.java`. Probably documented in another
`aj`/`ac` sibling class. Defer until everything else works.
- [ ] Reconnection logic: the OmniView drops sessions on reboot; reconnect with
backoff
- [ ] Multi-port switching: the OmniView fronts an Avocent KVM — the `port_id`
field in the ServerInit selects between attached HP servers
- [ ] mTLS between proxy and browser using your Step CA
- [ ] Quantum-safe TLS for the proxy's frontend listener (matches your
preferences); the OmniView side stays plain TCP because the device can't
do anything modern
---
## Risk register
| Risk | Likelihood | Mitigation |
|------|------------|------------|
| Encoding 9 needed for usable framerates | Low | SetEncodings doesn't advertise 9 unless user picks "advanced" mode in `RemoteConsoleApplet.if(d2)` line 423; default `[255, 7, -250]` works fine |
| Keyboard wire format more complex than `aw.a(byte)` suggests | Medium | `KeyEventHandler.java` is small; read it before Phase 6 |
| HTTP login flow has CSRF / session-binding quirks | Medium | Capture with DevTools first; any irregularities are scriptable |
| `h.int()` (u16) vs `aw.int()` (varint) name collision | High during development | `aw.f()` rect headers use `h.int()` = u16; `aw.int()` varint is only for Tight stream lengths. Strict newtypes on `proto.rs` primitives; never use raw integers |
| Zlib stream desync from dropped/skipped rectangles | High if architecture wrong | Decoder owns the stream; consumers can drop output but never input |
## Success criteria
A working v1 ships when:
1. Browser tab on any device on the wireguard mesh shows the live OmniView KVM
2. Mouse and keyboard work for BIOS, OS installer, and running OS
3. Connection survives ≥ 8 hours uninterrupted
4. No Java anywhere in the stack
5. Source on git.lair.cafe under a permissive license, with a README that
names the Peppercon e-RIC heritage so the next person searching for this
has a chance of finding it