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>
This commit is contained in:
2026-05-06 13:40:40 +03:00
parent 6e1a6fc29d
commit a60cee3f23

View File

@@ -38,8 +38,12 @@ blekin/
Every byte derives from `aw.java`, `ac.java`, and `ByteColorRFBRenderer.java`.
**Wire facts:**
- Read primitives are mixed: u8, u16-BE, varint (13 bytes, per `aw.int()` at
line 484). Write primitives are u8 and u16-BE only.
- 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,
@@ -53,21 +57,21 @@ Every byte derives from `aw.java`, `ac.java`, and `ByteColorRFBRenderer.java`.
| 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 4 more, see `aw.a(int)`) | line 264 |
| 3 | S→C | 15 bytes: `"-RIC RFB MM.NN\n"` (digits ASCII) | `int(by)` line 395 |
| 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 | varint length + N bytes server name | `l()` line 413 |
| 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 | 25 bytes pixel-format-ish struct | `i()` line 519 |
| 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 | 16 bytes ServerInit: `[bX, w_u16, h_u16, bE, a, try, a3, x_u16, ao_u16, v_u16, bM, bW, bU, 3 pad]` | `k()` line 435 |
| 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 244 of
**Server-to-client message types** (from `ac.char()` switch at line 292 of
`ac.java`):
| Type | Reader | Purpose |
@@ -77,18 +81,18 @@ After step 11, the client sends `SetEncodings` and a non-incremental
| 2 | — | Bell |
| 3 | `aw.goto()` | ServerCutText |
| 7 | `aw.l()` | server-name update |
| 8 | `aw.e()` | pixel-format change (reads 25-byte struct) |
| 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** — must respond with `aw.if(0)` (msg 149) |
| 150 | `aw.do()` | bandwidth measurement probe |
| 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 233 of `ac.java`):
**Per-rectangle encoding dispatch** (`ac.e()` at line 218 of `ac.java`, switch at line 226):
| Encoding | Renderer method | Notes |
|----------|-----------------|-------|
@@ -107,9 +111,9 @@ After step 11, the client sends `SetEncodings` and a non-incremental
| 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]` |
| 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 `[149, 0,0,0, n_u32]` |
| 149 | `if(int)` line 636 | PingResponse `[-107(=149 unsigned), 0,0,0, n_i32]` (8 bytes) |
---
@@ -131,25 +135,33 @@ After step 11, the client sends `SetEncodings` and a non-incremental
## Phase 1 — Protocol primitives (`ericrfb/src/proto.rs`)
**Goal:** Faithful Rust port of `aw.java`'s read/write helpers. No I/O — all
operations on `&mut impl AsyncRead` / `&mut impl AsyncWrite` (or `Buf`/`BufMut`
for unit tests).
**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:**
- `aw.int()` line 484 — varint, 13 bytes, top bit = continuation. **The single
most important primitive; document it in a code comment with the exact byte
examples.**
- `aw.new()` line 454`read_u8`
- `h.try()`, `h.do()`, `h.char()`, `h.byte()` — wrapped i8/u8/i16/string reads
through the `h.java` adapter class. Map to `read_u8`, `read_u16_be`,
read-length-prefixed-string.
- `aw.f()` line 468 — rectangle header reader (varint x, varint y, varint w,
varint h, u8 encoding). **Critical — these coords are varints, not u16.**
- `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 484varint, 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_varint` / `write_*` primitives with property tests
- [ ] `RectHeader { x: u32, y: u32, w: u32, h: u32, encoding: u8 }`
- [ ] Length-prefixed-string reader with `ISO-8859-1` decode
- [ ] `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
@@ -171,9 +183,10 @@ read_half, write_half)`.
bytes)
- `aw.char()` line 425 — 2-byte port-init message
- `aw.k()` line 435 — ServerInit reader
- `aw.i()` line 519 — pixel-format struct reader
- `aw.a(int)` line 350 — error code → string (1=no perm, 2=exclusive, 6=auth
failed, etc.)
- `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 }`
@@ -196,10 +209,12 @@ to a `Framebuffer { width, height, pixels: Vec<u8> }` (8bpp), expand to RGBA via
LUT, write a PNG to disk.
**Java references:**
- `ac.char()` line 244 (of `ac.java`) — server-msg dispatch loop
- `ac.f()` line 213 → `ac.e()` line 165 — FramebufferUpdate reader
- `aw.null()` line 459 — FramebufferUpdate header (1 pad + u16 num_rects)
- `aw.f()` line 468per-rectangle header
- `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 459FramebufferUpdate 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
@@ -233,15 +248,18 @@ us a long way.
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 bytes ignored + 1 byte payload)
- `aw.if(int)` line 636 — ping response writer
- `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), 131 (debug string,
log), 132 (RFB command, log key=value), 148 (ping → respond), 150
(bandwidth probe — echo per `aw.do()` line 642)
- [ ] 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/`
@@ -296,9 +314,10 @@ identical for static screens.
actually usable.
**Java references:**
- `aw.a(boolean, int, int, int, int, int)` line 612 — PointerEvent writer.
Payload: `[5, button_mask, x_u16, y_u16]`. With `bl=true`, byte 0 becomes 147
for "exclusive/relative" mode — ignore for v1.
- `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
@@ -309,7 +328,8 @@ actually usable.
- `KbdMapping_en.java` (in rcsoftkbd) — alternate mapping path
**Tasks:**
- [ ] `PointerEvent { mask: u8, x: u16, y: u16 }` writer
- [ ] `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`
@@ -431,7 +451,7 @@ be needed.
| 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 |
| Varint vs u16 confusion in `aw` | High during development | Strict types on `proto.rs` primitives; never use raw integers |
| `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