diff --git a/js/arena-runner-g6.js b/js/arena-runner-g6.js index 769e584..3cdaf44 100644 --- a/js/arena-runner-g6.js +++ b/js/arena-runner-g6.js @@ -103,14 +103,16 @@ var ArenaRunnerG6 = (function () { * can't represent, before the encoder's terser RangeError would fire: * - mode ∉ {2,3,4} * - non-integer / < 1 patternId - * Gain is coerced; encodeTrialParams enforces the int8 range. frame_rate + * Gain is coerced; encodeTrialParams enforces the int16 range. frame_rate * is passed through SIGNED — negative plays Mode 2 in reverse (G4-style * count-down; fw ee74c33+, fw issue #4), sign ignored by firmware in - * Modes 3/4; encodeTrialParams enforces the int16 range. + * Modes 3/4; encodeTrialParams enforces the int16 range. duration is + * seconds (same convention as conditionDuration's wait/trialParams math); + * encodeTrialParams converts to 10 ms ticks on the wire. * * @param {object} cmd the trialParams controller command * @param {{patternId:number}} opts the resolved 1-based SD index - * @returns {{mode:number, patternId:number, frameRate:number, gain:number, initPos:number}} + * @returns {{mode:number, patternId:number, frameRate:number, gain:number, initPos:number, duration:number}} */ function buildTrialParams(cmd, opts) { cmd = cmd || {}; @@ -129,6 +131,7 @@ var ArenaRunnerG6 = (function () { const gain = cmd.gain === undefined ? 0 : toNumber(cmd.gain, 'gain'); const initPos = frameIndexToInitPos(cmd.frame_index); + const duration = cmd.duration === undefined ? 0 : toNumber(cmd.duration, 'duration'); const patternId = toNumber(opts.patternId, 'patternId'); if (!Number.isInteger(patternId) || patternId < 1) { @@ -138,7 +141,7 @@ var ArenaRunnerG6 = (function () { ); } - return { mode, patternId, frameRate, gain, initPos }; + return { mode, patternId, frameRate, gain, initPos, duration }; } // The controller commands the sequence runner can EMIT on G6, grounded in the @@ -505,6 +508,9 @@ var ArenaRunnerG6 = (function () { * the controller signals completion), replace this with a function that AWAITS * the controller's run-complete event instead of sleeping — or inject * `opts.timing` into runSequence. Nothing else in the runner changes. + * The wire-side duration now exists (buildTrialParams/encodeTrialParams), so + * the swap is unblocked whenever it's picked up — this function is untouched + * for now (host-side timing stays authoritative; see plan scope decision). * * Best-effort: a slept/closed tab won't fire the timer, so STOP/abort is the * primary control. diff --git a/js/arena-session.js b/js/arena-session.js index 4972c40..462eb3e 100644 --- a/js/arena-session.js +++ b/js/arena-session.js @@ -295,7 +295,7 @@ * Run a SINGLE trial (LAB-94 dry-run): sends only trialParams, arms an * optional host-timed auto-stop. Pre-empts any active run first. * @param {object} a - * @param {object} a.params encodeTrialParams arg {mode,patternId,frameRate,gain,initPos} + * @param {object} a.params encodeTrialParams arg {mode,patternId,frameRate,initPos,gain,duration} * @param {number} [a.durationSec] >0 arms a host-side auto-stop * @param {string} [a.conditionName] * @param {Function} [a.onStatus] per-call status sink (also broadcast as 'runstatus') diff --git a/js/arena-wire-g6.js b/js/arena-wire-g6.js index 629d83f..08e47fc 100644 --- a/js/arena-wire-g6.js +++ b/js/arena-wire-g6.js @@ -145,17 +145,6 @@ const ArenaWireG6 = (function () { return [value & 0xff, (value >> 8) & 0xff]; } - // Validate a signed int8 and return the unsigned wire byte (two's - // complement). e.g. gain -50 -> 0xCE. This is the easy-to-get-wrong case - // the golden tests pin. - function int8Byte(value, name) { - requireInt(value, name); - if (value < -128 || value > 127) { - throw new RangeError(name + ' must be -128..127 (int8), got ' + value); - } - return value & 0xff; - } - // Validate a signed int16 and return [lo, hi] little-endian two's // complement. e.g. -2 -> [0xFE, 0xFF]. Used for trial-params frame_rate, // which firmware reads as int16 (fw #4, ee74c33: negative = Mode-2 @@ -214,10 +203,10 @@ const ArenaWireG6 = (function () { /** * trial-params (0x08) — select display mode + pattern + timing. * Emits the documented 13-byte combined command: - * [0C 08 mode pat(LE16) rate(LE16) gain init(LE16) 00 00 00] - * The length byte 0x0C = 12 = cmd + 11 param bytes. The 3 trailing reserved - * bytes pad the combined-command length; the firmware reads only the first - * 8 param bytes. + * [0C 08 mode pat(LE16) rate(LE16) init(LE16) gain(LE16) duration(LE16)] + * The length byte 0x0C = 12 = cmd + 11 param bytes, all required (fw #4 + * canonical re-layout: gain widened to int16, moved after init_pos, plus + * a new controller-run Duration field). * * @param {object} p * @param {number} [p.mode=2] display mode (2 open / 3 show-frame / 4 closed) @@ -225,8 +214,12 @@ const ArenaWireG6 = (function () { * @param {number} [p.frameRate=0] frame-advance rate in Hz, int16 — negative * plays Mode 2 in REVERSE (G4-style count-down; * fw ee74c33+); sign ignored in Modes 3/4 - * @param {number} [p.gain=0] signed int8 velocity gain (×10 fps/V in Mode 4) * @param {number} [p.initPos=0] initial frame index (0-based) + * @param {number} [p.gain=0] signed int16 velocity gain (×10 fps/V in Mode 4) + * @param {number} [p.duration=0] controller-run trial length, in SECONDS + * (converted to AC::constants::duration_tick_ms — + * 10 ms — ticks on the wire); `0` = no auto-stop, + * the controller runs until told to stop */ function encodeTrialParams(p) { p = p || {}; @@ -238,10 +231,14 @@ const ArenaWireG6 = (function () { } const pat = u16le(patternId, 'patternId'); const rate = i16le(p.frameRate === undefined ? 0 : p.frameRate, 'frameRate'); - const gain = int8Byte(p.gain === undefined ? 0 : p.gain, 'gain'); const init = u16le(p.initPos === undefined ? 0 : p.initPos, 'initPos'); - // mode, pat(2), rate(2), gain, init(2), reserved(3) = 11 param bytes. - const params = [mode, ...pat, ...rate, gain, ...init, 0, 0, 0]; + const gain = i16le(p.gain === undefined ? 0 : p.gain, 'gain'); + const durationTicks = u16le( + Math.round((p.duration === undefined ? 0 : p.duration) * 100), + 'duration' + ); // seconds -> 10ms ticks, matching AC::constants::duration_tick_ms + // mode, pat(2), rate(2), init(2), gain(2), duration(2) = 11 param bytes. + const params = [mode, ...pat, ...rate, ...init, ...gain, ...durationTicks]; return frame(OPCODES.TRIAL_PARAMS, params); // 0C 08 ... } diff --git a/tests/test-arena-runner-g6.js b/tests/test-arena-runner-g6.js index 4203d15..408e31f 100644 --- a/tests/test-arena-runner-g6.js +++ b/tests/test-arena-runner-g6.js @@ -188,11 +188,12 @@ async function main() { check('frameRate', p.frameRate, 10); check('gain', p.gain, 0); check('initPos (from frame_index 1)', p.initPos, 1); - // The encoded frame must match the wire golden vector. + check('duration', p.duration, 5); + // The encoded frame must match the wire golden vector (duration=5s -> 500 ticks -> F4 01). checkBytes( 'encodeTrialParams(mapped)', Wire.encodeTrialParams(p), - '0c 08 02 01 00 0a 00 00 01 00 00 00 00' + '0c 08 02 01 00 0a 00 01 00 00 00 f4 01' ); // THE coercion test: string scalars (as a YAML parser might yield) must work. @@ -201,7 +202,7 @@ async function main() { checkBytes( 'string-typed fields coerce to the same frame', Wire.encodeTrialParams(ps), - '0c 08 02 01 00 0a 00 00 01 00 00 00 00' + '0c 08 02 01 00 0a 00 01 00 00 00 00 00' ); // NEGATIVE frame_rate = Mode-2 reverse playback (fw ee74c33+, fw #4) — @@ -233,7 +234,7 @@ async function main() { checkBytes( 'sent the trialParams frame', link.sent[0], - '0c 08 02 01 00 0a 00 00 01 00 00 00 00' + '0c 08 02 01 00 0a 00 01 00 00 00 f4 01' ); check('conditionName tracked', runner.conditionName, 'sine_grating'); @@ -530,7 +531,7 @@ async function main() { }); check('sent exactly 3 frames (allOn, trialParams, final STOP)', link.sent.length, 3); checkBytes('1st send: allOn', link.sent[0], '01 ff'); - checkBytes('2nd send: trialParams', link.sent[1], '0c 08 02 01 00 0a 00 00 01 00 00 00 00'); + checkBytes('2nd send: trialParams', link.sent[1], '0c 08 02 01 00 0a 00 01 00 00 00 f4 01'); checkBytes('3rd send: final STOP', link.sent[2], '01 30'); checkBool('summary.completed true', summary.completed === true); checkBool('summary.aborted false', summary.aborted === false); diff --git a/tests/test-arena-wire-g6.js b/tests/test-arena-wire-g6.js index 6205853..f18c9f3 100644 --- a/tests/test-arena-wire-g6.js +++ b/tests/test-arena-wire-g6.js @@ -10,7 +10,7 @@ * - scripts/all_on.py (all-on / all-off) * - scripts/web-serial/main.js (every button's byte sequence) * - src/commands.h (opcodes) - * in the sibling repo LED-Display_G6_Firmware_Arena. The negative-gain int8 + * in the sibling repo LED-Display_G6_Firmware_Arena. The negative-gain int16 * Mode-4 case is pinned explicitly — it's the easy-to-get-wrong one. * * Exits 0 on PASS, 1 on any FAIL. Wired into `npm test` for CI. @@ -132,33 +132,44 @@ checkBytes( Wire.encodeTrialParams({ mode: 2, patternId: 1, frameRate: 30, initPos: 0 }), '0c 08 02 01 00 1e 00 00 00 00 00 00 00' ); -// Defaults: mode=2, patternId=1, frameRate=0, gain=0, initPos=0. +// Defaults: mode=2, patternId=1, frameRate=0, initPos=0, gain=0, duration=0. checkBytes( 'trial defaults (mode2 pat1)', Wire.encodeTrialParams(), '0c 08 02 01 00 00 00 00 00 00 00 00 00' ); -// Mode 4 closed-loop, NEGATIVE gain -50 -> int8 byte 0xCE (the must-pin case). +// Mode 4 closed-loop, NEGATIVE gain -50 -> int16 LE CE FF (the must-pin case). checkBytes( - 'trial mode4 gain -50 -> 0xCE', + 'trial mode4 gain -50 -> CE FF', Wire.encodeTrialParams({ mode: 4, patternId: 1, frameRate: 0, gain: -50, initPos: 0 }), - '0c 08 04 01 00 00 00 ce 00 00 00 00 00' + '0c 08 04 01 00 00 00 00 00 ce ff 00 00' ); -// play_pattern.py docstring example: Mode 4 gain -20 -> int8 byte 0xEC. +// play_pattern.py docstring example: Mode 4 gain -20 -> int16 LE EC FF. checkBytes( - 'trial mode4 gain -20 -> 0xEC', + 'trial mode4 gain -20 -> EC FF', Wire.encodeTrialParams({ mode: 4, patternId: 1, gain: -20 }), - '0c 08 04 01 00 00 00 ec 00 00 00 00 00' + '0c 08 04 01 00 00 00 00 00 ec ff 00 00' ); -// int8 boundaries: -128 -> 0x80, 127 -> 0x7F, -1 -> 0xFF. -check('gain -128 byte', Wire.encodeTrialParams({ gain: -128 })[7], 0x80); -check('gain 127 byte', Wire.encodeTrialParams({ gain: 127 })[7], 0x7f); -check('gain -1 byte', Wire.encodeTrialParams({ gain: -1 })[7], 0xff); +// Widened-gain case: -500 is past the old int8 ceiling, exercising the gain +// high byte -> int16 LE 0C FE. +checkBytes( + 'trial mode4 gain -500 -> 0C FE', + Wire.encodeTrialParams({ mode: 4, patternId: 1, gain: -500 }), + '0c 08 04 01 00 00 00 00 00 0c fe 00 00' +); +// int16 boundaries: -32768 -> 00 80, 32767 -> FF 7F, -1 -> FF FF. +check('gain -32768 lo byte', Wire.encodeTrialParams({ gain: -32768 })[9], 0x00); +check('gain -32768 hi byte', Wire.encodeTrialParams({ gain: -32768 })[10], 0x80); +check('gain 32767 lo byte', Wire.encodeTrialParams({ gain: 32767 })[9], 0xff); +check('gain 32767 hi byte', Wire.encodeTrialParams({ gain: 32767 })[10], 0x7f); +check('gain -1 lo byte', Wire.encodeTrialParams({ gain: -1 })[9], 0xff); +check('gain -1 hi byte', Wire.encodeTrialParams({ gain: -1 })[10], 0xff); // Mode 3 show-frame with a large pattern id / init exercises both u16 hi bytes. +// init_pos now sits at offset 7-8 (post-relayout). checkBytes( 'trial mode3 pat300 init513', Wire.encodeTrialParams({ mode: 3, patternId: 300, initPos: 513 }), - '0c 08 03 2c 01 00 00 00 01 02 00 00 00' + '0c 08 03 2c 01 00 00 01 02 00 00 00 00' ); // NEGATIVE frame_rate = Mode-2 REVERSE (fw reads int16 since ee74c33, fw #4). // -2 Hz -> int16 LE FE FF — the two's-complement must-pin case for the rate. @@ -176,13 +187,20 @@ checkBytes( check('rate 32767 lo byte', Wire.encodeTrialParams({ frameRate: 32767 })[5], 0xff); check('rate 32767 hi byte', Wire.encodeTrialParams({ frameRate: 32767 })[6], 0x7f); check('rate -32768 hi byte', Wire.encodeTrialParams({ frameRate: -32768 })[6], 0x80); +// duration=0 (default) is covered by 'trial defaults' above. 1.5 s -> 150 +// ticks (10 ms/tick) -> int16 LE 96 00, at the new offset 11-12. +checkBytes( + 'trial duration 1.5s -> 150 ticks (96 00)', + Wire.encodeTrialParams({ mode: 2, patternId: 1, duration: 1.5 }), + '0c 08 02 01 00 00 00 00 00 00 00 96 00' +); // Length byte is always 0x0C (12 bytes follow: cmd + 11 params); total = 13 B. check('trial frame total length', Wire.encodeTrialParams().length, 13); check('trial length byte', Wire.encodeTrialParams()[0], 0x0c); console.log('\n=== encoder range validation (throws) ==='); -checkThrows('gain -129 throws', () => Wire.encodeTrialParams({ gain: -129 })); -checkThrows('gain 128 throws', () => Wire.encodeTrialParams({ gain: 128 })); +checkThrows('gain -32769 throws', () => Wire.encodeTrialParams({ gain: -32769 })); +checkThrows('gain 32768 throws', () => Wire.encodeTrialParams({ gain: 32768 })); checkThrows('patternId 70000 throws', () => Wire.encodeTrialParams({ patternId: 70000 })); // frame_rate is int16 now: 32768..65535 would ALIAS to reverse rates on the // signed firmware — must throw, not silently encode (they were legal as u16). @@ -193,6 +211,11 @@ checkThrows('rate 65535 throws (was legal as u16)', () => Wire.encodeTrialParams({ frameRate: 65535 }) ); checkThrows('rate -32769 throws', () => Wire.encodeTrialParams({ frameRate: -32769 })); +// duration ticks must fit uint16: 655.35 s is the max representable (65535 +// ticks); 655.36 s rounds to 65536 ticks and must throw, not wrap. +checkThrows('duration 655.36s throws (tick overflow)', () => + Wire.encodeTrialParams({ duration: 655.36 }) +); checkThrows('frame position -1 throws', () => Wire.encodeSetFramePosition(-1)); checkThrows('frame position 70000 throws', () => Wire.encodeSetFramePosition(70000)); checkThrows('non-integer mhz throws', () => Wire.encodeSetSpiClock(20.5));