Skip to content

Commit aba507d

Browse files
Add mobile Cat Blink v2 capture (#268)
* Restore legacy Cat Mode fullscreen blinking * Add mobile Cat Blink v2 capture
1 parent 013d761 commit aba507d

12 files changed

Lines changed: 1286 additions & 36 deletions
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
/**
2+
* catBlinkV2.test.ts — proves the mobile Cat Blink v2 FRAME-layer decoder is
3+
* bit-true against the SAME committed vector the JS and Python codecs use.
4+
*
5+
* The vector (tests/data/cat_blink_v2_vector.json) is produced by the JS emitter
6+
* (web_demo/static/cat-blink-v2.js) and consumed by cat_blink_v2.py; reading the
7+
* real file here keeps the JS-emitter ↔ mobile-decoder round-trip proven, not
8+
* just self-consistent. Each entry in `frames` is one WHITENED frame bitstring.
9+
*/
10+
11+
import * as fs from 'fs';
12+
import * as path from 'path';
13+
import {
14+
decodeFrame,
15+
scanFrames,
16+
crc16,
17+
bytesToBits,
18+
bitsToBytes,
19+
dewhitenBits,
20+
whitenBits,
21+
CatBlinkV2Collector,
22+
collectTarget,
23+
SYNC,
24+
VERSION,
25+
} from '../src/services/catBlinkV2';
26+
27+
// ── Load the committed cross-language vector ────────────────────────────────
28+
29+
interface Vector {
30+
payload_bytes: number[];
31+
kBlocks: number;
32+
blockSize: number;
33+
originalLength: number;
34+
frames: string[];
35+
}
36+
37+
const VECTOR_PATH = path.resolve(__dirname, '../../tests/data/cat_blink_v2_vector.json');
38+
const vector = JSON.parse(fs.readFileSync(VECTOR_PATH, 'utf8')) as Vector;
39+
40+
// ── Frame-layer primitives ──────────────────────────────────────────────────
41+
42+
describe('catBlinkV2 primitives', () => {
43+
it('crc16 is CRC-16/CCITT-FALSE (known test vector "123456789" = 0x29B1)', () => {
44+
const bytes = new Uint8Array([...'123456789'].map((c) => c.charCodeAt(0)));
45+
expect(crc16(bytes)).toBe(0x29b1);
46+
});
47+
48+
it('bytesToBits/bitsToBytes round-trip MSB-first', () => {
49+
const bytes = new Uint8Array([0xb1, 0x17, 0x5e, 0x02, 0x00, 0xff]);
50+
const bits = bytesToBits(bytes);
51+
expect(bits).toBe('101100010001011101011110000000100000000011111111');
52+
expect([...bitsToBytes(bits)]).toEqual([...bytes]);
53+
});
54+
55+
it('dewhitenBits is self-inverse (matches catWhitening)', () => {
56+
const raw = bytesToBits(new Uint8Array([0x00, 0x00, 0x00, 0xff, 0xa5]));
57+
expect(dewhitenBits(whitenBits(raw))).toBe(raw);
58+
});
59+
});
60+
61+
// ── decodeFrame against every committed frame ───────────────────────────────
62+
63+
describe('catBlinkV2 decodeFrame (committed vector)', () => {
64+
it('decodes every frame with the vector headers and a well-formed droplet', () => {
65+
expect(vector.frames.length).toBeGreaterThan(0);
66+
for (const wf of vector.frames) {
67+
const f = decodeFrame(wf);
68+
expect(f).not.toBeNull();
69+
expect(f!.version).toBe(VERSION);
70+
expect(f!.kBlocks).toBe(vector.kBlocks);
71+
expect(f!.blockSize).toBe(vector.blockSize);
72+
expect(f!.originalLength).toBe(vector.originalLength);
73+
// droplet_len = 4(seed)+2(count)+count*2(indices)+blockSize(data)
74+
expect(f!.dropletBytes.length).toBe(4 + 2 + f!.count * 2 + vector.blockSize);
75+
// seed = first 4 bytes of dropletBytes, big-endian
76+
const db = f!.dropletBytes;
77+
const expectedSeed =
78+
(db[0]! * 0x1000000 + (db[1]! << 16) + (db[2]! << 8) + db[3]!) >>> 0;
79+
expect(f!.seed).toBe(expectedSeed);
80+
}
81+
});
82+
83+
it('recovers all kBlocks distinct source-block indices across the frames', () => {
84+
// The systematic droplets (degree-1) cover every source block; gathering the
85+
// frames must reference all k blocks at least once.
86+
const covered = new Set<number>();
87+
for (const wf of vector.frames) {
88+
const f = decodeFrame(wf);
89+
if (!f) continue;
90+
const db = f.dropletBytes;
91+
const count = (db[4]! << 8) | db[5]!;
92+
for (let i = 0; i < count; i++) {
93+
covered.add((db[6 + i * 2]! << 8) | db[7 + i * 2]!);
94+
}
95+
}
96+
for (let k = 0; k < vector.kBlocks; k++) {
97+
expect(covered.has(k)).toBe(true);
98+
}
99+
});
100+
101+
it('rejects a frame whose CRC is corrupted (fail-closed)', () => {
102+
const wf = vector.frames[0]!;
103+
// Flip the last (whitened) bit → the recovered CRC no longer matches.
104+
const flipped = wf.slice(0, -1) + (wf[wf.length - 1] === '1' ? '0' : '1');
105+
expect(decodeFrame(flipped)).toBeNull();
106+
});
107+
108+
it('rejects a too-short bitstring and a wrong-SYNC frame', () => {
109+
expect(decodeFrame('0101')).toBeNull();
110+
// Build a header-length whitened frame whose SYNC is wrong.
111+
const bogus = new Uint8Array(20); // all zero → SYNC mismatch
112+
expect(decodeFrame(whitenBits(bytesToBits(bogus)))).toBeNull();
113+
});
114+
});
115+
116+
// ── scanFrames over a free-running loop (concatenation) ─────────────────────
117+
118+
describe('catBlinkV2 scanFrames', () => {
119+
/** Build a free-running loop: guard garbage + concatenated whitened frames. */
120+
function buildLoop(frames: string[], guard = '0110'): string {
121+
return guard + frames.join(guard) + guard;
122+
}
123+
124+
it('locks onto SYNC and recovers every frame from a concatenated stream', () => {
125+
const stream = vector.frames.join(''); // back-to-back, no guard
126+
const found = scanFrames(stream);
127+
expect(found.length).toBe(vector.frames.length);
128+
for (const sf of found) {
129+
expect(sf.kBlocks).toBe(vector.kBlocks);
130+
expect(sf.blockSize).toBe(vector.blockSize);
131+
}
132+
// The recovered whitened slices must equal the emitted frames.
133+
expect(found.map((f) => f.whitenedFrame)).toEqual(vector.frames);
134+
});
135+
136+
it('tolerates leading/trailing garbage and inter-frame guard bits', () => {
137+
const stream = buildLoop(vector.frames);
138+
const found = scanFrames(stream);
139+
// Every unique seed present in the vector must be recovered.
140+
const seeds = new Set(found.map((f) => f.seed));
141+
const expected = new Set(vector.frames.map((wf) => decodeFrame(wf)!.seed));
142+
expect(seeds).toEqual(expected);
143+
});
144+
});
145+
146+
// ── Collector: dedupe + completion (drop/shuffle case) ──────────────────────
147+
148+
describe('CatBlinkV2Collector', () => {
149+
it('collectTarget mirrors the QR path: ceil(1.5 × k)', () => {
150+
expect(collectTarget(3)).toBe(5); // ceil(4.5)
151+
expect(collectTarget(1)).toBe(2);
152+
expect(collectTarget(10)).toBe(15);
153+
});
154+
155+
it('collects unique droplets and completes at ceil(1.5×k)', () => {
156+
const c = new CatBlinkV2Collector();
157+
// Feed one frame at a time (as the growing sample buffer would).
158+
for (const wf of vector.frames) c.addStream(wf);
159+
const r = c.result();
160+
expect(r.kBlocks).toBe(vector.kBlocks);
161+
expect(r.blockSize).toBe(vector.blockSize);
162+
expect(r.originalLength).toBe(vector.originalLength);
163+
expect(r.target).toBe(collectTarget(vector.kBlocks));
164+
// The vector has >= target unique droplets, so collection completes.
165+
expect(r.droplets.length).toBeGreaterThanOrEqual(r.target!);
166+
expect(r.complete).toBe(true);
167+
});
168+
169+
it('dedupes: re-feeding the same stream adds nothing', () => {
170+
const c = new CatBlinkV2Collector();
171+
const stream = vector.frames.join('');
172+
const first = c.addStream(stream);
173+
expect(first).toBe(vector.frames.length);
174+
expect(c.addStream(stream)).toBe(0); // idempotent
175+
expect(c.size).toBe(vector.frames.length);
176+
});
177+
178+
it('drop/shuffle case: recovers all kBlocks from a lossy, reordered subset', () => {
179+
// Shuffle deterministically and drop a third of the frames.
180+
const shuffled = [...vector.frames]
181+
.map((wf, i) => ({ wf, key: (i * 7 + 3) % vector.frames.length }))
182+
.sort((a, b) => a.key - b.key)
183+
.map((x) => x.wf);
184+
const kept = shuffled.filter((_, i) => i % 3 !== 0); // drop every 3rd
185+
186+
const c = new CatBlinkV2Collector();
187+
for (const wf of kept) c.addStream(wf);
188+
const r = c.result();
189+
190+
expect(r.kBlocks).toBe(vector.kBlocks);
191+
// Every surviving droplet has a distinct seed → no false dedup.
192+
const distinct = new Set(kept.map((wf) => decodeFrame(wf)!.seed));
193+
expect(r.droplets.length).toBe(distinct.size);
194+
// The kept subset still covers all k source blocks (systematic droplets).
195+
const covered = new Set<number>();
196+
for (const d of r.droplets) {
197+
const db = d.dropletBytes;
198+
const count = (db[4]! << 8) | db[5]!;
199+
for (let i = 0; i < count; i++) {
200+
covered.add((db[6 + i * 2]! << 8) | db[7 + i * 2]!);
201+
}
202+
}
203+
for (let k = 0; k < vector.kBlocks; k++) expect(covered.has(k)).toBe(true);
204+
});
205+
206+
it('reset clears all collected state', () => {
207+
const c = new CatBlinkV2Collector();
208+
c.addStream(vector.frames[0]!);
209+
expect(c.size).toBe(1);
210+
c.reset();
211+
expect(c.size).toBe(0);
212+
expect(c.result().kBlocks).toBeNull();
213+
});
214+
});
215+
216+
// Sanity: the SYNC constant is exactly the documented marker.
217+
it('SYNC marker is 0xB1 0x17 0x5E', () => {
218+
expect([...SYNC]).toEqual([0xb1, 0x17, 0x5e]);
219+
});

0 commit comments

Comments
 (0)