diff --git a/CLAUDE.md b/CLAUDE.md index 6e1cc17..9433ce9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ Bare-metal USB proxy firmware for NXP i.MX RT1062. Man-in-the-middle USB HID dev - `src/hurra.c` / `src/hurra.h` — Hurra binary protocol parser (TinyFrame), default - `src/proto.h` — compile-time protocol selector (`proto_*` -> Hurra or Ferrum) - `src/ferrum.c` / `src/ferrum.h` — Ferrum ASCII parser (opt-in via `PROTOCOL=ferrum`) -- `src/smooth.c` — Smooth motion queue, bezier, humanization, sub-pixel accumulation +- `src/humanize.c` — Always-on humanization filter applied to every injected delta (jitter, micro-correction, sub-pixel carry); the standalone smooth/easing trajectory generator (smooth.c) was retired — humanize.c is now the single humanization path - `src/usb_host.c` / `src/usb_device.c` — EHCI host + device controllers - `src/main.c` — Main loop: poll → merge → send @@ -36,4 +36,4 @@ Bare-metal USB proxy firmware for NXP i.MX RT1062. Man-in-the-middle USB HID dev - `make flash` — flashes via teensy_loader_cli - `make clean` — removes objects + artifacts - ARM GCC via PlatformIO toolchain -- Hot-path files (-O2 + -ffast-math): kmbox, hurra (or ferrum), actions, smooth, usb_host, usb_device, humanize +- Hot-path files (-O2 + -ffast-math): kmbox, hurra (or ferrum), actions, usb_host, usb_device, humanize diff --git a/Makefile b/Makefile index 47bfb8f..a575d58 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ LDFLAGS = $(MCU_FLAGS) \ CORE_SRC = core/startup.c core/bootdata.c SRC = src/main.c src/usb_host.c src/usb_device.c src/desc_capture.c \ - src/kmbox.c src/humanize.c src/smooth.c src/actions.c src/led.c \ + src/kmbox.c src/humanize.c src/actions.c src/led.c \ $(PROTO_SRC) OBJ = $(CORE_SRC:.c=.o) $(SRC:.c=.o) @@ -58,7 +58,7 @@ $(TARGET).hex: $(TARGET).elf $(OBJCOPY) -O ihex -R .eeprom $< $@ # Hot-path sources get -O2 instead of -Os -HOT_SRC = src/usb_host.o src/usb_device.o src/kmbox.o src/smooth.o \ +HOT_SRC = src/usb_host.o src/usb_device.o src/kmbox.o \ src/humanize.o src/actions.o ifeq ($(PROTOCOL),hurra) HOT_SRC += src/hurra.o src/third_party/TinyFrame/TinyFrame.o @@ -77,3 +77,11 @@ clean: rm -f $(OBJ) $(TARGET).elf $(TARGET).hex .PHONY: all flash clean + +# Host-native unit tests (no cross-compile). humanize.c must stay free of +# hardware headers behind HUMANIZE_HOSTTEST so it builds with system gcc. +.PHONY: test +test: + cc -std=c11 -O2 -DHUMANIZE_HOSTTEST -Isrc -o /tmp/humanize_test \ + test/humanize_test.c src/humanize.c -lm + /tmp/humanize_test diff --git a/docs/superpowers/plans/2026-06-02-builtin-humanization.md b/docs/superpowers/plans/2026-06-02-builtin-humanization.md new file mode 100644 index 0000000..d10db96 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-builtin-humanization.md @@ -0,0 +1,709 @@ +# Built-in Humanization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the opt-in `smooth.c` trajectory generator with a single always-on, default-on humanization *filter* that perturbs every injected mouse delta (jerk, micro-noise, dither, human caps) while leaving real-mouse passthrough untouched. + +**Architecture:** One module, `src/humanize.c`, exposes a pure per-frame filter `humanize_filter(&dx,&dy)` that transforms the *injected* delta only. It is the single consumption point for pending injection (`kmbox_take_injection`), called by both the merge and synth send paths in `src/kmbox.c`. Displacement is conserved by a 1st-order exponential "owed" drain; correlated noise + sub-pixel dither break Tier-1 signatures; a human per-frame cap prevents teleport/saturation. The `smooth.c` generator (queue/easing/Fitts) is retired — it was already off the active integration path (`bridge → hurra_move`, never `move_smooth`). + +**Tech Stack:** Bare-metal C (i.MXRT1062, ARM GCC `-O2 -ffast-math`), fixed-point + single-precision float, host-compiled unit tests via a native `gcc` target. + +--- + +## File Structure + +- `src/humanize.h` — public API: `humanize_init`, `humanize_filter`, `humanize_timing_next`, `humanize_set_level`, `humanize_reseed`. (Replaces the stub.) +- `src/humanize.c` — the filter: RNG + fast-math primitives (moved from `smooth.c`), per-session state, per-frame filter, timing jitter, level presets. (Replaces the stub.) +- `src/kmbox.c` — add `kmbox_take_injection`; route merge fast/slow + `kmbox_send_pending` injection consumption through it; call `humanize_init` is done from main. +- `src/main.c` — call `humanize_init(interval_us)`; replace the PIT-tick `smooth_*` generator block with `humanize_timing_next`. +- `src/ferrum.c` — add `km.human(level)` command. +- `src/hurra.c` — `MOUSE_MOVE_SMOOTH` (0x11) routes to the same raw path as `MOUSE_MOVE`. +- `src/smooth.c`, `src/smooth.h`, `src/smooth_config.h` — deleted. +- `Makefile`, `CLAUDE.md` — drop `smooth`, add the host-test target. +- `test/humanize_test.c` — host unit tests. +- `tools/humanization_analyze.py` — statistical Tier-1 analyzer. + +--- + +## Task 1: Host test harness + +**Files:** +- Create: `test/humanize_test.c` +- Modify: `Makefile` (add `test` target) + +- [ ] **Step 1: Add a native test target to the Makefile** + +Append to `Makefile`: + +```makefile +# Host-native unit tests (no cross-compile). humanize.c must stay free of +# hardware headers behind HUMANIZE_HOSTTEST so it builds with system gcc. +.PHONY: test +test: + cc -std=c11 -O2 -DHUMANIZE_HOSTTEST -Isrc -o /tmp/humanize_test \ + test/humanize_test.c src/humanize.c -lm + /tmp/humanize_test +``` + +- [ ] **Step 2: Write the test scaffold (one passing assert)** + +Create `test/humanize_test.c`: + +```c +#include +#include +#include +#include +#include "humanize.h" + +static int failures = 0; +#define CHECK(cond, msg) do { if (!(cond)) { \ + printf("FAIL: %s\n", msg); failures++; } } while (0) + +int main(void) { + humanize_init(1000); /* 1 ms frame */ + CHECK(1, "scaffold"); + printf(failures ? "\n%d FAILED\n" : "\nALL PASSED\n", failures); + return failures ? 1 : 0; +} +``` + +- [ ] **Step 3: Stub the header so the scaffold links** + +Create `src/humanize.h` (final API; bodies arrive in Task 2): + +```c +#pragma once +#include +#include + +/* Always-on humanization filter. Operates on the INJECTED mouse delta only; + * real-mouse passthrough is never routed through it. */ +void humanize_init(uint32_t interval_us); /* seed + level default */ +void humanize_filter(int16_t *dx, int16_t *dy); /* in-place, per frame */ +uint32_t humanize_timing_next(uint32_t base_ldval, bool *out_skip); +void humanize_set_level(uint8_t level); /* 0=off..3=strong */ +``` + +- [ ] **Step 4: Create a minimal `src/humanize.c` so the target builds** + +Replace `src/humanize.c` with: + +```c +#include "humanize.h" +void humanize_init(uint32_t interval_us) { (void)interval_us; } +void humanize_filter(int16_t *dx, int16_t *dy) { (void)dx; (void)dy; } +uint32_t humanize_timing_next(uint32_t b, bool *s) { *s = false; return b; } +void humanize_set_level(uint8_t level) { (void)level; } +``` + +- [ ] **Step 5: Run the harness** + +Run: `make test` +Expected: `ALL PASSED` + +- [ ] **Step 6: Commit** + +```bash +git add Makefile test/humanize_test.c src/humanize.h src/humanize.c +git commit -m "test: host harness + humanize.c filter API skeleton" +``` + +--- + +## Task 2: Filter core — conservation, idle gate, clamp + +**Files:** +- Modify: `src/humanize.c` +- Test: `test/humanize_test.c` + +- [ ] **Step 1: Write failing tests for the three invariants** + +Add to `test/humanize_test.c` `main()` before the summary: + +```c + /* (A) Conservation: summed output == summed injected, within rounding. */ + humanize_init(1000); + humanize_set_level(2); + long sx = 0; + for (int i = 0; i < 5000; i++) { /* steady 3 px/frame stream */ + int16_t dx = 3, dy = 0; + humanize_filter(&dx, &dy); + sx += dx; + } + /* feed zeros to flush the owed accumulator */ + for (int i = 0; i < 200; i++) { int16_t dx = 0, dy = 0; humanize_filter(&dx,&dy); sx += dx; } + CHECK(labs(sx - 5000L*3) <= 2, "conservation: output sum tracks input sum"); + + /* (B) Idle gate: zero in, settled -> zero out (no tremor on still cursor). */ + humanize_init(1000); + for (int i = 0; i < 50; i++) { int16_t dx=0, dy=0; humanize_filter(&dx,&dy); } + int moved = 0; + for (int i = 0; i < 500; i++) { int16_t dx=0, dy=0; humanize_filter(&dx,&dy); if (dx||dy) moved=1; } + CHECK(!moved, "idle gate: still cursor stays still"); + + /* (C) Human cap: a huge single injection never emits a teleport frame. */ + humanize_init(1000); + humanize_set_level(2); + int16_t bx = 30000, by = 0; long total = 0; int maxframe = 0; + humanize_filter(&bx, &by); total += bx; if (abs(bx) > maxframe) maxframe = abs(bx); + for (int i = 0; i < 4000; i++) { int16_t dx=0,dy=0; humanize_filter(&dx,&dy); total += dx; if (abs(dx)>maxframe) maxframe=abs(dx); } + CHECK(maxframe <= 127, "cap: no single frame exceeds human ceiling"); + CHECK(labs(total - 30000) <= 4, "cap: clamped motion is carried, not dropped"); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `make test` +Expected: FAIL (conservation/idle/cap assertions fail against the stub). + +- [ ] **Step 3: Implement the filter core** + +Replace `src/humanize.c` with (RNG/fast-math primitives are copied verbatim from `smooth.c`; the filter is new): + +```c +#include "humanize.h" +#include +#include + +/* ── tunables ───────────────────────────────────────────────────────── */ +#define HZ_DEFAULT_LEVEL 2 /* boot default: on, "normal" */ +#define HZ_MAX_PER_FRAME 127 /* human per-frame ceiling (counts) */ +#define HZ_IDLE_EPS 0.01f /* |owed| below this = settled */ + +/* Per-level presets: drain rate k (fraction of owed emitted per frame), + * and perpendicular-noise amplitude (counts RMS at speed). Level 0 = off. */ +static const float HZ_DRAIN[4] = { 1.0f, 0.55f, 0.40f, 0.30f }; +static const float HZ_NOISE[4] = { 0.0f, 0.15f, 0.35f, 0.60f }; + +static struct { + uint8_t level; + float drain, noise_amp; + float owed_x, owed_y; /* undelivered injected motion */ + float res_x, res_y; /* sub-pixel residual */ + float ewma; /* noise correlation alpha */ + float n_perp; /* correlated perpendicular noise state */ + uint32_t a, b, c, ctr; /* SFC32 */ + uint32_t timing_lfsr; + int idle; +} S; + +/* ── RNG (verbatim from smooth.c) ───────────────────────────────────── */ +static inline uint32_t sfc32(void) { + uint32_t t = S.a + S.b + S.ctr++; + S.a = S.b ^ (S.b >> 9); + S.b = S.c + (S.c << 3); + S.c = ((S.c << 21) | (S.c >> 11)) + t; + return t; +} +static inline float sfc32_uniform(void) { /* [-1, 1) */ + int32_t bal = (int32_t)(sfc32() >> 8) - 0x800000; + return (float)bal * (1.0f / 8388608.0f); +} + +/* ── seeding ────────────────────────────────────────────────────────── */ +#ifdef HUMANIZE_HOSTTEST +static uint32_t hw_entropy(void) { return 0x12345678u; } /* deterministic */ +#else +#include "imxrt.h" +static uint32_t hw_entropy(void) { + volatile uint32_t *dwt_ctrl = (volatile uint32_t *)0xE0001000; + volatile uint32_t *dwt_cyc = (volatile uint32_t *)0xE0001004; + *dwt_ctrl |= 1; + volatile uint32_t *uid0 = (volatile uint32_t *)0x401F4410; + return *dwt_cyc ^ *uid0; +} +#endif + +void humanize_set_level(uint8_t level) { + if (level > 3) level = 3; + S.level = level; + S.drain = HZ_DRAIN[level]; + S.noise_amp = HZ_NOISE[level]; +} + +void humanize_init(uint32_t interval_us) { + memset(&S, 0, sizeof(S)); + uint32_t seed = hw_entropy(); + S.a = seed ^ 0xCAFEBABEu; S.b = seed ^ 0xDEADBEEFu; + S.c = seed ^ 0x8BADF00Du; S.ctr = 1; + if (!S.a) S.a = 0xCAFEBABEu; + for (int i = 0; i < 16; i++) sfc32(); + S.timing_lfsr = sfc32() | 1u; + /* noise correlation: ~per-ms alpha, mild */ + S.ewma = 0.85f; + /* per-session personality: small noise-amp jitter from hardware seed */ + humanize_set_level(HZ_DEFAULT_LEVEL); + S.noise_amp *= 1.0f + 0.15f * sfc32_uniform(); + (void)interval_us; +} + +/* Drain `owed` by a fraction this frame (1st-order response -> rise/settle, + * nonzero varying jerk), add correlated perpendicular noise, dither via + * sub-pixel residual, clamp to the human ceiling and carry the overflow. + * Conserves total displacement: everything not emitted stays in owed/res. */ +static int16_t drain_axis(float *owed, float *res, float emit_v, float noise) { + float want = emit_v + noise + *res; + /* clamp emit to human ceiling; overflow returns to owed via res path */ + if (want > (float)HZ_MAX_PER_FRAME) want = (float)HZ_MAX_PER_FRAME; + if (want < -(float)HZ_MAX_PER_FRAME) want = -(float)HZ_MAX_PER_FRAME; + int16_t out = (int16_t)(want >= 0 ? (want + 0.5f) : (want - 0.5f)); + *res = want - (float)out; /* sub-pixel + clamp remainder */ + *owed -= emit_v; /* consume the drained portion */ + return out; +} + +void humanize_filter(int16_t *dx, int16_t *dy) { + if (S.level == 0) return; /* off: passthrough */ + + S.owed_x += (float)*dx; + S.owed_y += (float)*dy; + + /* idle gate: nothing owed and residual settled -> emit nothing */ + if (fabsf(S.owed_x) < HZ_IDLE_EPS && fabsf(S.owed_y) < HZ_IDLE_EPS && + fabsf(S.res_x) < 0.5f && fabsf(S.res_y) < 0.5f) { + if (S.idle < 1000) S.idle++; + *dx = 0; *dy = 0; + return; + } + S.idle = 0; + + float ex = S.owed_x * S.drain; /* this frame's emitted velocity */ + float ey = S.owed_y * S.drain; + + /* correlated perpendicular noise, scaled by speed */ + float speed = sqrtf(ex*ex + ey*ey); + S.n_perp = S.ewma * S.n_perp + (1.0f - S.ewma) * sfc32_uniform(); + float nmag = S.n_perp * S.noise_amp * speed; + float nx = 0.0f, ny = 0.0f; + if (speed > 1e-3f) { nx = -ey / speed * nmag; ny = ex / speed * nmag; } + + *dx = drain_axis(&S.owed_x, &S.res_x, ex, nx); + *dy = drain_axis(&S.owed_y, &S.res_y, ey, ny); +} + +uint32_t humanize_timing_next(uint32_t base_ldval, bool *out_skip) { + *out_skip = false; + if (S.level == 0) return base_ldval; + /* xorshift32 jitter, ±~12%, never a skipped poll (bimodal = detectable) */ + S.timing_lfsr ^= S.timing_lfsr << 13; + S.timing_lfsr ^= S.timing_lfsr >> 17; + S.timing_lfsr ^= S.timing_lfsr << 5; + float u = (float)(S.timing_lfsr >> 8) * (1.0f / 16777216.0f) - 0.5f; + float r = (float)base_ldval * (1.0f + 0.12f * u); + float lo = (float)base_ldval * 0.80f, hi = (float)base_ldval * 1.20f; + if (r < lo) r = lo; if (r > hi) r = hi; + return (uint32_t)r; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `make test` +Expected: `ALL PASSED` + +- [ ] **Step 5: Add a jerk/quantization regression test** + +Add before the summary in `test/humanize_test.c`: + +```c + /* (D) Tier-1: a constant-velocity stream must not emit a long run of + * identical values (anti-quantization) and must vary frame-to-frame. */ + humanize_init(1000); + humanize_set_level(2); + int max_run = 0, run = 0; int16_t prev = -999; + for (int i = 0; i < 3000; i++) { + int16_t dx = 5, dy = 5; humanize_filter(&dx, &dy); + if (dx == prev) { run++; if (run > max_run) max_run = run; } else run = 0; + prev = dx; + } + CHECK(max_run < 200, "anti-quantization: no long identical-value run"); +``` + +- [ ] **Step 6: Run and commit** + +Run: `make test` → Expected: `ALL PASSED` + +```bash +git add src/humanize.c src/humanize.h test/humanize_test.c +git commit -m "feat: humanization filter core (conserving 1st-order drain + noise + dither + cap)" +``` + +--- + +## Task 3: Single injection-consumption point in kmbox + +**Files:** +- Modify: `src/kmbox.c` (add helper, route fast/slow merge + send_pending through it) +- Modify: `src/kmbox.h` (none required; helper is static) + +- [ ] **Step 1: Add the consumption helper near the merge code** + +In `src/kmbox.c`, add `#include "humanize.h"` if not present, and add above `kmbox_merge_report`: + +```c +/* Pull this frame's injected delta out of the pending accumulators, run it + * through the humanization filter, and return the humanized amount to apply. + * Real-mouse passthrough is NOT routed here — only injected motion. */ +static void kmbox_take_injection(int16_t *out_dx, int16_t *out_dy) +{ + int16_t dx = inject.mouse_dx; + int16_t dy = inject.mouse_dy; + inject.mouse_dx = 0; /* filter now owns the remainder via owed */ + inject.mouse_dy = 0; + humanize_filter(&dx, &dy); + *out_dx = dx; + *out_dy = dy; +} +``` + +- [ ] **Step 2: Route the fast merge path through it** + +In `kmbox_merge_report` fast path, replace the block that reads `inject.mouse_dx`/`inject.mouse_dy` into the report (the `done_dx`/`done_dy` carry block from the current code) so the injected component comes from `kmbox_take_injection`. Concretely, at the top of the fast-path body (after the buttons line) insert: + +```c + int16_t inj_dx, inj_dy; + kmbox_take_injection(&inj_dx, &inj_dy); +``` + +and change each axis to add `inj_dx`/`inj_dy` (instead of `inject.mouse_dx`/`inject.mouse_dy`), e.g. for the 8-bit X axis: + +```c + int32_t rx = (int8_t)report[mouse_layout.x_byte]; + int32_t mx = rx + inj_dx; + if (mx > mouse_layout.x_max) mx = mouse_layout.x_max; + if (mx < -mouse_layout.x_max) mx = -mouse_layout.x_max; + report[mouse_layout.x_byte] = (uint8_t)(int8_t)mx; +``` + +(Apply the same `inj_dx`/`inj_dy` substitution to the 16-bit X, both Y branches.) The field clamp stays as a hard safety bound; the filter's own cap means it rarely triggers. Update the telemetry call to `proto_notify_axes(inj_dx, inj_dy, w_tlm);` and the dirty recompute to drop the now-zeroed dx/dy: + +```c + inject.mouse_dirty = (inject.mouse_buttons != 0 || + inject.mouse_wheel != 0); +``` + +- [ ] **Step 3: Route the slow path and synth path the same way** + +In `kmbox_merge_report_slow`, replace `inject.mouse_dx`/`inject.mouse_dy` usage with a leading `kmbox_take_injection(&inj_dx,&inj_dy);` and use `inj_dx/inj_dy`; drop the old `inject.mouse_dx = 0` lines. In `kmbox_send_pending`, replace the `int32_t dx = inject.mouse_dx; int32_t dy = inject.mouse_dy;` lines with: + +```c + int16_t inj_dx, inj_dy; + kmbox_take_injection(&inj_dx, &inj_dy); + int32_t dx = inj_dx; + int32_t dy = inj_dy; +``` + +and remove the subsequent `inject.mouse_dx = 0; inject.mouse_dy = 0;` (the filter owns the remainder now). Keep wheel handling unchanged. + +- [ ] **Step 4: Make `inject.mouse_dirty` stay true while the filter still owes motion** + +The filter holds undelivered motion in `owed` after `inject.mouse_dx` is zeroed, so the send paths must keep firing. Add to `src/humanize.h`: + +```c +bool humanize_pending(void); /* true while owed motion remains to emit */ +``` + +and to `src/humanize.c`: + +```c +bool humanize_pending(void) { + return fabsf(S.owed_x) >= HZ_IDLE_EPS || fabsf(S.owed_y) >= HZ_IDLE_EPS; +} +``` + +Then in `kmbox.c`, anywhere `inject.mouse_dirty` is recomputed, OR in `humanize_pending()`: + +```c + inject.mouse_dirty = (inject.mouse_buttons != 0 || + inject.mouse_wheel != 0 || + humanize_pending()); +``` + +- [ ] **Step 5: Build firmware (both protocols)** + +Run: `make clean && make && make clean && make PROTOCOL=ferrum` +Expected: both link, no warnings. + +- [ ] **Step 6: Commit** + +```bash +git add src/kmbox.c src/humanize.c src/humanize.h +git commit -m "feat: route all injection through the humanization filter (single point)" +``` + +--- + +## Task 4: Wire init + timing into main loop; retire the generator's PIT block + +**Files:** +- Modify: `src/main.c` + +- [ ] **Step 1: Initialise the filter where `smooth_init` was** + +In `src/main.c`, replace line 192 `smooth_init(interval_us);` with: + +```c + humanize_init(interval_us); +``` + +and replace `#include "smooth.h"` with `#include "humanize.h"` (and remove any `humanize_init()` stub call if a separate one exists). + +- [ ] **Step 2: Replace the PIT-tick generator block with timing jitter only** + +In the main loop (current lines ~231-239), replace: + +```c + if (pit_tick_pending) { + pit_tick_pending = false; + did_work = true; + bool skip = false; + uint32_t next_ldval = smooth_timing_next(pit_base_ldval, &skip); + pit_next_ldval = next_ldval; + if (!skip) { + int16_t sx, sy; + smooth_process_frame(&sx, &sy); + if (sx || sy) kmbox_inject_smooth(sx, sy); + } + } +``` + +with: + +```c + if (pit_tick_pending) { + pit_tick_pending = false; + did_work = true; + bool skip = false; + pit_next_ldval = humanize_timing_next(pit_base_ldval, &skip); + } +``` + +- [ ] **Step 3: Build** + +Run: `make` +Expected: links (will still reference `kmbox_inject_smooth`? no — removed). Any remaining `smooth_*` references are removed in Task 6; if the build breaks on `smooth.h` here, that's expected until Task 6. To keep this task self-contained, temporarily keep `src/smooth.c` compiled — it just no longer runs. + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/main.c +git commit -m "feat: init humanization filter + drive PIT jitter from it" +``` + +--- + +## Task 5: `km.human(level)` command — default-on, minimal + +**Files:** +- Modify: `src/ferrum.c` +- Modify: `src/hurra.c` (optional binary type; ASCII path is primary) +- Modify: `hurra-app/src/ferrum_parser.c`, `hurra-app/src/bridge.c` (bridge passthrough) + +- [ ] **Step 1: Add the firmware command** + +In `src/ferrum.c`, add `#include "humanize.h"` and a handler near `cmd_baud`: + +```c +static void cmd_human(arg_t *args, uint8_t nargs) +{ + if (nargs != 1) return; + int32_t n; + if (!parse_int(args[0].p, args[0].len, &n)) return; + if (n < 0) n = 0; + if (n > 3) n = 3; + humanize_set_level((uint8_t)n); +} +``` + +and register it in `dispatch` next to `"baud"`: + +```c + if (name_is(name, name_len, "human")) { cmd_human(args, nargs); return; } +``` + +- [ ] **Step 2: Confirm default-on requires no command** + +`humanize_init` already calls `humanize_set_level(HZ_DEFAULT_LEVEL)` (= 2). Verify no code path resets it to 0 on connect. Grep: + +Run: `grep -rn "humanize_set_level\|HZ_DEFAULT_LEVEL" src/` +Expected: only `humanize_init` (default) and `cmd_human` (explicit) set it. + +- [ ] **Step 3: Bridge passthrough (so an external sender can issue it)** + +In `hurra-app/src/ferrum_parser.c`, add an `on_human` callback to `ferrum_callbacks_t`, parse `human` like other single-int setters, and in `hurra-app/src/bridge.c` wire `cbs.on_human = cb_human;` where `cb_human` sends the command on to the device. If the device firmware is reached via the Ferrum-ASCII bridge, the bridge must forward `km.human(n)`; mirror how `cb_baud`/`cmd_baud` is forwarded. (If no binary Hurra type exists for it, forward as a passthrough text command.) + +- [ ] **Step 4: Build firmware + bridge** + +Run: `make PROTOCOL=ferrum && make` then `cd ../hurra-app && make` +Expected: all build. + +- [ ] **Step 5: Commit** + +```bash +git add src/ferrum.c ../hurra-app/src/ferrum_parser.c ../hurra-app/src/bridge.c +git commit -m "feat: km.human(level) control, default-on (level 2)" +``` + +--- + +## Task 6: Retire `smooth.c` and consolidate + +**Files:** +- Delete: `src/smooth.c`, `src/smooth.h`, `src/smooth_config.h` +- Modify: `src/hurra.c`, `src/kmbox.c`, `src/kmbox.h`, `Makefile`, `CLAUDE.md` + +- [ ] **Step 1: Repoint `MOUSE_MOVE_SMOOTH` to the raw path** + +In `src/hurra.c`, change the `move_smooth` handler (line ~209) from `act_move(..., true)` to `act_move(..., false)` so smoothed frames become normal injection (which the filter humanizes): + +```c + act_move(rd_i16le(&msg->data[0]), rd_i16le(&msg->data[2]), false); +``` + +- [ ] **Step 2: Remove `act_move`'s smooth branch and `kmbox_inject_smooth`** + +In `src/actions.c`, drop the `if (smooth) smooth_inject(...)` branch so `act_move` always calls `kmbox_inject_mouse`. In `src/kmbox.c`, delete `kmbox_inject_smooth` and the field-cap call `smooth_set_max_per_frame(...)` in `kmbox_cache_endpoints` (the filter's `HZ_MAX_PER_FRAME` replaces it). Remove `kmbox_inject_smooth` from `src/kmbox.h`. Remove `#include "smooth.h"`. + +- [ ] **Step 3: Delete the files and drop from the build** + +```bash +git rm src/smooth.c src/smooth.h src/smooth_config.h +``` + +In `Makefile`, remove `src/smooth.c` from `CORE_SRC` (line 46) and `src/smooth.o` from `HOT_SRC` (line 61). + +- [ ] **Step 4: Build both protocols, run host tests** + +Run: `make clean && make && make clean && make PROTOCOL=ferrum && make test` +Expected: both firmwares link with no `smooth_*`/`kmbox_inject_smooth` undefined-reference errors; `ALL PASSED`. + +- [ ] **Step 5: Update CLAUDE.md** + +In `CLAUDE.md`, change the `src/smooth.c` line under Key Files to describe `src/humanize.c` (always-on humanization filter) and note the generator was retired. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor: retire smooth.c trajectory generator; humanize.c is the single path" +``` + +--- + +## Task 7: Statistical Tier-1 analyzer + +**Files:** +- Create: `tools/humanization_analyze.py` + +- [ ] **Step 1: Write the analyzer** + +Create `tools/humanization_analyze.py`: + +```python +#!/usr/bin/env python3 +"""Tier-1 humanization analyzer. + +Reads a motion trace (one "dx dy" pair per line, whitespace-separated; lines +that fail to parse are skipped) and reports the kinematic signatures anti-cheat +Tier-1 detectors look for. Compare a captured device-output trace against a +real human baseline (a passthrough-mouse capture). + +Usage: tools/humanization_analyze.py trace.txt [--baseline human.txt] +""" +import sys, argparse, math, statistics + +def load(path): + xs = [] + with open(path) as f: + for line in f: + p = line.split() + if len(p) < 2: continue + try: xs.append((float(p[0]), float(p[1]))) + except ValueError: continue + return xs + +def metrics(tr): + # velocity per frame = the delta itself (1 frame dt) + v = [math.hypot(dx, dy) for dx, dy in tr] + a = [v[i] - v[i-1] for i in range(1, len(v))] # acceleration + j = [a[i] - a[i-1] for i in range(1, len(a))] # jerk + zero_jerk = sum(1 for x in j if abs(x) < 1e-9) / max(len(j), 1) + # longest identical-value run on dx (quantization tell) + run = mx = 1 + for i in range(1, len(tr)): + run = run + 1 if tr[i][0] == tr[i-1][0] else 1 + mx = max(mx, run) + return { + "frames": len(tr), + "zero_jerk_frac": zero_jerk, + "jerk_rms": (statistics.pstdev(j) if len(j) > 1 else 0.0), + "max_identical_run": mx, + "vel_mean": (statistics.mean(v) if v else 0.0), + } + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("trace") + ap.add_argument("--baseline") + a = ap.parse_args() + m = metrics(load(a.trace)) + print(f"trace: {a.trace}") + for k, val in m.items(): print(f" {k}: {val:.4f}" if isinstance(val,float) else f" {k}: {val}") + # Heuristic pass/fail (tune against the human baseline) + flags = [] + if m["zero_jerk_frac"] > 0.20: flags.append("HIGH zero-jerk fraction (robotic)") + if m["max_identical_run"] > 200: flags.append("long identical-value run (quantized)") + if m["jerk_rms"] < 0.05 and m["vel_mean"] > 1.0: flags.append("near-zero jerk variance (too smooth)") + if a.baseline: + b = metrics(load(a.baseline)) + print(f"baseline: {a.baseline}") + for k, val in b.items(): print(f" {k}: {val:.4f}" if isinstance(val,float) else f" {k}: {val}") + print("RESULT:", "FLAGS: " + "; ".join(flags) if flags else "looks human (Tier-1)") + return 1 if flags else 0 + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 2: Smoke-test against a synthetic robotic trace** + +Run: + +```bash +python3 - <<'PY' > /tmp/robotic.txt +for _ in range(2000): print("5 0") +PY +python3 tools/humanization_analyze.py /tmp/robotic.txt; echo "exit=$?" +``` + +Expected: flags the constant-velocity/identical-run trace (`exit=1`). + +- [ ] **Step 3: Commit** + +```bash +git add tools/humanization_analyze.py +git commit -m "test: Tier-1 humanization statistical analyzer" +``` + +--- + +## Self-Review + +**Spec coverage:** +- §3 always-on signal filter, 1st-order light dynamics → Tasks 1–2. ✓ +- §4 integration (injected-only, passthrough untouched, kmbox-compatible) → Task 3 (`kmbox_take_injection` on injection only). ✓ +- §5 placement / single chokepoint → Task 3 (merge fast/slow + synth all consume via one helper). ✓ +- §6 filter algorithm (idle gate, conserving drain, noise, dither, cap, timing) → Tasks 2 & 4. ✓ +- §7 consolidation / retire generator → Task 6. ✓ +- §8 default-on + minimal `km.human` → Tasks 2 (init default) & 5. ✓ +- §9 verification (unit core + analyzer) → Tasks 1–2, 7. ✓ +- §10 limitation (Tier-2 not covered) — documented in spec; no task needed. + +**Placeholder scan:** no TBD/TODO; every code step has full code. Task 3 substitutions reference the existing carry-block by name and show the per-axis replacement pattern explicitly. + +**Type consistency:** `humanize_init/filter/timing_next/set_level/pending` signatures match between `humanize.h`, `humanize.c`, tests, and callers. `kmbox_take_injection(int16_t*, int16_t*)` used consistently. `HZ_MAX_PER_FRAME` (127) matches the field-cap it replaces. + +**Open risk to verify during execution:** the acceptance test "feed a recorded human trace, filter must not degrade human-ness" (§9) needs a captured baseline; Task 7 provides the analyzer but the capture path (HID logger on target, or firmware delta telemetry) is hardware-dependent and should be set up when running on-device. diff --git a/docs/superpowers/specs/2026-06-02-builtin-humanization-design.md b/docs/superpowers/specs/2026-06-02-builtin-humanization-design.md new file mode 100644 index 0000000..2391ca9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-builtin-humanization-design.md @@ -0,0 +1,200 @@ +# Built-in Humanization — Design & Findings + +Status: draft for review +Date: 2026-06-02 +Component: Hurra-v2 firmware (`src/humanize.c`, `src/smooth.c`, `src/kmbox.c`, `src/main.c`) + +## 1. Problem + +Humanization today lives in `src/smooth.c` as a **trajectory generator**, reachable only +through the Hurra `MOUSE_MOVE_SMOOTH` (0x11) frame / `act_move(..., smooth=true)`. Every +other path — raw `km.move`, real-mouse passthrough, synth-injected reports — bypasses it and +emits unhumanized motion. `src/humanize.c` is currently only a DWT-init stub. + +The goal: make humanization a **built-in guarantee** — every report that leaves the device is +"sufficiently humanized" regardless of how the motion was produced — without breaking +low-latency closed-loop control or corrupting already-shaped input. + +## 2. Threat model (what "sufficiently humanized" must defeat) + +The hardware layer is already covered: the device enumerates as an ordinary HID mouse, so +USB-descriptor and `SendInput`-style API detection do not apply. All remaining risk is in the +**motion signal itself**, in two tiers: + +**Tier 1 — statistical / kinematic (common detectors).** Flags: +- Zero or constant jerk; sustained constant-velocity or constant-acceleration phases (humans + never hold these — jerk is always nonzero and varying). +- Quantized / repeated values (identical peak velocities, fixed step sizes, repeated params); + human distributions are continuous. +- Perfectly straight paths, perfectly regular timing cadence, single-frame "teleport" jumps, + saturated delta fields, superhuman speed/distance. + +**Tier 2 — submovement / ballistic analysis (strong detectors, e.g. Activision patent +US11947742B2).** Human aiming decomposes into a fast ballistic primary submovement that +under/overshoots, then smaller corrective submovements, each a pseudo-ballistic velocity bump +with non-zero jerk, separated by perception-action-cycle limits. Flags motion lacking this +structure, "too optimal," or with a **last-minute correction injected right before a click**. + +Sources: US11947742B2 (submovement detection); Castle "Bot or Not" (automated-cursor +detection); BeCAPTCHA-Mouse (arXiv 2005.00890) on kinematic feature sets (velocity, +acceleration, jerk, curvature, straightness). + +### Tier mapping → two kinds of humanization +- **Signal-level** (jerk, continuous-distribution micro-noise, timing dither, no + quantization/teleport/saturation, human kinematic caps) is a *bounded perturbation*. It can + be applied to **every output** because it roughens the signal without replacing intent. + Defeats **Tier 1 for all modes**. +- **Trajectory-level** (ballistic + corrective submovement structure) is a *path generator*. + It defeats **Tier 2**, but only for open-loop "go to target" moves; it cannot be applied to a + closed-loop optimal stream without buffering (latency) or to passthrough without corrupting + real motion. + +## 3. Decision + +**Scope: an always-on, signal-level humanization filter** (Tier-1 guarantee for all input +modes). Tier-2 submovement realism remains the responsibility of whatever generates the +trajectory (the host, or the opt-in `smooth.c` generator). + +**Mechanism: noise + light velocity dynamics.** A lightly-damped 2nd-order response +(mass-spring-damper — the human motor model) with a ~2–4 ms time constant, plus correlated +micro-noise, plus anti-quantization dither, all displacement-conserving and idle-gated. +Effective added latency ≈ 1–2 frames — the most realism that is still safe for closed-loop. + +(Both decisions confirmed during brainstorming.) + +## 4. Integration model (how inputs reach the device) + +External input senders are the consumers. Confirmed from `rn-mouse/` (a representative +aimbot sender with source) and `Input-injection-testbench/protocols.h`: + +- Senders connect to the device as a **kmbox/Ferrum-compatible serial endpoint** and stream + ASCII commands — `km.move(dx,dy)\r\n`, `km.left/right`, `km.click`, `km.wheel`, + `km.echo(0)`, `km.buttons(1)` — typically at 4 Mbps. For us that endpoint is the + **hurra-bridge VCOM/PTY**, which already emulates this surface (`hurra-app/src/ferrum_parser.c` + + `bridge.c` → Hurra binary). **Integration requires no protocol change.** +- The send pattern is a **dense stream of small relative deltas** off a move-queue/worker — + i.e. the closed-loop / streaming workload, not sparse target flicks. +- **The host already owns trajectory humanization.** `rn-mouse` runs WindMouse path + generation, Kalman X/Y filtering, `easeInOut` smoothing, target prediction, distance-based + speed multipliers, and host-side sub-pixel overflow *before* sending. + +Consequences for the design (all reinforce §3): +1. **Filter, not generator.** The device must not re-shape trajectories; WindMouse/Kalman + output would be double-humanized and corrupted. Confirmed. +2. **The filter adds what the host pipeline lacks.** Kalman/easing produce *too-smooth, + low-jerk* output — itself a Tier-1 flag. The signal filter re-injects varying jerk, + continuous micro-noise, timing dither, and anti-quantization, which the host stripped. + Complementary, not redundant. +3. **Path preservation is a hard requirement.** The filter must conserve displacement and stay + a small perturbation so the host's aim still lands and its WindMouse curvature survives. +4. **CS2-DMA-class tools send no input** (ESP/radar only, explicitly no kmbox) — they are the + data-producer half; the aim/input layer (e.g. Pro-CS2's closed aimbot, or `rn-mouse`) is + separate and is the only thing that touches the device. + +## 5. Architecture & placement + +- Promote `src/humanize.c` from stub to the always-on filter module. Pure math + per-session + state; no hardware/protocol knowledge. + - `void humanize_filter_init(uint32_t interval_us);` — seed RNG (reuse the TRNG/OCOTP/DWT + seeding currently in `smooth.c`), set time constants from the frame interval, draw + per-power-cycle personality (noise amplitude, τ jitter) from the hardware UID. + - `void humanize_filter_frame(int16_t *dx, int16_t *dy);` — in-place perturbation of one + frame's composite delta, conserving displacement via internal accumulators. + - `uint32_t humanize_timing_next(uint32_t base_ldval, bool *skip);` — generalize the existing + PIT-reload jitter so it runs always (respecting the cloned bInterval — internal phase + wobble only), not just when `smooth.c` is active. +- **Single output chokepoint.** The filter runs on the merged mouse delta (real passthrough + + any injection) after `kmbox_merge_report` and before `usb_device_send_report`, applied + exactly once to *every* mouse report — merged, synth (`kmbox_send_pending`), and wheel paths. + Implementation note for the plan: consolidate the mouse-report send sites or insert the + filter at each, so coverage is exactly-once with no path missed. +- The filter reads/writes the report's delta fields via the existing `mouse_layout` + field accessors used by the merge. + +## 6. Filter algorithm (per frame, dt = frame interval) + +Input: composite per-frame delta `(dx_in, dy_in)`. State: `owed_{x,y}` accumulator, output +velocity `v_{x,y}`, sub-pixel residual `r_{x,y}`, EWMA noise terms, RNG. + +1. **Idle gate** — if `dx_in==dy_in==0` and velocity settled → output 0, decay state, return. + No tremor on a still cursor. +2. **Displacement-conserving velocity dynamics** — `owed += d_in`; emit this frame through a + lightly-damped 2nd-order response (τ ≈ 2–4 ms) toward draining `owed`; `owed -= emitted`. + Output velocity gains natural rise/settle/jerk; `owed → 0` guarantees Σout = Σin; effective + latency ≈ 1–2 frames. +3. **Correlated micro-noise** — zero-mean perpendicular + tangential noise, EWMA-correlated, + scaled by current speed (reuse `smooth.c` noise/RNG primitives). Adds curvature and varying + jerk; zero-mean so it does not drift net displacement. +4. **Anti-quantization dither + sub-pixel carry** — accumulate fractional output in `r`, emit + the integer part, carry the remainder. Steady motion never emits identical repeated values + and small motion never rounds to zero. +5. **Human kinematic clamp** — cap per-frame magnitude to a human ceiling; overflow carries via + `owed` (never clip-drop, never teleport). Will not bite genuine passthrough. +6. **Timing jitter** — `humanize_timing_next` dithers the PIT reload within bInterval. + +Invariants: displacement conserved (accumulators), bounded latency (τ), idle→zero, +no superhuman/teleport frames. + +## 7. Consolidation: one humanization module + +Resolved direction: **consolidate to a single clean path; no two subsystems.** + +The `smooth.c` trajectory generator (32-slot queue, easing, Fitts gain, overshoot/arc) is +**already dead on the real integration path** — the bridge maps `km.move → hurra_move` (raw +0x10) and never calls `hurra_move_smooth` (0x11); only `examples/hello.c` does. So retiring it +removes complexity without affecting how inputs actually arrive. + +Plan: +- Fold the reusable primitives `smooth.c` already has — hardware-seeded RNG, EWMA correlated + noise, sub-pixel accumulation, PIT timing jitter — into the single `humanize.c` filter. +- Retire the standalone generator (queue + easing + Fitts/overshoot/arc) and its `smooth_*` + trajectory API. `MOUSE_MOVE_SMOOTH` frames, if ever received, route through the normal + inject → filter path (i.e. become equivalent to `MOUSE_MOVE` plus the always-on filter). +- Net result: humanization lives in exactly one module (`humanize.c`), on one path, applied to + every output. The merge's carry + per-frame-cap fixes are subsumed by the filter's step 5. + +## 8. Control surface + +- **Default-on, no user config required.** The filter initialises to a sensible level at boot + (`HUMANIZE_DEFAULT_LEVEL`), independent of the host — if a sender never issues a command, the + guarantee still holds. This is the primary requirement. +- A minimal `km.human(level)` command, levels `0..3` (0 = off, 1 = light, 2 = default, + 3 = strong), only tunes/disables intensity — mainly so the load and aim tests can measure raw + vs filtered. Keep it trivial: one integer arg mapping to a small preset table of + (noise amplitude, τ); no per-parameter API. Wired through the Ferrum firmware parser and the + hurra-app bridge. If wiring proves non-trivial, ship default-on hardcoded and defer the + command. +- Per-power-cycle personality (noise amplitude, τ) seeded from the hardware UID so two + sessions/devices are not statistically identical. + +## 9. Verification + +- **Pure-core unit tests** (host-compiled `humanize.c`): displacement conservation + (Σout == Σin within rounding over long sequences), idle→zero, bounded latency, kinematic + clamp + carry. TDD target. +- **Statistical analyzer** (`tools/humanization_analyze.py`): ingest a captured motion trace + and compute Tier-1 metrics — fraction of zero-jerk frames, repeated-value run lengths, + peak-velocity histogram continuity, straightness/curvature — with pass thresholds derived + from a **real human baseline** (the passthrough mouse capture). +- **Acceptance tests:** (a) feed a synthetic straight constant-velocity stream → output must now + pass the Tier-1 metrics; (b) feed a recorded human trace → the filter must not degrade its + human-ness. (c) feed a WindMouse/Kalman stream (captured from `rn-mouse`) → output passes + Tier-1 and total displacement is preserved within tolerance. + +## 10. Limitations (explicit) + +- Defeats Tier-1 for all input modes. Does **not** manufacture Tier-2 submovement/ballistic + structure for a closed-loop optimal stream — a straight robotic snap becomes a "noisy straight + snap," not a human flick. Tier-2 is the host's / opt-in generator's job. +- Adds ~1–2 frames latency by construction (the τ of the velocity dynamics). + +## 11. Decisions (resolved) + +1. **Consolidate to one module; retire the standalone `smooth.c` generator.** It is already off + the active integration path. Fold its primitives into `humanize.c`. (§7) +2. **Add `km.human(level)` — minimal, default-on.** Boot default = level 2, applied at init with + no dependence on host config; the command only tunes/disables. Ship default-on hardcoded if + the command wiring is non-trivial. (§8) + +Overriding principle from review: **keep the code clean and simple** — one path, one module, +small preset table, no speculative configurability. diff --git a/src/actions.c b/src/actions.c index 22183f4..91f6e1c 100644 --- a/src/actions.c +++ b/src/actions.c @@ -2,7 +2,6 @@ #include "actions.h" #include "kmbox.h" -#include "smooth.h" #include extern uint32_t millis(void); @@ -54,10 +53,10 @@ int8_t act_button_set(uint8_t mask, uint8_t action) { if (action == 0) { g_buttons &= ~mask; - kmbox_inject_mouse(0, 0, g_buttons, 0, false); + kmbox_inject_mouse(0, 0, g_buttons, 0); } else if (action == 1) { g_buttons |= mask; - kmbox_inject_mouse(0, 0, g_buttons, 0, false); + kmbox_inject_mouse(0, 0, g_buttons, 0); } else if (action == 2) { g_buttons &= ~mask; } else { @@ -70,7 +69,7 @@ void act_click(uint8_t button_1based, uint8_t count, uint32_t delay_ms) { uint8_t mask = btn_idx_to_mask(button_1based); g_buttons |= mask; - kmbox_inject_mouse(0, 0, g_buttons, 0, false); + kmbox_inject_mouse(0, 0, g_buttons, 0); if (count == 1) { kmbox_schedule_click_release(mask, delay_ms); @@ -83,15 +82,14 @@ void act_click(uint8_t button_1based, uint8_t count, uint32_t delay_ms) } } -void act_move(int16_t dx, int16_t dy, bool smooth) +void act_move(int16_t dx, int16_t dy) { if (s_swap_xy) { int16_t t = dx; dx = dy; dy = t; } if (s_invert_x) dx = (int16_t)-dx; if (s_invert_y) dy = (int16_t)-dy; g_pos_x += dx; g_pos_y += dy; - if (smooth) smooth_inject(dx, dy); - else kmbox_inject_mouse(dx, dy, g_buttons, 0, false); + kmbox_inject_mouse(dx, dy, g_buttons, 0); } int8_t act_kb_down(uint8_t key) @@ -179,7 +177,7 @@ void act_kb_mask(uint8_t key, uint8_t mode) void act_wheel(int8_t ticks) { - kmbox_inject_mouse(0, 0, g_buttons, ticks, false); + kmbox_inject_mouse(0, 0, g_buttons, ticks); } bool act_get_invert_x(void) { return s_invert_x; } diff --git a/src/actions.h b/src/actions.h index 6f61bc5..bf04bdf 100644 --- a/src/actions.h +++ b/src/actions.h @@ -15,7 +15,7 @@ extern uint16_t g_lock_mask; void act_init(void); int8_t act_button_set(uint8_t mask, uint8_t action); // action: 0=up, 1=down void act_click(uint8_t button_1based, uint8_t count, uint32_t delay_ms); -void act_move(int16_t dx, int16_t dy, bool smooth); +void act_move(int16_t dx, int16_t dy); int8_t act_kb_down(uint8_t key); void act_kb_up(uint8_t key); void act_kb_press(uint8_t key, uint32_t delay_ms); diff --git a/src/ferrum.c b/src/ferrum.c index a212bed..7e3c1aa 100644 --- a/src/ferrum.c +++ b/src/ferrum.c @@ -13,6 +13,7 @@ #include "ferrum.h" #include "actions.h" +#include "humanize.h" #include "kmbox.h" #include @@ -239,7 +240,7 @@ static void cmd_move(arg_t *args, uint8_t nargs) if (x < INT16_MIN) x = INT16_MIN; if (y > INT16_MAX) y = INT16_MAX; if (y < INT16_MIN) y = INT16_MIN; - act_move((int16_t)x, (int16_t)y, false); + act_move((int16_t)x, (int16_t)y); } // Generic button handler. mask is the bit in g_buttons. @@ -272,7 +273,7 @@ static void cmd_wheel(arg_t *args, uint8_t nargs) if (!parse_int(args[0].p, args[0].len, &n)) return; if (n > INT8_MAX) n = INT8_MAX; if (n < INT8_MIN) n = INT8_MIN; - kmbox_inject_mouse(0, 0, g_buttons, (int8_t)n, false); + kmbox_inject_mouse(0, 0, g_buttons, (int8_t)n); } // Generic lock handler. bit is the LOCK_BIT_* index. @@ -423,6 +424,16 @@ static void cmd_baud(arg_t *args, uint8_t nargs) kmbox_set_baud((uint32_t)n); } +static void cmd_human(arg_t *args, uint8_t nargs) +{ + if (nargs != 1) return; + int32_t n; + if (!parse_int(args[0].p, args[0].len, &n)) return; + if (n < 0) n = 0; + if (n > 3) n = 3; + humanize_set_level((uint8_t)n); +} + // ============================================================================= // Dispatch // ============================================================================= @@ -474,6 +485,7 @@ static void dispatch(const char *name, uint8_t name_len, arg_t *args, uint8_t na if (name_is(name, name_len, "keys")) { cmd_cb_toggle(&s_cb_keys, args, nargs); return; } if (name_is(name, name_len, "baud")) { cmd_baud(args, nargs); return; } + if (name_is(name, name_len, "human")) { cmd_human(args, nargs); return; } // Unknown — silent drop. } diff --git a/src/humanize.c b/src/humanize.c index 8541b78..0f32ee8 100644 --- a/src/humanize.c +++ b/src/humanize.c @@ -1,11 +1,152 @@ -// Humanize stub — DWT cycle counter init only. -// Tremor generation moved to EWMA noise model in smooth.c. - #include "humanize.h" +#include +#include + +/* ── tunables ───────────────────────────────────────────────────────── */ +#define HZ_DEFAULT_LEVEL 2 /* boot default: on, "normal" */ +#define HZ_MAX_PER_FRAME 127 /* human per-frame ceiling (counts) */ +#define HZ_IDLE_EPS 0.01f /* |owed| below this = settled */ +#define HZ_TIMING_JITTER 0.12f /* +/- fraction of base period to jitter */ +#define HZ_TIMING_FLOOR 0.80f /* min multiple of base period */ +#define HZ_TIMING_CEIL 1.20f /* max multiple of base period */ + +/* Per-level perpendicular-noise amplitude (fraction of speed → ~constant + * jitter angle). Level 0 = off. Injection is delivered in-frame (no cross-frame + * velocity smoothing): the humanization is a bounded per-frame perturbation + * (noise + dither + cap), so it never holds back or lags the host's motion. */ +static const float HZ_NOISE[4] = { 0.0f, 0.05f, 0.10f, 0.18f }; + +static struct { + uint8_t level; + float noise_amp; + float owed_x, owed_y; /* undelivered injected motion */ + float res_x, res_y; /* sub-pixel residual */ + float ewma; /* noise correlation alpha */ + float n_perp; /* correlated perpendicular noise state */ + uint32_t a, b, c, ctr; /* SFC32 */ + uint32_t timing_lfsr; + int idle; +} S; + +/* ── RNG (verbatim from smooth.c) ───────────────────────────────────── */ +static inline uint32_t sfc32(void) { + uint32_t t = S.a + S.b + S.ctr++; + S.a = S.b ^ (S.b >> 9); + S.b = S.c + (S.c << 3); + S.c = ((S.c << 21) | (S.c >> 11)) + t; + return t; +} +static inline float sfc32_uniform(void) { /* [-1, 1) */ + int32_t bal = (int32_t)(sfc32() >> 8) - 0x800000; + return (float)bal * (1.0f / 8388608.0f); +} + +/* ── seeding ────────────────────────────────────────────────────────── */ +#ifdef HUMANIZE_HOSTTEST +static uint32_t hw_entropy(void) { return 0x12345678u; } /* deterministic */ +#else +#include "imxrt.h" +static uint32_t hw_entropy(void) { + volatile uint32_t *dwt_ctrl = (volatile uint32_t *)0xE0001000; + volatile uint32_t *dwt_cyc = (volatile uint32_t *)0xE0001004; + *dwt_ctrl |= 1; + volatile uint32_t *uid0 = (volatile uint32_t *)0x401F4410; + return *dwt_cyc ^ *uid0; +} +#endif + +void humanize_set_level(uint8_t level) { + if (level > 3) level = 3; + S.level = level; + S.noise_amp = HZ_NOISE[level]; +} + +void humanize_init(uint32_t interval_us) { + memset(&S, 0, sizeof(S)); + uint32_t seed = hw_entropy(); + S.a = seed ^ 0xCAFEBABEu; S.b = seed ^ 0xDEADBEEFu; + S.c = seed ^ 0x8BADF00Du; S.ctr = 1; + if (!S.a) S.a = 0xCAFEBABEu; + /* Only S.a is guarded: the 16-iteration warm-up below guarantees nonzero + * liveness even if b/c happen to seed to zero. */ + for (int i = 0; i < 16; i++) sfc32(); + S.timing_lfsr = sfc32() | 1u; + S.ewma = 0.85f; + humanize_set_level(HZ_DEFAULT_LEVEL); + S.noise_amp *= 1.0f + 0.15f * sfc32_uniform(); + (void)interval_us; /* intentionally unused: drain rate is level-preset, not interval-scaled */ +} + +static int16_t drain_axis(float *owed, float *res, float emit_v, float noise) { + float want = emit_v + noise + *res; + /* cap to human ceiling */ + float capped = want; + if (capped > (float)HZ_MAX_PER_FRAME) capped = (float)HZ_MAX_PER_FRAME; + if (capped < -(float)HZ_MAX_PER_FRAME) capped = -(float)HZ_MAX_PER_FRAME; + float res_in = *res; /* residual before this frame's update */ + int16_t out = (int16_t)(capped >= 0 ? (capped + 0.5f) : (capped - 0.5f)); + *res = capped - (float)out; + /* Drain owed by exactly the integer output; sub-pixel residual is tracked + * separately in *res so it is not lost. Noise is zero-mean so we don't + * track it in owed — but when the cap fires we must not lose signal. + * Strategy: drain emit_v from owed unconditionally; if the cap cut into + * the *signal* portion (emit_v), add that cut back so owed carries it. */ + float signal_cut = 0.0f; + float uncapped_signal = emit_v + res_in; /* signal + pre-update dither, no noise */ + if (uncapped_signal > (float)HZ_MAX_PER_FRAME) + signal_cut = uncapped_signal - (float)HZ_MAX_PER_FRAME; + else if (uncapped_signal < -(float)HZ_MAX_PER_FRAME) + signal_cut = uncapped_signal - (-(float)HZ_MAX_PER_FRAME); + *owed -= emit_v - signal_cut; + return out; +} + +void humanize_filter(int16_t *dx, int16_t *dy) { + if (S.level == 0) return; /* off: passthrough */ + + S.owed_x += (float)*dx; + S.owed_y += (float)*dy; + + if (fabsf(S.owed_x) < HZ_IDLE_EPS && fabsf(S.owed_y) < HZ_IDLE_EPS && + fabsf(S.res_x) < 0.5f && fabsf(S.res_y) < 0.5f) { + if (S.idle < 1000) S.idle++; + *dx = 0; *dy = 0; + return; + } + S.idle = 0; + + /* Deliver all owed motion this frame (cap-with-carry below handles the + * rare >127/frame flick). No fractional drain → no latency / no smear. */ + float ex = S.owed_x; + float ey = S.owed_y; + + float speed = sqrtf(ex*ex + ey*ey); + S.n_perp = S.ewma * S.n_perp + (1.0f - S.ewma) * sfc32_uniform(); + float nmag = S.n_perp * S.noise_amp * speed; + float nx = 0.0f, ny = 0.0f; + if (speed > 1e-3f) { nx = -ey / speed * nmag; ny = ex / speed * nmag; } + + *dx = drain_axis(&S.owed_x, &S.res_x, ex, nx); + *dy = drain_axis(&S.owed_y, &S.res_y, ey, ny); +} + +void humanize_return(int16_t dx, int16_t dy) { + S.owed_x += (float)dx; + S.owed_y += (float)dy; +} + +bool humanize_pending(void) { + return fabsf(S.owed_x) >= HZ_IDLE_EPS || fabsf(S.owed_y) >= HZ_IDLE_EPS; +} -void humanize_init(void) -{ - // Enable DWT cycle counter (used for LFSR seeding) - volatile uint32_t *dwt_ctrl = (volatile uint32_t *)0xE0001000; - *dwt_ctrl |= 1; +uint32_t humanize_timing_next(uint32_t base_ldval) { + if (S.level == 0) return base_ldval; + S.timing_lfsr ^= S.timing_lfsr << 13; + S.timing_lfsr ^= S.timing_lfsr >> 17; + S.timing_lfsr ^= S.timing_lfsr << 5; + float u = (float)(S.timing_lfsr >> 8) * (1.0f / 16777216.0f) - 0.5f; + float r = (float)base_ldval * (1.0f + HZ_TIMING_JITTER * u); + float lo = (float)base_ldval * HZ_TIMING_FLOOR, hi = (float)base_ldval * HZ_TIMING_CEIL; + if (r < lo) r = lo; if (r > hi) r = hi; + return (uint32_t)r; } diff --git a/src/humanize.h b/src/humanize.h index cba79b2..451ede7 100644 --- a/src/humanize.h +++ b/src/humanize.h @@ -1,6 +1,15 @@ #pragma once #include +#include -// Humanize stub — enables DWT cycle counter for LFSR seeding. -// Tremor generation is now handled by EWMA noise in smooth.c. -void humanize_init(void); +/* Always-on humanization filter. Operates on the INJECTED mouse delta only; + * real-mouse passthrough is never routed through it. */ +void humanize_init(uint32_t interval_us); /* seed + level default */ +void humanize_filter(int16_t *dx, int16_t *dy); /* in-place, per frame */ +uint32_t humanize_timing_next(uint32_t base_ldval); +void humanize_set_level(uint8_t level); /* 0=off..3=strong */ +bool humanize_pending(void); /* true while owed motion remains to emit */ +/* Return injected motion that the report's delta field could not carry this + * frame (it was clamped), so the filter redelivers it as headroom opens. + * Real-mouse passthrough keeps priority; only the injected overflow comes back. */ +void humanize_return(int16_t dx, int16_t dy); diff --git a/src/hurra.c b/src/hurra.c index 457bad8..9f5f467 100644 --- a/src/hurra.c +++ b/src/hurra.c @@ -3,6 +3,7 @@ #include "TinyFrame.h" #include "hurra.h" #include "actions.h" +#include "humanize.h" #include "kmbox.h" #include "imxrt.h" #include @@ -30,6 +31,7 @@ enum { TYPE_INVERT_X = 0x17, TYPE_INVERT_Y = 0x18, TYPE_SWAP_XY = 0x19, + TYPE_HUMAN = 0x1A, TYPE_BTN_LEFT = 0x20, TYPE_BTN_RIGHT = 0x21, TYPE_BTN_MIDDLE = 0x22, @@ -197,7 +199,7 @@ static TF_Result l_mouse_move(TinyFrame *tf, TF_Msg *msg) if (msg->len != 4) { s_payload_invalid++; return TF_STAY; } int16_t dx = rd_i16le(&msg->data[0]); int16_t dy = rd_i16le(&msg->data[2]); - act_move(dx, dy, false); + act_move(dx, dy); return TF_STAY; } @@ -206,7 +208,7 @@ static TF_Result l_mouse_move_smooth(TinyFrame *tf, TF_Msg *msg) (void)tf; track_id(msg->frame_id); if (msg->len != 4) { s_payload_invalid++; return TF_STAY; } - act_move(rd_i16le(&msg->data[0]), rd_i16le(&msg->data[2]), true); + act_move(rd_i16le(&msg->data[0]), rd_i16le(&msg->data[2])); return TF_STAY; } @@ -215,7 +217,7 @@ static TF_Result l_mouse_silent(TinyFrame *tf, TF_Msg *msg) (void)tf; track_id(msg->frame_id); if (msg->len != 4) { s_payload_invalid++; return TF_STAY; } - act_move(rd_i16le(&msg->data[0]), rd_i16le(&msg->data[2]), false); + act_move(rd_i16le(&msg->data[0]), rd_i16le(&msg->data[2])); return TF_STAY; } @@ -231,7 +233,7 @@ static TF_Result l_mouse_mo(TinyFrame *tf, TF_Msg *msg) // pan/tilt (data[6], data[7]) accepted but dropped — no HID transport. act_button_set(buttons ^ g_buttons, 0); act_button_set(buttons, 1); - act_move(dx, dy, false); + act_move(dx, dy); if (wheel) act_wheel(wheel); return TF_STAY; } @@ -255,6 +257,17 @@ static TF_Result l_mouse_wheel(TinyFrame *tf, TF_Msg *msg) return TF_STAY; } +static TF_Result l_human(TinyFrame *tf, TF_Msg *msg) +{ + (void)tf; + track_id(msg->frame_id); + if (msg->len != 1) { s_payload_invalid++; return TF_STAY; } + uint8_t lvl = msg->data[0]; + if (lvl > 3) lvl = 3; + humanize_set_level(lvl); + return TF_STAY; +} + static TF_Result l_mouse_getpos(TinyFrame *tf, TF_Msg *msg) { (void)tf; @@ -614,6 +627,7 @@ void hurra_init(void) TF_AddTypeListener(&s_tf, TYPE_INVERT_X, l_invert_x); TF_AddTypeListener(&s_tf, TYPE_INVERT_Y, l_invert_y); TF_AddTypeListener(&s_tf, TYPE_SWAP_XY, l_swap_xy); + TF_AddTypeListener(&s_tf, TYPE_HUMAN, l_human); TF_AddTypeListener(&s_tf, TYPE_KB_DOWN, l_kb_down); TF_AddTypeListener(&s_tf, TYPE_KB_UP, l_kb_up); TF_AddTypeListener(&s_tf, TYPE_KB_PRESS, l_kb_press); diff --git a/src/kmbox.c b/src/kmbox.c index b272a3c..a44385e 100644 --- a/src/kmbox.c +++ b/src/kmbox.c @@ -3,7 +3,7 @@ // Moved off LPUART6 (pins 0/1) after suspected pad damage on D0/D1. #include "kmbox.h" -#include "smooth.h" +#include "humanize.h" #include "imxrt.h" #include "usb_device.h" #include "proto.h" @@ -171,7 +171,7 @@ static uint8_t cached_mouse_report_len; // actual report length from first real static bool merged_this_cycle; static void apply_mouse_result(int16_t dx, int16_t dy, uint8_t buttons, - int8_t wheel, bool use_smooth); + int8_t wheel); static void baud_change_apply(uint32_t baud); static void tx_enqueue(uint8_t b) @@ -381,7 +381,6 @@ void kmbox_init(void) // Hurra build never transmits (TF_WriteImpl no-ops on a NULL s_tx). proto_init(); proto_set_tx(uart_tx_frame); - smooth_init(1000); // default 1kHz, main.c re-inits with actual rate } static void parse_mouse_layout(const uint8_t *rd, uint16_t rdlen) @@ -591,6 +590,7 @@ void kmbox_cache_endpoints(const captured_descriptors_t *desc) cached_kb_ep = ep; } } + } bool kmbox_rx_pending(void) @@ -742,10 +742,34 @@ static void kmbox_merge_report_slow(uint8_t *report, uint8_t len, __attribute__((cold, noinline)) static void kmbox_merge_keyboard(uint8_t *report, uint8_t len); +// Output cadence tracking. last_merge_ms = when a real mouse report last rode +// through (injection rides those). last_synth_ms = last standalone synth frame. +// Used to keep exactly one mouse report per ~1 ms: injection rides merge +// reports while the mouse is active, and the synth path only fills in when the +// mouse has gone silent (so the two paths never both emit in the same frame). +static uint32_t last_merge_ms; +static uint32_t last_synth_ms; +#define SYNTH_SILENCE_MS 2 // mouse considered idle after this many ms of no report + +/* Pull this frame's injected delta from the pending accumulators and run it + * through the humanization filter. The filter delivers in-frame and owns + * conservation (sub-pixel residual + >127 cap-carry), so we just consume. */ +static void kmbox_take_injection(int16_t *out_dx, int16_t *out_dy) +{ + int16_t dx = inject.mouse_dx; + int16_t dy = inject.mouse_dy; + inject.mouse_dx = 0; + inject.mouse_dy = 0; + humanize_filter(&dx, &dy); + *out_dx = dx; + *out_dy = dy; +} + __attribute__((section(".fastrun"))) void kmbox_merge_report(uint8_t iface_protocol, uint8_t * restrict report, uint8_t len) { if (iface_protocol == 2) { + last_merge_ms = millis(); // a real mouse report is riding through now if (__builtin_expect(cached_mouse_report_len == 0, 0)) cached_mouse_report_len = len; @@ -757,66 +781,91 @@ void kmbox_merge_report(uint8_t iface_protocol, uint8_t * restrict report, uint8 report[doff] |= inject.mouse_buttons; proto_notify_buttons(report[doff]); + int16_t inj_dx, inj_dy; + kmbox_take_injection(&inj_dx, &inj_dy); + + // Each axis adds humanized injection onto the mouse's own delta + // and clamps to the report field as a hard safety bound only. + // The filter's per-frame cap (127) means the field clamp rarely + // fires; conservation is now owned by humanize_filter's internal + // owed accumulator. + int32_t done_w = 0; + int32_t done_dx = 0, done_dy = 0; if (mouse_layout.x_is16) { int32_t rx = (int16_t)(report[mouse_layout.x_byte] | ((uint16_t)report[mouse_layout.x_byte + 1] << 8)); - int32_t mx = rx + inject.mouse_dx; + int32_t mx = rx + inj_dx; if (mx > mouse_layout.x_max) mx = mouse_layout.x_max; if (mx < -mouse_layout.x_max) mx = -mouse_layout.x_max; report[mouse_layout.x_byte] = (uint8_t)(mx & 0xFF); report[mouse_layout.x_byte + 1] = (uint8_t)(mx >> 8); + done_dx = mx - rx; } else { int32_t rx = (int8_t)report[mouse_layout.x_byte]; - int32_t mx = rx + inject.mouse_dx; + int32_t mx = rx + inj_dx; if (mx > mouse_layout.x_max) mx = mouse_layout.x_max; if (mx < -mouse_layout.x_max) mx = -mouse_layout.x_max; report[mouse_layout.x_byte] = (uint8_t)(int8_t)mx; + done_dx = mx - rx; } if (mouse_layout.y_is16) { int32_t ry = (int16_t)(report[mouse_layout.y_byte] | ((uint16_t)report[mouse_layout.y_byte + 1] << 8)); - int32_t my = ry + inject.mouse_dy; + int32_t my = ry + inj_dy; if (my > mouse_layout.y_max) my = mouse_layout.y_max; if (my < -mouse_layout.y_max) my = -mouse_layout.y_max; report[mouse_layout.y_byte] = (uint8_t)(my & 0xFF); report[mouse_layout.y_byte + 1] = (uint8_t)(my >> 8); + done_dy = my - ry; } else { int32_t ry = (int8_t)report[mouse_layout.y_byte]; - int32_t my = ry + inject.mouse_dy; + int32_t my = ry + inj_dy; if (my > mouse_layout.y_max) my = mouse_layout.y_max; if (my < -mouse_layout.y_max) my = -mouse_layout.y_max; report[mouse_layout.y_byte] = (uint8_t)(int8_t)my; + done_dy = my - ry; } if (mouse_layout.w_byte != 0xFF && inject.mouse_wheel != 0) { if (mouse_layout.w_is16) { int32_t rw = (int16_t)(report[mouse_layout.w_byte] | ((uint16_t)report[mouse_layout.w_byte + 1] << 8)); - int32_t mw = rw + inject.mouse_wheel; + int32_t want = rw + inject.mouse_wheel; + int32_t mw = want; if (mw > mouse_layout.w_max) mw = mouse_layout.w_max; if (mw < -mouse_layout.w_max) mw = -mouse_layout.w_max; report[mouse_layout.w_byte] = (uint8_t)(mw & 0xFF); report[mouse_layout.w_byte + 1] = (uint8_t)(mw >> 8); + inject.mouse_wheel = (int8_t)(want - mw); + done_w = mw - rw; } else { int32_t rw = (int8_t)report[mouse_layout.w_byte]; - int32_t mw = rw + inject.mouse_wheel; + int32_t want = rw + inject.mouse_wheel; + int32_t mw = want; if (mw > mouse_layout.w_max) mw = mouse_layout.w_max; if (mw < -mouse_layout.w_max) mw = -mouse_layout.w_max; report[mouse_layout.w_byte] = (uint8_t)(int8_t)mw; + inject.mouse_wheel = (int8_t)(want - mw); + done_w = mw - rw; } - // Wheel zero deferred until after proto_notify_axes so the - // callback sees the actual scroll value, not 0. } - proto_notify_axes(inject.mouse_dx, inject.mouse_dy, - inject.mouse_wheel); - if (mouse_layout.w_byte != 0xFF) - inject.mouse_wheel = 0; - inject.mouse_dx = 0; - inject.mouse_dy = 0; + // For a wheel on a separate report ID (no field here) the scroll + // is flushed later by kmbox_send_wheel_report, so report the full + // pending value now to preserve its telemetry cadence. + int8_t w_tlm = (mouse_layout.w_byte != 0xFF) + ? (int8_t)done_w : inject.mouse_wheel; + proto_notify_axes((int16_t)done_dx, (int16_t)done_dy, w_tlm); + // If the field clamp rejected part of the injected delta (e.g. + // 8-bit field while the real mouse is also moving), return the + // unfit injected portion so the filter redelivers it next frame. + // Real-mouse motion keeps priority; nothing injected is dropped. + humanize_return((int16_t)(inj_dx - done_dx), + (int16_t)(inj_dy - done_dy)); inject.mouse_dirty = (inject.mouse_buttons != 0 || - inject.mouse_wheel != 0); + inject.mouse_wheel != 0 || + humanize_pending()); } else { kmbox_merge_report_slow(report, len, rid, doff); } @@ -832,7 +881,13 @@ __attribute__((cold, noinline)) static void kmbox_merge_report_slow(uint8_t *report, uint8_t len, uint8_t rid, uint8_t doff) { - bool wheel_consumed = false; + // Pull humanized injection once for this frame; conservation is owned by + // the filter's internal owed accumulator. Only the axes whose report ID + // actually arrived are applied — if X and Y live on different report IDs + // the caller re-enters with the other ID and the filter will emit again. + int16_t inj_dx, inj_dy; + kmbox_take_injection(&inj_dx, &inj_dy); + int32_t done_dx = 0, done_dy = 0, done_w = 0; if (rid == mouse_layout.report_id) { report[doff] |= inject.mouse_buttons; @@ -840,20 +895,22 @@ static void kmbox_merge_report_slow(uint8_t *report, uint8_t len, int32_t rx = read_report_field(report, len, mouse_layout.x_bit, mouse_layout.x_size, doff); - int32_t mx = rx + inject.mouse_dx; + int32_t mx = rx + inj_dx; if (mx > mouse_layout.x_max) mx = mouse_layout.x_max; if (mx < -mouse_layout.x_max) mx = -mouse_layout.x_max; write_report_field(report, len, mouse_layout.x_bit, mouse_layout.x_size, doff, mx); + done_dx = mx - rx; if (rid == mouse_layout.y_report_id) { int32_t ry = read_report_field(report, len, mouse_layout.y_bit, mouse_layout.y_size, doff); - int32_t my = ry + inject.mouse_dy; + int32_t my = ry + inj_dy; if (my > mouse_layout.y_max) my = mouse_layout.y_max; if (my < -mouse_layout.y_max) my = -mouse_layout.y_max; write_report_field(report, len, mouse_layout.y_bit, mouse_layout.y_size, doff, my); + done_dy = my - ry; } } @@ -861,22 +918,24 @@ static void kmbox_merge_report_slow(uint8_t *report, uint8_t len, rid == mouse_layout.wheel_report_id) { int32_t rw = read_report_field(report, len, mouse_layout.wheel_bit, mouse_layout.wheel_size, doff); - int32_t mw = rw + inject.mouse_wheel; + int32_t ww = rw + inject.mouse_wheel; + int32_t mw = ww; if (mw > mouse_layout.w_max) mw = mouse_layout.w_max; if (mw < -mouse_layout.w_max) mw = -mouse_layout.w_max; write_report_field(report, len, mouse_layout.wheel_bit, mouse_layout.wheel_size, doff, mw); - wheel_consumed = true; + inject.mouse_wheel = (int8_t)(ww - mw); + done_w = mw - rw; } - proto_notify_axes(inject.mouse_dx, inject.mouse_dy, - inject.mouse_wheel); - inject.mouse_dx = 0; - inject.mouse_dy = 0; - if (wheel_consumed) - inject.mouse_wheel = 0; + proto_notify_axes((int16_t)done_dx, (int16_t)done_dy, (int8_t)done_w); + // Return any injected motion not applied this frame — either field-clamped, + // or (on split X/Y report-ID layouts) belonging to an axis whose report ID + // didn't arrive this call. The filter redelivers it; nothing is dropped. + humanize_return((int16_t)(inj_dx - done_dx), (int16_t)(inj_dy - done_dy)); inject.mouse_dirty = (inject.mouse_buttons != 0 || - inject.mouse_wheel != 0); + inject.mouse_wheel != 0 || + humanize_pending()); } __attribute__((cold, noinline)) @@ -922,14 +981,24 @@ void kmbox_send_pending(void) } if (merged_this_cycle) return; - if (inject.mouse_dirty && cached_mouse_ep && mouse_layout.valid) { + // Only synthesize a standalone mouse report when the physical mouse has + // gone silent — otherwise injection rides the next real report (merge), + // so the two paths never both emit in the same frame (which would flood / + // overwrite at the 1 kHz endpoint). Capped to one synth per ms. + uint32_t ms = millis(); + bool mouse_silent = (uint32_t)(ms - last_merge_ms) >= SYNTH_SILENCE_MS; + if (inject.mouse_dirty && mouse_silent && ms != last_synth_ms && + cached_mouse_ep && mouse_layout.valid) { + last_synth_ms = ms; uint8_t synth[16]; memset(synth, 0, sizeof(synth)); uint8_t doff = mouse_layout.data_off; if (doff) synth[0] = mouse_layout.report_id; synth[doff] = inject.mouse_buttons; - int32_t dx = inject.mouse_dx; - int32_t dy = inject.mouse_dy; + int16_t inj_dx, inj_dy; + kmbox_take_injection(&inj_dx, &inj_dy); + int32_t dx = inj_dx; + int32_t dy = inj_dy; if (dx > mouse_layout.x_max) dx = mouse_layout.x_max; if (dx < -mouse_layout.x_max) dx = -mouse_layout.x_max; if (dy > mouse_layout.y_max) dy = mouse_layout.y_max; @@ -951,10 +1020,9 @@ void kmbox_send_pending(void) uint8_t rlen = cached_mouse_report_len; if (rlen == 0) rlen = (cached_mouse_maxpkt < 16) ? (uint8_t)cached_mouse_maxpkt : 16; usb_device_send_report(cached_mouse_ep, synth, rlen); - inject.mouse_dx = 0; - inject.mouse_dy = 0; inject.mouse_wheel = 0; - inject.mouse_dirty = (inject.mouse_buttons != 0); + inject.mouse_dirty = (inject.mouse_buttons != 0 || + humanize_pending()); } if (__builtin_expect(inject.kb_dirty && cached_kb_ep, 0)) { kmbox_send_keyboard_report(); @@ -993,13 +1061,6 @@ static void kmbox_send_keyboard_report(void) memcmp(inject.kb_keys, zeros, 6) != 0); } -void kmbox_inject_smooth(int16_t dx, int16_t dy) -{ - inject.mouse_dx += dx; - inject.mouse_dy += dy; - inject.mouse_dirty = true; -} - static void baud_change_apply(uint32_t baud) { uint32_t baud_reg = compute_baud_reg(baud); @@ -1047,26 +1108,19 @@ uint16_t kmbox_tx_room(void) __attribute__((section(".fastrun"))) static void apply_mouse_result(int16_t dx, int16_t dy, uint8_t buttons, - int8_t wheel, bool use_smooth) + int8_t wheel) { inject.mouse_buttons = buttons; inject.mouse_wheel += wheel; - - if (use_smooth && (dx != 0 || dy != 0)) { - smooth_inject(dx, dy); - if (buttons != 0 || wheel != 0) - inject.mouse_dirty = true; - } else { - inject.mouse_dx += dx; - inject.mouse_dy += dy; - inject.mouse_dirty = true; - } + inject.mouse_dx += dx; + inject.mouse_dy += dy; + inject.mouse_dirty = true; } void kmbox_inject_mouse(int16_t dx, int16_t dy, uint8_t buttons, - int8_t wheel, bool use_smooth) + int8_t wheel) { - apply_mouse_result(dx, dy, buttons, wheel, use_smooth); + apply_mouse_result(dx, dy, buttons, wheel); } void kmbox_inject_keyboard(uint8_t modifier, const uint8_t keys[6]) diff --git a/src/kmbox.h b/src/kmbox.h index 74d8246..53e7f1e 100644 --- a/src/kmbox.h +++ b/src/kmbox.h @@ -17,10 +17,8 @@ void kmbox_cache_endpoints(const captured_descriptors_t *desc); void kmbox_send_pending(void); -void kmbox_inject_smooth(int16_t dx, int16_t dy); - void kmbox_inject_mouse(int16_t dx, int16_t dy, uint8_t buttons, - int8_t wheel, bool use_smooth); + int8_t wheel); void kmbox_inject_keyboard(uint8_t modifier, const uint8_t keys[6]); void kmbox_schedule_click_release(uint8_t button_mask, uint32_t delay_ms); diff --git a/src/main.c b/src/main.c index 428035c..cb78047 100644 --- a/src/main.c +++ b/src/main.c @@ -7,7 +7,6 @@ #include "usb_device.h" #include "desc_capture.h" #include "kmbox.h" -#include "smooth.h" #include "humanize.h" #include "gpt_profile.h" #include "led.h" @@ -78,7 +77,7 @@ int main(void) kmbox_init(); led_init(); - // PIT0: smooth injection timer — clock/ISR now, rate set after enumeration + // PIT0: humanization timer — clock/ISR now, rate set after enumeration CCM_CCGR1 |= CCM_CCGR1_PIT(CCM_CCGR_ON); PIT_MCR = 0; // enable module, timers run in debug PIT_TCTRL0 = 0; // stopped until we know the mouse poll interval @@ -180,7 +179,7 @@ int main(void) } break; // use first mouse interface } - // Clamp to [125µs, 10ms] — sane range for smooth injection + // Clamp to [125µs, 10ms] — sane range for humanized injection if (interval_us < 125) interval_us = 125; if (interval_us > 10000) interval_us = 10000; uint32_t ipg_mhz = (F_CPU / 4u) / 1000000u; // IPG = ARM / 4 @@ -189,7 +188,7 @@ int main(void) pit_next_ldval = ldval; PIT_LDVAL0 = ldval; PIT_TCTRL0 = PIT_TCTRL_TIE | PIT_TCTRL_TEN; - smooth_init(interval_us); + humanize_init(interval_us); } kmbox_cache_endpoints(&desc); if (!usb_device_init(&desc)) { @@ -225,18 +224,11 @@ int main(void) uint32_t now = millis(); bool did_work = false; - // --- Latency-critical: smooth injection first --- + // --- Latency-critical: humanized PIT tick --- if (pit_tick_pending) { pit_tick_pending = false; did_work = true; - bool skip = false; - uint32_t next_ldval = smooth_timing_next(pit_base_ldval, &skip); - pit_next_ldval = next_ldval; - if (!skip) { - int16_t sx, sy; - smooth_process_frame(&sx, &sy); - if (sx || sy) kmbox_inject_smooth(sx, sy); - } + pit_next_ldval = humanize_timing_next(pit_base_ldval); } // --- USB device EP completion (unblock EPs for next send) --- diff --git a/src/smooth.c b/src/smooth.c deleted file mode 100644 index a20232b..0000000 --- a/src/smooth.c +++ /dev/null @@ -1,647 +0,0 @@ -// smooth.c — movement injection queue - -#include "smooth.h" -#include "smooth_config.h" -#include "imxrt.h" -#include -#include - -#define SMOOTH_ALL_SLOTS_MASK \ - ((SMOOTH_QUEUE_SIZE == 32) ? 0xFFFFFFFFu : ((1u << SMOOTH_QUEUE_SIZE) - 1u)) - -static inline int32_t int_to_fp(int16_t v) -{ - return (int32_t)v << SMOOTH_FP_SHIFT; -} - -static inline int16_t fp_to_int(int32_t fp) -{ - return (int16_t)((fp + SMOOTH_FP_HALF) >> SMOOTH_FP_SHIFT); -} - -static inline int32_t fp_mul(int32_t a, int32_t b) -{ - return (int32_t)(((int64_t)a * b) >> SMOOTH_FP_SHIFT); -} - -__attribute__((const)) -static inline int32_t fp_div(int32_t a, int32_t b) -{ - if (b == 0) return 0; - return (int32_t)(((int64_t)a << SMOOTH_FP_SHIFT) / b); -} - -static inline float fast_invsqrt(float x) -{ - union { float f; uint32_t u; } v = { .f = x }; - v.u = 0x5F3759DFu - (v.u >> 1); - float est = v.f; - est *= (1.5f - 0.5f * x * est * est); - return est; -} - -static inline int32_t ease_asymmetric(int32_t t, float attack_frac, - float inv_attack_frac, float inv_tail) -{ - float tf = (float)t * (1.0f / (float)SMOOTH_FP_ONE); - - float result; - if (tf < attack_frac) { - float n = tf * inv_attack_frac; - result = n * n * SMOOTH_EASE_ATTACK_DIST; - } else { - float n = (tf - attack_frac) * inv_tail; - float omt = 1.0f - n; - result = SMOOTH_EASE_ATTACK_DIST - + (1.0f - SMOOTH_EASE_ATTACK_DIST) * (1.0f - omt * omt * omt); - } - - return (int32_t)(result * (float)SMOOTH_FP_ONE); -} - -typedef struct { - int32_t x_remaining_fp; - int32_t y_remaining_fp; - int32_t inv_total_fp; // precomputed FP_ONE / total_frames - int32_t eased_prev; // cached eased(progress) from last frame - float speed_gain; // Fitts-calibrated speed noise multiplier - uint8_t frames_left; - uint8_t total_frames; -} queue_entry_t; - -static struct { - int32_t x_accum_fp; - int32_t y_accum_fp; - queue_entry_t queue[SMOOTH_QUEUE_SIZE]; - uint32_t free_mask; // bitmask: bit set = slot available - uint8_t count; - int16_t max_per_frame; - float dt; // seconds per tick - uint32_t interval_us; // microseconds per tick - float inv_interval_f; // 1.0f / interval_us, precomputed - int32_t inv_frames_lut[SMOOTH_MAX_FRAMES + 1]; // SMOOTH_FP_ONE / n - bool humanize; // humanization toggle (runtime) - - uint32_t rng_a, rng_b, rng_c, rng_counter; // SFC32 frame noise - - float ewma_alpha; - float ewma_beta; - float speed_noise; - float perp_noise; - - float arc_bias; - float overshoot_bias; - float fitts_a; - float fitts_b; - - // Easing curve — precomputed; only changes when humanize toggles - float attack_frac; - float inv_attack_frac; - float inv_tail; - - float last_noise_vx; - float last_noise_vy; - - float tremor_x; - float tremor_y; - uint32_t idle_frames; - - uint8_t consec_skips; - float rate_bias; - uint32_t trng_buf[32]; // cached TRNG entropy - uint8_t trng_idx; - uint32_t timing_lfsr; // xorshift32 fallback -} state; - -static inline uint32_t sfc32_draw(uint32_t *a, uint32_t *b, uint32_t *c, - uint32_t *ctr) -{ - uint32_t t = *a + *b + (*ctr)++; - *a = *b ^ (*b >> 9); - *b = *c + (*c << 3); - *c = ((*c << 21) | (*c >> 11)) + t; - return t; -} - -static inline float sfc32_uniform(uint32_t *a, uint32_t *b, uint32_t *c, - uint32_t *ctr) -{ - uint32_t r = sfc32_draw(a, b, c, ctr); - int32_t balanced = (int32_t)(r >> 8) - 0x800000; - return (float)balanced * (1.0f / 8388608.0f); -} - -static inline uint32_t sfc32_next(void) -{ - return sfc32_draw(&state.rng_a, &state.rng_b, &state.rng_c, - &state.rng_counter); -} - -static inline float smooth_rand_uniform(void) -{ - return sfc32_uniform(&state.rng_a, &state.rng_b, &state.rng_c, - &state.rng_counter); -} - -static void recompute_easing(void) -{ - float overshoot = state.humanize ? state.overshoot_bias : 0.0f; - float af = SMOOTH_EASE_ATTACK_FRAC + overshoot; - if (af < 0.05f) af = 0.05f; - if (af > 0.95f) af = 0.95f; - state.attack_frac = af; - state.inv_attack_frac = 1.0f / af; - state.inv_tail = 1.0f / (1.0f - af); -} - -static uint8_t compute_spread_frames(int32_t abs_x_fp, int32_t abs_y_fp) -{ - int32_t max_comp = abs_x_fp > abs_y_fp ? abs_x_fp : abs_y_fp; - int32_t px = max_comp >> SMOOTH_FP_SHIFT; - - uint32_t spread_us; - if (px < SMOOTH_SPREAD_SMALL_PX) spread_us = SMOOTH_SPREAD_SMALL_US; - else if (px < SMOOTH_SPREAD_MEDIUM_PX) spread_us = SMOOTH_SPREAD_MEDIUM_US; - else if (px < SMOOTH_SPREAD_LARGE_PX) spread_us = SMOOTH_SPREAD_LARGE_US; - else spread_us = SMOOTH_SPREAD_XLARGE_US; - - if (state.humanize) { - float jitter = smooth_rand_uniform() * SMOOTH_SPREAD_JITTER; - spread_us = (uint32_t)((float)spread_us * (1.0f + jitter)); - } - - uint32_t frames = (uint32_t)( - (float)(spread_us + (state.interval_us >> 1)) * state.inv_interval_f); - if (frames < SMOOTH_MIN_FRAMES) frames = SMOOTH_MIN_FRAMES; - if (frames > SMOOTH_MAX_FRAMES) frames = SMOOTH_MAX_FRAMES; - return (uint8_t)frames; -} - -static inline float fast_log2f(float x) -{ - union { float f; uint32_t u; } v = { .f = x }; - float exp_part = (float)((int32_t)(v.u >> 23) - 127); - v.u = (v.u & 0x007FFFFFu) | 0x3F800000u; // mantissa in [1,2) - float m = v.f; - return exp_part + (-0.34484843f * m + 2.02466578f) * m - 0.67487759f - 1.0f; -} - -static inline float fast_exp2f(float x) -{ - if (x > 20.0f) x = 20.0f; - if (x < -20.0f) return 0.0f; - float fi = (x >= 0) ? (float)(int)(x) : (float)(int)(x) - 1.0f; - float frac = x - fi; - float pow_frac = 1.0f + frac * (0.6931472f + frac * (0.2402265f + frac * 0.0558f)); - union { float f; uint32_t u; } v; - v.u = (uint32_t)((int)(fi) + 127) << 23; - return v.f * pow_frac; -} - -static float compute_fitts_speed_gain(int32_t abs_x_fp, int32_t abs_y_fp) -{ - float dx = (float)(abs_x_fp >> SMOOTH_FP_SHIFT); - float dy = (float)(abs_y_fp >> SMOOTH_FP_SHIFT); - float dist2 = dx * dx + dy * dy; - float dist = (dist2 > 1.0f) ? dist2 * fast_invsqrt(dist2) : 1.0f; - - float mt = state.fitts_a + state.fitts_b * fast_log2f(dist + 1.0f); - float gain = SMOOTH_FITTS_GAIN_NUM / mt; - - float k = SMOOTH_FITTS_GAIN_SOFT_K; - float x_lo = (gain - SMOOTH_FITTS_GAIN_MIN) / k; - if (x_lo < 10.0f) { - float ex = fast_exp2f(x_lo * 1.4426950f); - gain = SMOOTH_FITTS_GAIN_MIN + k * 0.6931472f * fast_log2f(1.0f + ex); - } - - float x_hi = (SMOOTH_FITTS_GAIN_MAX - gain) / k; - if (x_hi < 10.0f) { - float ex = fast_exp2f(x_hi * 1.4426950f); - gain = SMOOTH_FITTS_GAIN_MAX - k * 0.6931472f * fast_log2f(1.0f + ex); - } - - return gain; -} - -void smooth_init(uint32_t interval_us) -{ - memset(&state, 0, sizeof(state)); - state.free_mask = SMOOTH_ALL_SLOTS_MASK; - state.max_per_frame = 127; - state.humanize = true; - state.interval_us = interval_us > 0 ? interval_us : 1000; - state.dt = (float)state.interval_us / 1000000.0f; - state.inv_interval_f = 1.0f / (float)state.interval_us; - - state.inv_frames_lut[0] = 0; // unused; frames >= SMOOTH_MIN_FRAMES - for (uint32_t n = 1; n <= SMOOTH_MAX_FRAMES; n++) - state.inv_frames_lut[n] = SMOOTH_FP_ONE / (int32_t)n; - - volatile uint32_t *dwt_ctrl = (volatile uint32_t *)0xE0001000; - volatile uint32_t *dwt_cyccnt = (volatile uint32_t *)0xE0001004; - *dwt_ctrl |= 1; - - uint32_t seed1 = *dwt_cyccnt; - state.rng_a = seed1 ^ 0xCAFEBABE; - state.rng_b = seed1 ^ 0xDEADBEEF; - state.rng_c = seed1 ^ 0x8BADF00D; - state.rng_counter = 1; - if (state.rng_a == 0) state.rng_a = 0xCAFEBABE; - - for (int i = 0; i < SMOOTH_RNG_WARMUP; i++) sfc32_next(); - - // Personality seed: OCOTP hardware UID + DWT - volatile uint32_t *ocotp_cfg0 = (volatile uint32_t *)0x401F4410; - volatile uint32_t *ocotp_cfg1 = (volatile uint32_t *)0x401F4420; - uint32_t hw_uid0 = *ocotp_cfg0; - uint32_t hw_uid1 = *ocotp_cfg1; - uint32_t seed2 = *dwt_cyccnt; - uint32_t p_a = seed2 ^ hw_uid0 ^ 0x12345678; - uint32_t p_b = seed2 ^ hw_uid1 ^ 0x9ABCDEF0; - uint32_t p_c = (hw_uid0 ^ hw_uid1) ^ 0xFEDCBA98; - uint32_t p_ctr = 1; - if (p_a == 0) p_a = 0x12345678; - - for (int i = 0; i < SMOOTH_RNG_WARMUP; i++) - sfc32_draw(&p_a, &p_b, &p_c, &p_ctr); - - state.arc_bias = sfc32_uniform(&p_a, &p_b, &p_c, &p_ctr) - * SMOOTH_ARC_BIAS_RANGE; - state.overshoot_bias = sfc32_uniform(&p_a, &p_b, &p_c, &p_ctr) - * SMOOTH_OVERSHOOT_RANGE; - state.fitts_a = SMOOTH_FITTS_A_MIN - + fabsf(sfc32_uniform(&p_a, &p_b, &p_c, &p_ctr)) * SMOOTH_FITTS_A_SPAN; - state.fitts_b = SMOOTH_FITTS_B_MIN - + fabsf(sfc32_uniform(&p_a, &p_b, &p_c, &p_ctr)) * SMOOTH_FITTS_B_SPAN; - - recompute_easing(); - - // Scale EWMA alpha to tick rate - { - float exponent = (float)state.interval_us / 1000.0f; - state.ewma_alpha = fast_exp2f(exponent * fast_log2f(SMOOTH_EWMA_ALPHA)); - if (state.ewma_alpha < 0.5f) state.ewma_alpha = 0.5f; - if (state.ewma_alpha > 0.999f) state.ewma_alpha = 0.999f; - state.ewma_beta = 1.0f - state.ewma_alpha; - } - - for (int i = 0; i < SMOOTH_EWMA_PRIME_ROUNDS; i++) { - state.speed_noise = state.ewma_alpha * state.speed_noise - + state.ewma_beta * smooth_rand_uniform(); - state.perp_noise = state.ewma_alpha * state.perp_noise - + state.ewma_beta * smooth_rand_uniform(); - } - - state.rate_bias = sfc32_uniform(&p_a, &p_b, &p_c, &p_ctr) - * SMOOTH_TIMING_RATE_OFFSET; - - state.timing_lfsr = sfc32_draw(&p_a, &p_b, &p_c, &p_ctr); - if (state.timing_lfsr == 0) state.timing_lfsr = 0xDEADBEEF; - - // Hardware TRNG init - CCM_CCGR6 |= CCM_CCGR6_TRNG(CCM_CCGR_ON); - TRNG_MCTL = TRNG_MCTL_RST_DEF | TRNG_MCTL_PRGM; - TRNG_MCTL = TRNG_MCTL_SAMP_MODE(2) | TRNG_MCTL_TRNG_ACC; - for (int batch = 0; batch < 2; batch++) { - while (!(TRNG_MCTL & TRNG_MCTL_ENT_VAL)) {} - volatile uint32_t *ent = &TRNG_ENT0; - for (int i = 0; i < 16; i++) - state.trng_buf[batch * 16 + i] = ent[i]; - } - state.trng_idx = 0; -} - -__attribute__((section(".fastrun"))) -void smooth_inject(int16_t x, int16_t y) -{ - if (x == 0 && y == 0) return; - - state.idle_frames = 0; - - if (state.free_mask == 0) { - state.x_accum_fp += int_to_fp(x); - state.y_accum_fp += int_to_fp(y); - return; - } - - uint8_t slot = (uint8_t)__builtin_ctz(state.free_mask); - state.free_mask &= state.free_mask - 1; // clear lowest set bit - - int32_t xfp = int_to_fp(x); - int32_t yfp = int_to_fp(y); - int32_t ax = xfp >= 0 ? xfp : -xfp; - int32_t ay = yfp >= 0 ? yfp : -yfp; - - uint8_t frames = compute_spread_frames(ax, ay); - - state.queue[slot].x_remaining_fp = xfp; - state.queue[slot].y_remaining_fp = yfp; - state.queue[slot].inv_total_fp = state.inv_frames_lut[frames]; - state.queue[slot].eased_prev = 0; // progress starts at 0 - state.queue[slot].speed_gain = state.humanize - ? compute_fitts_speed_gain(ax, ay) - : SMOOTH_SPEED_GAIN_DEFAULT; - state.queue[slot].frames_left = frames; - state.queue[slot].total_frames = frames; - state.count++; -} - -__attribute__((section(".fastrun"))) -void smooth_process_frame(int16_t *out_x, int16_t *out_y) -{ - int32_t frame_x_fp = 0; - int32_t frame_y_fp = 0; - bool has_movement = false; - - float frame_speed_gain = 0.0f; - float total_weight = 0.0f; - - // Easing constants are precomputed in smooth_init / smooth_set_humanize — - // overshoot_bias is session-constant. - float attack_frac = state.attack_frac; - float inv_attack_frac = state.inv_attack_frac; - float inv_tail = state.inv_tail; - - uint32_t active = ~state.free_mask & SMOOTH_ALL_SLOTS_MASK; - while (active) { - uint8_t i = (uint8_t)__builtin_ctz(active); - active &= active - 1; // clear lowest set bit - queue_entry_t *e = &state.queue[i]; - - if (e->frames_left == 0) { - state.free_mask |= (1u << i); - state.count--; - continue; - } - - int32_t eased_now = e->eased_prev; - int32_t next_progress = SMOOTH_FP_ONE - - fp_mul(int_to_fp((int16_t)(e->frames_left - 1)), e->inv_total_fp); - int32_t eased_next = ease_asymmetric(next_progress, attack_frac, - inv_attack_frac, inv_tail); - int32_t step_frac = eased_next - eased_now; - - int32_t remaining_progress = SMOOTH_FP_ONE - eased_now; - int32_t frame_dx, frame_dy; - if (remaining_progress > 0) { - int32_t ratio = fp_div(step_frac, remaining_progress); - frame_dx = fp_mul(e->x_remaining_fp, ratio); - frame_dy = fp_mul(e->y_remaining_fp, ratio); - } else { - frame_dx = e->x_remaining_fp; - frame_dy = e->y_remaining_fp; - } - - e->x_remaining_fp -= frame_dx; - e->y_remaining_fp -= frame_dy; - e->eased_prev = eased_next; - e->frames_left--; - - if (e->frames_left == 0) { - frame_dx += e->x_remaining_fp; - frame_dy += e->y_remaining_fp; - e->x_remaining_fp = 0; - e->y_remaining_fp = 0; - state.free_mask |= (1u << i); - state.count--; - } - - float entry_mag = fabsf((float)frame_dx) + fabsf((float)frame_dy); - frame_speed_gain += e->speed_gain * entry_mag; - total_weight += entry_mag; - - frame_x_fp += frame_dx; - frame_y_fp += frame_dy; - has_movement = true; - } - - // Idle fast-path: must still decay last_noise_vx/vy each frame — - // it's a geometric series the next active frame inherits. - if (!has_movement - && state.free_mask == SMOOTH_ALL_SLOTS_MASK - && state.idle_frames >= SMOOTH_TREMOR_IDLE_TIMEOUT - && state.x_accum_fp > -SMOOTH_FP_HALF - && state.x_accum_fp < SMOOTH_FP_HALF - && state.y_accum_fp > -SMOOTH_FP_HALF - && state.y_accum_fp < SMOOTH_FP_HALF) { - if (state.idle_frames < UINT32_MAX) - state.idle_frames++; - state.last_noise_vx *= SMOOTH_VELOCITY_DECAY; - state.last_noise_vy *= SMOOTH_VELOCITY_DECAY; - *out_x = 0; - *out_y = 0; - return; - } - - if (total_weight > 0.0f) - frame_speed_gain /= total_weight; - else - frame_speed_gain = SMOOTH_SPEED_GAIN_DEFAULT; - - if (has_movement && state.humanize) { - int32_t base_x = frame_x_fp; - int32_t base_y = frame_y_fp; - - state.speed_noise = state.ewma_alpha * state.speed_noise - + state.ewma_beta * smooth_rand_uniform(); - state.perp_noise = state.ewma_alpha * state.perp_noise - + state.ewma_beta * smooth_rand_uniform(); - - state.arc_bias += SMOOTH_ARC_DRIFT_RATE - * (smooth_rand_uniform() * SMOOTH_ARC_DRIFT_INPUT - - state.arc_bias * SMOOTH_ARC_DRIFT_DECAY); - - float noise_x = 0.0f; - float noise_y = 0.0f; - - float speed_mod = state.speed_noise * SMOOTH_SPEED_NOISE_SCALE - * frame_speed_gain; - noise_x += (float)base_x * speed_mod; - noise_y += (float)base_y * speed_mod; - - float fx = (float)base_x; - float fy = (float)base_y; - float mag2 = fx * fx + fy * fy; - if (mag2 > 1.0f) { - float inv_mag = fast_invsqrt(mag2); - float px = -fy * inv_mag; - float py = fx * inv_mag; - - float speed_px = mag2 * inv_mag * (1.0f / (float)SMOOTH_FP_ONE); - float arc_scale = 1.0f + SMOOTH_LOWSPEED_BOOST / (speed_px + 1.0f); - - float perp_px = (state.arc_bias - + state.perp_noise * SMOOTH_PERP_AMPLITUDE) * arc_scale; - noise_x += px * perp_px * (float)SMOOTH_FP_ONE; - noise_y += py * perp_px * (float)SMOOTH_FP_ONE; - } - - float smoothed_nx = SMOOTH_VELOCITY_ALPHA * noise_x - + (1.0f - SMOOTH_VELOCITY_ALPHA) * state.last_noise_vx; - float smoothed_ny = SMOOTH_VELOCITY_ALPHA * noise_y - + (1.0f - SMOOTH_VELOCITY_ALPHA) * state.last_noise_vy; - - float base_mag = fabsf((float)base_x) + fabsf((float)base_y); - if (base_mag > 0.0f) { - float noise_mag = fabsf(smoothed_nx) + fabsf(smoothed_ny); - float max_noise = base_mag * SMOOTH_NOISE_MAX_RATIO; - if (noise_mag > max_noise) { - float scale = max_noise / noise_mag; - smoothed_nx *= scale; - smoothed_ny *= scale; - } - } - - state.last_noise_vx = smoothed_nx; - state.last_noise_vy = smoothed_ny; - - frame_x_fp = base_x + (int32_t)(smoothed_nx + (smoothed_nx >= 0 ? 0.5f : -0.5f)); - frame_y_fp = base_y + (int32_t)(smoothed_ny + (smoothed_ny >= 0 ? 0.5f : -0.5f)); - } else { - state.last_noise_vx *= SMOOTH_VELOCITY_DECAY; - state.last_noise_vy *= SMOOTH_VELOCITY_DECAY; - } - - if (has_movement) { - state.idle_frames = 0; - } else { - if (state.idle_frames < UINT32_MAX) - state.idle_frames++; - } - - if (!has_movement && state.humanize - && state.idle_frames < SMOOTH_TREMOR_IDLE_TIMEOUT) { - state.tremor_x = state.tremor_x * SMOOTH_TREMOR_DECAY - + smooth_rand_uniform() * SMOOTH_TREMOR_STEP; - state.tremor_y = state.tremor_y * SMOOTH_TREMOR_DECAY - + smooth_rand_uniform() * SMOOTH_TREMOR_STEP; - frame_x_fp = (int32_t)(state.tremor_x * (float)SMOOTH_FP_ONE); - frame_y_fp = (int32_t)(state.tremor_y * (float)SMOOTH_FP_ONE); - } else if (!has_movement) { - // On the first frame past the tremor timeout, hard-zero residual - // state so the next call can take the idle fast-path. - if (state.idle_frames == SMOOTH_TREMOR_IDLE_TIMEOUT) { - state.tremor_x = 0.0f; - state.tremor_y = 0.0f; - } else { - state.tremor_x *= SMOOTH_TREMOR_DECAY; - state.tremor_y *= SMOOTH_TREMOR_DECAY; - } - state.x_accum_fp = 0; - state.y_accum_fp = 0; - } - - state.x_accum_fp += frame_x_fp; - state.y_accum_fp += frame_y_fp; - - int16_t ix, iy; - if (state.humanize) { - int32_t dx = (int32_t)(smooth_rand_uniform() * SMOOTH_DITHER_RANGE - * (float)SMOOTH_FP_ONE); - int32_t dy = (int32_t)(smooth_rand_uniform() * SMOOTH_DITHER_RANGE - * (float)SMOOTH_FP_ONE); - ix = (int16_t)((state.x_accum_fp + SMOOTH_FP_HALF + dx) >> SMOOTH_FP_SHIFT); - iy = (int16_t)((state.y_accum_fp + SMOOTH_FP_HALF + dy) >> SMOOTH_FP_SHIFT); - } else { - ix = fp_to_int(state.x_accum_fp); - iy = fp_to_int(state.y_accum_fp); - } - - state.x_accum_fp -= int_to_fp(ix); - state.y_accum_fp -= int_to_fp(iy); - - if (ix > state.max_per_frame) ix = state.max_per_frame; - if (ix < -state.max_per_frame) ix = -state.max_per_frame; - if (iy > state.max_per_frame) iy = state.max_per_frame; - if (iy < -state.max_per_frame) iy = -state.max_per_frame; - - *out_x = ix; - *out_y = iy; -} - -bool smooth_has_pending(void) -{ - return state.count > 0 || - state.x_accum_fp > SMOOTH_FP_HALF || - state.x_accum_fp < -SMOOTH_FP_HALF || - state.y_accum_fp > SMOOTH_FP_HALF || - state.y_accum_fp < -SMOOTH_FP_HALF; -} - -void smooth_clear(void) -{ - state.free_mask = SMOOTH_ALL_SLOTS_MASK; - state.count = 0; - state.x_accum_fp = 0; - state.y_accum_fp = 0; - state.last_noise_vx = 0.0f; - state.last_noise_vy = 0.0f; - state.tremor_x = 0.0f; - state.tremor_y = 0.0f; -} - -void smooth_set_max_per_frame(int16_t max) -{ - if (max < 1) max = 1; - state.max_per_frame = max; -} - -void smooth_set_humanize(bool enabled) -{ - state.humanize = enabled; - recompute_easing(); -} - -static inline uint32_t timing_lfsr_next(void) -{ - uint32_t x = state.timing_lfsr; - x ^= x << 13; - x ^= x >> 17; - x ^= x << 5; - state.timing_lfsr = x; - return x; -} - -static inline uint32_t timing_rand32(void) -{ - if (state.trng_idx < 32) - return state.trng_buf[state.trng_idx++]; - - if (TRNG_MCTL & TRNG_MCTL_ENT_VAL) { - volatile uint32_t *ent = &TRNG_ENT0; - for (int i = 0; i < 16; i++) - state.trng_buf[i] = ent[i]; - state.trng_idx = 1; - return state.trng_buf[0]; - } - - return timing_lfsr_next(); -} - -static inline float timing_rand_uniform(void) -{ - int32_t balanced = (int32_t)(timing_rand32() >> 8) - 0x800000; - return (float)balanced * (1.0f / 8388608.0f); -} - -__attribute__((section(".fastrun"))) -uint32_t smooth_timing_next(uint32_t base_ldval, bool *out_skip) -{ - *out_skip = false; - - if (!state.humanize) return base_ldval; - - float u1 = timing_rand_uniform(); - float u2 = timing_rand_uniform(); - float jitter = (u1 + u2) * 0.5f * SMOOTH_TIMING_LDVAL_JITTER; - float offset = state.rate_bias + jitter; - float result = (float)base_ldval * (1.0f + offset); - - float lo = (float)base_ldval * 0.75f; - float hi = (float)base_ldval * 1.50f; - if (result < lo) result = lo; - if (result > hi) result = hi; - - return (uint32_t)result; -} diff --git a/src/smooth.h b/src/smooth.h deleted file mode 100644 index d325cb3..0000000 --- a/src/smooth.h +++ /dev/null @@ -1,18 +0,0 @@ -#pragma once -#include -#include - -#define SMOOTH_FP_SHIFT 16 -#define SMOOTH_FP_ONE (1 << SMOOTH_FP_SHIFT) -#define SMOOTH_FP_HALF (1 << (SMOOTH_FP_SHIFT - 1)) - -#define SMOOTH_QUEUE_SIZE 32 - -void smooth_init(uint32_t interval_us); -void smooth_inject(int16_t x, int16_t y); -void smooth_process_frame(int16_t *out_x, int16_t *out_y); -bool smooth_has_pending(void); -void smooth_clear(void); -void smooth_set_max_per_frame(int16_t max); -void smooth_set_humanize(bool enabled); -uint32_t smooth_timing_next(uint32_t base_ldval, bool *out_skip); diff --git a/src/smooth_config.h b/src/smooth_config.h deleted file mode 100644 index b6dbb4b..0000000 --- a/src/smooth_config.h +++ /dev/null @@ -1,138 +0,0 @@ -#pragma once -// Smooth injection — tunable parameters -// Edit these to adjust humanization behavior without touching smooth.c. - -// ---- Easing curve shape ---- -// Attack phase: fraction of total time spent in fast ramp [0.0, 1.0] -#define SMOOTH_EASE_ATTACK_FRAC 0.30f -// Attack phase: fraction of total distance covered during attack [0.0, 1.0] -#define SMOOTH_EASE_ATTACK_DIST 0.60f - -// ---- Spread duration (microseconds) ---- -// How long to spread a movement over, by distance bucket. -// Longer = smoother easing, shorter = more responsive. -#define SMOOTH_SPREAD_SMALL_US 3500 // <20 px (short to minimize cmd/obs gap) -#define SMOOTH_SPREAD_MEDIUM_US 5000 // 20-60 px -#define SMOOTH_SPREAD_LARGE_US 4000 // 60-120 px -#define SMOOTH_SPREAD_XLARGE_US 3000 // >120 px - -// Distance thresholds for spread buckets (pixels) -#define SMOOTH_SPREAD_SMALL_PX 20 -#define SMOOTH_SPREAD_MEDIUM_PX 60 -#define SMOOTH_SPREAD_LARGE_PX 120 - -// Spread duration jitter: ± this fraction of spread_us -#define SMOOTH_SPREAD_JITTER 0.25f - -// Min/max frames per injection -#define SMOOTH_MIN_FRAMES 3 -#define SMOOTH_MAX_FRAMES 128 - -// ---- EWMA noise channels ---- -// Alpha for correlated noise (higher = slower drift, longer correlation) -// 0.97 at 1kHz -> ~33ms correlation time (30Hz bandwidth) -#define SMOOTH_EWMA_ALPHA 0.97f -#define SMOOTH_EWMA_BETA (1.0f - SMOOTH_EWMA_ALPHA) - -// ---- Speed perturbation ---- -// Fallback speed gain when no Fitts data available -#define SMOOTH_SPEED_GAIN_DEFAULT 1.2f -// Overall speed noise amplitude scaling (controls movement-level CV). -// With EWMA alpha=0.97, speed_noise std ≈ 0.071. -// At SCALE=0.55, short hops (gain≈2.0) → ~0.08 per-frame CV → ~0.16 movement CV -// Long throws (gain≈0.55) → ~0.02 per-frame CV → ~0.04 movement CV -#define SMOOTH_SPEED_NOISE_SCALE 0.55f - -// ---- Perpendicular wobble ---- -// Base wobble amplitude (pixels, before arc_scale) -#define SMOOTH_PERP_AMPLITUDE 2.0f - -// Low-speed angle smoothing: wobble boost at low speeds -// arc_scale = 1 + BOOST / (speed_px + 1) -// At 1px/frame: ~2x, at 3px: ~1.4x, at 8px+: ~1.2x -#define SMOOTH_LOWSPEED_BOOST 1.5f - -// ---- Session personality ranges ---- -// Arc bias: dominant perpendicular direction (randomized per session) -#define SMOOTH_ARC_BIAS_RANGE 0.4f -// Arc bias O-U drift: dx = RATE * (rand*INPUT - bias*DECAY) -// theta = RATE*DECAY = 0.0000165 -> tau ~60s at 1kHz -// Steady-state std ~0.15 (wander within ±0.4 init range) -#define SMOOTH_ARC_DRIFT_RATE 0.005f -#define SMOOTH_ARC_DRIFT_INPUT 0.30f -#define SMOOTH_ARC_DRIFT_DECAY 0.0033f - -// Easing personality: per-session perturbation of attack fraction [-range, +range] -// Positive = longer attack (snappier start), negative = shorter attack (more gradual) -#define SMOOTH_OVERSHOOT_RANGE 0.05f - -// Fitts' Law coefficients: MT = a + b * log2(dist + 1) -// Intercept range: [min, min + span] -#define SMOOTH_FITTS_A_MIN 0.08f -#define SMOOTH_FITTS_A_SPAN 0.07f -// Slope range: [min, min + span] -#define SMOOTH_FITTS_B_MIN 0.08f -#define SMOOTH_FITTS_B_SPAN 0.07f -// Gain numerator: gain = numerator / MT, soft-clamped to [min, max] -// 0.50 calibrated so mid-personality spans [0.7, 1.3] for typical movements -#define SMOOTH_FITTS_GAIN_NUM 0.50f -#define SMOOTH_FITTS_GAIN_MIN 0.5f -#define SMOOTH_FITTS_GAIN_MAX 2.5f -// Softplus k for soft clamping (higher = softer transition at boundaries) -#define SMOOTH_FITTS_GAIN_SOFT_K 0.15f - -// ---- Noise velocity continuity ---- -// Smoothing alpha for noise components only (base easing is unsmoothed) -// Higher = more responsive, less smoothing. No displacement leak. -#define SMOOTH_VELOCITY_ALPHA 0.40f -// Noise decay rate when idle (per frame, prevents stale noise momentum) -#define SMOOTH_VELOCITY_DECAY 0.85f - -// ---- Timing humanization (PIT interval jitter) ---- -// Simulates real mouse USB polling characteristics: -// - Occasional missed polls (right-skew) -// - Gaussian-ish interval spread (natural CV) -// - Per-session poll rate personality -// -// Poll skip: DISABLED — creates bimodal interval distribution (detected as -// synthetic by analyzers). Pure continuous jitter is sufficient. -// #define SMOOTH_TIMING_SKIP_PROB_INV 25 -// #define SMOOTH_TIMING_MAX_CONSEC_SKIP 2 - -// PIT reload jitter: ±fraction of base LDVAL, applied via triangular -// distribution (sum of 2 uniforms → bell-shaped, unimodal). -// Triangular std = 0.408 * JITTER, so 0.22 → CV ≈ 0.09. -// Real mice at 1kHz: CV ~0.05-0.15. -#define SMOOTH_TIMING_LDVAL_JITTER 0.22f // ±22% peak, ~9% CV via triangular - -// Per-session poll rate personality: slight offset to base interval. -// Real mice vary ±2-5% between units due to oscillator tolerance. -#define SMOOTH_TIMING_RATE_OFFSET 0.05f // ±5% session bias on interval - -// ---- Persistent micro-tremor ---- -// Fills zero-movement gaps between injection commands with Brownian-like -// micro-movements during active injection. Mean-reverting to prevent drift. -// Only active for IDLE_TIMEOUT frames after the last injection queue activity; -// once expired, tremor and sub-pixel accumulator are zeroed to prevent -// phantom cursor drift when the system is truly idle. -#define SMOOTH_TREMOR_STEP 0.30f // px, random walk step per frame -#define SMOOTH_TREMOR_DECAY 0.88f // mean-reversion factor -#define SMOOTH_TREMOR_IDLE_TIMEOUT 100 // frames after last injection to keep tremor alive - -// ---- Noise magnitude clamp ---- -// Maximum ratio of noise to base displacement per frame. -// Prevents perpendicular wobble from dominating at low speeds where -// fixed-amplitude noise can exceed actual movement by 10x+. -// At 0.35: a 1px/frame movement allows 0.35px of noise. -#define SMOOTH_NOISE_MAX_RATIO 0.35f - -// ---- Dithered rounding ---- -// Varies sub-pixel rounding threshold ±RANGE around 0.5 to break up -// repeated identical integer deltas from smooth easing curves. -#define SMOOTH_DITHER_RANGE 0.35f - -// ---- PRNG ---- -// SFC32 warmup rounds (recommended 12+ for full diffusion) -#define SMOOTH_RNG_WARMUP 15 -// EWMA channel priming rounds -#define SMOOTH_EWMA_PRIME_ROUNDS 20 diff --git a/test/humanize_test.c b/test/humanize_test.c new file mode 100644 index 0000000..e819ecd --- /dev/null +++ b/test/humanize_test.c @@ -0,0 +1,85 @@ +#include +#include +#include +#include +#include "humanize.h" + +static int failures = 0; +#define CHECK(cond, msg) do { if (!(cond)) { \ + printf("FAIL: %s\n", msg); failures++; } } while (0) + +int main(void) { + humanize_init(1000); /* 1 ms frame */ + CHECK(1, "scaffold"); + + /* (A) Conservation: summed output == summed injected, within rounding. */ + humanize_init(1000); + humanize_set_level(2); + long sx = 0; + for (int i = 0; i < 5000; i++) { /* steady 3 px/frame stream */ + int16_t dx = 3, dy = 0; + humanize_filter(&dx, &dy); + sx += dx; + } + for (int i = 0; i < 200; i++) { int16_t dx = 0, dy = 0; humanize_filter(&dx,&dy); sx += dx; } + CHECK(labs(sx - 5000L*3) <= 2, "conservation: output sum tracks input sum"); + + /* (B) Idle gate: zero in, settled -> zero out (no tremor on still cursor). */ + humanize_init(1000); + for (int i = 0; i < 50; i++) { int16_t dx=0, dy=0; humanize_filter(&dx,&dy); } + int moved = 0; + for (int i = 0; i < 500; i++) { int16_t dx=0, dy=0; humanize_filter(&dx,&dy); if (dx||dy) moved=1; } + CHECK(!moved, "idle gate: still cursor stays still"); + + /* (C) Human cap: a huge single injection never emits a teleport frame. */ + humanize_init(1000); + humanize_set_level(2); + int16_t bx = 30000, by = 0; long total = 0; int maxframe = 0; + humanize_filter(&bx, &by); total += bx; if (abs(bx) > maxframe) maxframe = abs(bx); + for (int i = 0; i < 4000; i++) { int16_t dx=0,dy=0; humanize_filter(&dx,&dy); total += dx; if (abs(dx)>maxframe) maxframe=abs(dx); } + CHECK(maxframe <= 127, "cap: no single frame exceeds human ceiling"); + CHECK(labs(total - 30000) <= 4, "cap: clamped motion is carried, not dropped"); + + /* (D) Tier-1: a constant-velocity stream must not emit a long run of + * identical values (anti-quantization) and must vary frame-to-frame. */ + humanize_init(1000); + humanize_set_level(2); + int max_run = 0, run = 0; int16_t prev = -999; + for (int i = 0; i < 3000; i++) { + int16_t dx = 5, dy = 5; humanize_filter(&dx, &dy); + if (dx == prev) { run++; if (run > max_run) max_run = run; } else run = 0; + prev = dx; + } + CHECK(max_run < 200, "anti-quantization: no long identical-value run"); + + /* (E) In-frame delivery: a single small injection (<= cap) must land THIS + * frame, not be smeared across many frames. Pure-X move has no + * perpendicular noise on X, so dx should be ~the full injected amount. */ + humanize_init(1000); + humanize_set_level(2); + int16_t edx = 20, edy = 0; + humanize_filter(&edx, &edy); + CHECK(edx >= 18, "in-frame: small injection delivered same frame"); + + /* (F) Field-clip carry: motion returned (because the report field couldn't + * carry it this frame) is redelivered, not lost. Models an 8-bit field + * with only 50 counts of headroom/frame; all 200 must still arrive. */ + humanize_init(1000); + humanize_set_level(2); + { + long delivered = 0; + int16_t fdx = 200, fdy = 0; + humanize_filter(&fdx, &fdy); + for (int i = 0; i < 80; i++) { + int acc = fdx > 50 ? 50 : (fdx < -50 ? -50 : fdx); /* field headroom */ + humanize_return((int16_t)(fdx - acc), 0); /* carry the rest */ + delivered += acc; + fdx = 0; fdy = 0; + humanize_filter(&fdx, &fdy); + } + CHECK(labs(delivered - 200) <= 2, "field-clip carry: returned motion is redelivered"); + } + + printf(failures ? "\n%d FAILED\n" : "\nALL PASSED\n", failures); + return failures ? 1 : 0; +} diff --git a/tools/humanization_analyze.py b/tools/humanization_analyze.py new file mode 100755 index 0000000..c1a03da --- /dev/null +++ b/tools/humanization_analyze.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Tier-1 humanization analyzer. + +Reads a motion trace (one "dx dy" pair per line, whitespace-separated; lines +that fail to parse are skipped) and reports the kinematic signatures anti-cheat +Tier-1 detectors look for. Compare a captured device-output trace against a +real human baseline (a passthrough-mouse capture). + +Usage: tools/humanization_analyze.py trace.txt [--baseline human.txt] +""" +import sys, argparse, math, statistics + +def load(path): + xs = [] + with open(path) as f: + for line in f: + p = line.split() + if len(p) < 2: continue + try: xs.append((float(p[0]), float(p[1]))) + except ValueError: continue + return xs + +def metrics(tr): + # velocity per frame = the delta itself (1 frame dt) + v = [math.hypot(dx, dy) for dx, dy in tr] + a = [v[i] - v[i-1] for i in range(1, len(v))] # acceleration + j = [a[i] - a[i-1] for i in range(1, len(a))] # jerk + zero_jerk = sum(1 for x in j if abs(x) < 1e-9) / max(len(j), 1) + # longest identical-value run on dx (quantization tell) + run = mx = 1 + for i in range(1, len(tr)): + run = run + 1 if tr[i][0] == tr[i-1][0] else 1 + mx = max(mx, run) + return { + "frames": len(tr), + "zero_jerk_frac": zero_jerk, + "jerk_rms": (statistics.pstdev(j) if len(j) > 1 else 0.0), + "max_identical_run": mx, + "vel_mean": (statistics.mean(v) if v else 0.0), + } + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("trace") + ap.add_argument("--baseline") + a = ap.parse_args() + m = metrics(load(a.trace)) + print(f"trace: {a.trace}") + for k, val in m.items(): print(f" {k}: {val:.4f}" if isinstance(val,float) else f" {k}: {val}") + # Heuristic pass/fail (tune against the human baseline) + flags = [] + if m["zero_jerk_frac"] > 0.20: flags.append("HIGH zero-jerk fraction (robotic)") + if m["max_identical_run"] > 200: flags.append("long identical-value run (quantized)") + if m["jerk_rms"] < 0.05 and m["vel_mean"] > 1.0: flags.append("near-zero jerk variance (too smooth)") + if a.baseline: + b = metrics(load(a.baseline)) + print(f"baseline: {a.baseline}") + for k, val in b.items(): print(f" {k}: {val:.4f}" if isinstance(val,float) else f" {k}: {val}") + print("RESULT:", "FLAGS: " + "; ".join(flags) if flags else "looks human (Tier-1)") + return 1 if flags else 0 + +if __name__ == "__main__": + sys.exit(main())