From cdd63934fbf11f7c8c35f057369f072581237f50 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 4 Oct 2026 21:30:44 -0700 Subject: [PATCH] Add AC OS HDMI controller playground and Slab keyboard remote Add HDMI-only primary output, explicit pixel scale, cropped alpha blits, saved-network retries, and the native display/sprite/world/platformer pieces. Include the 8BitDo USB reader, physics tests, and Cmd-Option-L SSH keyboard handoff. Package shared piece libraries in the build and dev-push paths. Record device validation and remaining release limits in controller-playground.md. No public OTA release. --- fedac/native/docker-build.sh | 3 + fedac/native/docs/controller-playground.md | 104 ++++++ fedac/native/lib/platform-physics.mjs | 79 +++++ fedac/native/lib/platform-physics.test.mjs | 10 + fedac/native/lib/saved-wifi.mjs | 22 ++ fedac/native/lib/saved-wifi.test.mjs | 12 + fedac/native/pieces/doody-blit.mjs | 141 ++++++++ fedac/native/pieces/feral-file-now.mjs | 56 ++++ fedac/native/pieces/fia-stars.mjs | 34 ++ fedac/native/pieces/hdmi.mjs | 86 +++++ fedac/native/pieces/little-platformer.mjs | 90 +++++ fedac/native/pieces/little-world.mjs | 128 ++++++++ .../scripts/build-and-flash-initramfs.sh | 3 + fedac/native/scripts/build-and-flash.sh | 3 + fedac/native/scripts/dev-push.sh | 3 + fedac/native/src/ac-native.c | 7 + fedac/native/src/drm-display.c | 43 ++- fedac/native/src/js-bindings.c | 26 +- fedac/native/tools/ac-keyboard-remote-test.c | 52 +++ fedac/native/tools/ac-keyboard-remote.c | 125 +++++++ fedac/native/tools/ac-usb-controller-test.c | 22 ++ fedac/native/tools/ac-usb-controller.c | 118 +++++++ fedac/native/tools/hdmi-mode-probe.c | 56 ++++ fedac/native/tools/hdmi-monitor.sh | 12 + slab/menubar-swift/README.md | 21 ++ .../SlabMenubar/ACKeyboardRemote.swift | 307 ++++++++++++++++++ .../Sources/SlabMenubar/AppDelegate.swift | 19 ++ slab/menubar-swift/tests/ac-remote-test.sh | 8 + slab/menubar-swift/tests/ac-remote-test.swift | 80 +++++ 29 files changed, 1665 insertions(+), 5 deletions(-) create mode 100644 fedac/native/docs/controller-playground.md create mode 100644 fedac/native/lib/platform-physics.mjs create mode 100644 fedac/native/lib/platform-physics.test.mjs create mode 100644 fedac/native/lib/saved-wifi.mjs create mode 100644 fedac/native/lib/saved-wifi.test.mjs create mode 100644 fedac/native/pieces/doody-blit.mjs create mode 100644 fedac/native/pieces/feral-file-now.mjs create mode 100644 fedac/native/pieces/fia-stars.mjs create mode 100644 fedac/native/pieces/hdmi.mjs create mode 100644 fedac/native/pieces/little-platformer.mjs create mode 100644 fedac/native/pieces/little-world.mjs create mode 100644 fedac/native/tools/ac-keyboard-remote-test.c create mode 100644 fedac/native/tools/ac-keyboard-remote.c create mode 100644 fedac/native/tools/ac-usb-controller-test.c create mode 100644 fedac/native/tools/ac-usb-controller.c create mode 100644 fedac/native/tools/hdmi-mode-probe.c create mode 100755 fedac/native/tools/hdmi-monitor.sh create mode 100644 slab/menubar-swift/Sources/SlabMenubar/ACKeyboardRemote.swift create mode 100755 slab/menubar-swift/tests/ac-remote-test.sh create mode 100644 slab/menubar-swift/tests/ac-remote-test.swift diff --git a/fedac/native/docker-build.sh b/fedac/native/docker-build.sh index eb9cf22299..e7f58e706a 100644 --- a/fedac/native/docker-build.sh +++ b/fedac/native/docker-build.sh @@ -700,6 +700,9 @@ fi # These are pure JS with no DOM/browser deps — work in QuickJS as-is. # The module loader resolves "../lib/X.mjs" → "/lib/X.mjs" in the initramfs. mkdir -p "$IROOT/lib" +for libmjs in saved-wifi.mjs platform-physics.mjs; do + cp "$NATIVE/lib/$libmjs" "$IROOT/lib/$libmjs" +done for libmjs in melody-parser.mjs notepat-convert.mjs note-colors.mjs num.mjs percussion.mjs synth.mjs nom.mjs; do SRC_LIB="$SRC/system/public/aesthetic.computer/lib/$libmjs" if [ -f "$SRC_LIB" ]; then diff --git a/fedac/native/docs/controller-playground.md b/fedac/native/docs/controller-playground.md new file mode 100644 index 0000000000..8097f7b5c6 --- /dev/null +++ b/fedac/native/docs/controller-playground.md @@ -0,0 +1,104 @@ +# HDMI controller playground + +The October 4, 2026 device session runs a closed-lid ThinkPad on HDMI at +1920×1080/60, drawing a 640×360 canvas at 3× nearest-neighbor scale. These +changes are a development checkpoint; no public OTA was built or published. + +## Display and drawing + +Set `AC_HDMI_ONLY=1 AC_PIXEL_SCALE=3` before launching `ac-native`. HDMI becomes +the primary display, using advertised progressive 1080p60 when available, and +other CRTCs are disabled after a successful modeset. With no connected HDMI +at startup, the primary-display search falls back to the laptop panel. +`AC_PIXEL_SCALE` accepts integers 1–16. HDMI-only mode does not provide live +unplug-to-internal-panel handoff; restart the runtime to select another output. + +Native `paste(painting, dx, dy, sx, sy, width, height)` now supports source +rectangles with preserved row stride and alpha compositing. Invalid source +rectangles are ignored. The original three-argument form is unchanged. + +`hdmi` displays FPS, source size, actual HDMI mode, scale, and the nearest +filter. Run `sh /mnt/tools/hdmi-monitor.sh` to populate its mode readout from +DRM debugfs. `hdmi-mode-probe.c` is an optional diagnostic preload for older +mirroring runtimes: it restricts modes to advertised progressive choices and +reverts unconfirmed tests after 20 seconds. Do not combine it with HDMI-only +mode. It is not loaded by the normal build. + +## Controller and keyboard + +The tested USB device is the **8BitDo Arcade Controller for Xbox, 2dc8:202c**. +The device kernel has neither xpad nor loadable modules. The narrowly scoped +`tools/ac-usb-controller.c` reader claims its unbound USB data interface via +usbfs, initializes GIP reports, and publishes atomic state to +`/tmp/ac-controller.json`. It never detaches a kernel driver. A lock prevents +multiple readers; unplugging clears held controls, and discovery retries. +Pieces discard stale state after 1.5 seconds. This is a piece-level bridge, +not a general native gamepad API or support for every Xbox controller. + +Build the helpers on Linux for the target architecture: + +```sh +cc -O2 -Wall -Wextra -Werror -static tools/ac-usb-controller.c -o ac-usb-controller +cc -O2 -Wall -Wextra -Werror -static tools/ac-keyboard-remote.c -o ac-keyboard-remote +``` + +Install executable copies under `/mnt/tools/`. Both playground pieces start +`ac-usb-controller` when its heartbeat is missing. Slab launches the keyboard +receiver over authenticated SSH; see [AC keyboard remote](../../../slab/menubar-swift/README.md#ac-keyboard-remote) +for the Command–Option–L shortcut and per-host configuration. Helpers are +installed separately; the image builders do not install these binaries yet. + +## Pieces + +- `little-platformer`: side-scrolling physics at a fixed 120 Hz simulation + step. A jumps (hold for height), B shoves, X drops a ball, Y resets, and + RB/RT runs. Includes coyote time, buffered jumps, object collisions, + spring pads, falling/respawn, held buttons and FPS. Arrows/Space work too. +- `little-world`: top-down movement and camera scrolling; A hops, B plants, + RB/RT runs, and stars can be collected. +- `doody-blit`: four animated sprite types leave persistent stamps. C clears, + arrows change stamp count, Space pauses, and M toggles low sine-wave music. +- `fia-stars`: the “love u fia” starfield. +- `feral-file-now`: a dated October 4 news snapshot with source attribution. + +The pieces use `lib/saved-wifi.mjs` to join visible saved networks without +interrupting association/DHCP. Credentials stay in device-local +`/mnt/wifi_creds.json`; none are bundled in these pieces. Image builders and +`dev-push.sh` include the saved-WiFi and platform-physics modules. + +On the tested USB, both boot partitions hold copies of the pieces, libraries, +helpers and boot configuration. Its device-local init copies `/mnt/pieces/*.mjs` +and `/mnt/lib/*.mjs` into RAM before launching AC. The platform-physics module +is also in its initramfs overlay. These local boot overrides are separate from +the repository's stock init. If installing onto another existing image, +install the dependencies in its initramfs or equivalent persistent boot hook; +a RAM-only `/lib` upload does not survive reboot. + +## Evidence and limits + +The running device was inspected at 1080p60 with the laptop panel disabled; +HDMI captures showed 60 FPS, transparent sprites and persistent stamps. +Actual controller presses moved the top-down character and scrolled its camera; +the user confirmed operation. The platformer was inspected on the same TV. +The live native binary uses the device's earlier base plus these display/blit +changes; it is not a full current-HEAD release. USB archive integrity and +piece/helper hashes were checked on both partitions; the final boot overlays +have not been cold-boot tested. + +Checks: + +```sh +node --test lib/saved-wifi.test.mjs lib/platform-physics.test.mjs +cc -O2 -Wall -Wextra -Werror tests/framebuffer-scale.c src/framebuffer.c -o /tmp/framebuffer-scale +/tmp/framebuffer-scale +# Linux: +cc -O2 -Wall -Wextra -Werror tools/ac-usb-controller-test.c -o /tmp/controller-test +/tmp/controller-test +cc -O2 -Wall -Wextra -Werror tools/ac-keyboard-remote-test.c -o /tmp/keyboard-test +/tmp/keyboard-test +# macOS, from the repository root: +bash slab/menubar-swift/tests/ac-remote-test.sh +``` + +The device also passed a cropped-paste probe covering source bounds, alpha, +untouched pixels and no automatic full-frame clears in the blit piece. diff --git a/fedac/native/lib/platform-physics.mjs b/fedac/native/lib/platform-physics.mjs new file mode 100644 index 0000000000..1f3371175f --- /dev/null +++ b/fedac/native/lib/platform-physics.mjs @@ -0,0 +1,79 @@ +// Small fixed-step platform playground. Units are pixels and seconds. +export const STEP=1/120; +export const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); +export function body(kind,x,y,w,h){return {kind,x,y,w,h,vx:0,vy:0,grounded:false,mass:kind==='crate'?2:1,bounce:kind==='ball'?.73:0};} +export function createWorld(){ + const floors=[[0,640],[735,485],[1330,550],[1980,720]].map(([x,w])=>({x,y:480,w,h:120})); + const platforms=[[190,393,110],[365,324,105],[520,258,110],[790,374,170],[1070,312,115],[1425,391,140],[1630,310,120],[1830,244,125],[2080,364,130],[2310,286,160]].map(([x,y,w])=>({x,y,w,h:16})); + return {player:body('player',100,462,18,28),objects:[body('ball',260,300,26,26),body('ball',315,260,20,20),body('crate',430,450,28,28),body('crate',460,416,28,28),body('ball',870,310,30,30),body('crate',1110,280,28,28)],solids:[...floors,...platforms],springs:[{x:565,y:480,w:44},{x:1490,y:391,w:40},{x:2200,y:480,w:44}],coyote:0,buffer:0,jumpAge:1,time:0,landings:0,bumps:0,finish:false}; +} +export function spawnBall(s){if(s.objects.length>=24)s.objects.shift();const p=s.player,b=body('ball',p.x,p.y-50,24,24);b.vx=p.vx*.5;b.vy=-100;s.objects.push(b);} +function overlap(a,b){return a.x+a.w/2>b.x&&a.x-a.w/2b.y&&a.y-a.h/20)b.x=r.x-b.w/2;else if(b.vx<0)b.x=r.x+r.w+b.w/2;else continue;b.vx=-b.vx*b.bounce;} + const impact=b.vy;b.y+=b.vy*dt; + for(const r of solids)if(overlap(b,r)){ + if(b.vy>=0){b.y=r.y-b.h/2;b.grounded=true;b.vy=Math.abs(impact)>45?-impact*b.bounce:0;} + else{b.y=r.y+r.h+b.h/2;b.vy=-b.vy*b.bounce;} + } + b.x=clamp(b.x,b.w/2,2700-b.w/2); + if(b.x===b.w/2&&b.vx<0||b.x===2700-b.w/2&&b.vx>0)b.vx=-b.vx*b.bounce; + return impact; +} +function contact(a,b){ + if(a.kind==='ball'&&b.kind==='ball'){ + const dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy),r=(a.w+b.w)/2; + return d=radius)return null; + if(d>0){const sign=c===a?1:-1;return {nx:dx/d*sign,ny:dy/d*sign,depth:radius-d};} + } + const dx=b.x-a.x,dy=b.y-a.y,ox=(a.w+b.w)/2-Math.abs(dx),oy=(a.h+b.h)/2-Math.abs(dy); + if(ox<=0||oy<=0)return null; + return ox.5)a.grounded=true;if(c.ny<-.5)b.grounded=true; +} +export function step(s,input,dt=STEP){ + const p=s.player;s.time+=dt; + const wasGrounded=p.grounded;s.coyote=wasGrounded?.1:Math.max(0,s.coyote-dt); + s.buffer=input.jumpPressed?.12:Math.max(0,s.buffer-dt); + const target=clamp(input.axis||0,-1,1)*(input.run?300:195),accel=wasGrounded?1500:650; + if(target)p.vx+=clamp(target-p.vx,-accel*dt,accel*dt); + else p.vx*=Math.exp(-(wasGrounded?9:1.2)*dt); + if(s.buffer>0&&s.coyote>0){p.vy=-365;p.grounded=false;s.buffer=s.coyote=0;s.jumpAge=0;} + s.jumpAge+=dt; + const lowGravity=input.jumpHeld&&p.vy<0&&s.jumpAge<.22; + p.vy=Math.min(650,p.vy+(lowGravity?490:1100)*dt); + const impact=move(p,s.solids,dt); + if(p.grounded&&!wasGrounded&&impact>130)s.landings++; + for(const o of s.objects){o.vy=Math.min(650,o.vy+1100*dt);o.vx*=Math.exp(-(o.grounded?(o.kind==='ball'?.28:3):.04)*dt);move(o,s.solids,dt);} + const all=[p,...s.objects]; + for(let pass=0;pass<3;pass++){ + for(let i=0;in.d0)b.vy=0;}else if(c.y>0&&b.vy<0)b.vy=0; + if(c.x&&b.vx*c.x<0)b.vx=0; + } + } + for(const b of all)for(const spring of s.springs)if(b.grounded&&b.x+b.w/2>spring.x&&b.x-b.w/2b.y<750); + if(p.y>720){p.x=100;p.y=440;p.vx=p.vy=0;s.coyote=s.buffer=0;} + if(p.x>2600)s.finish=true; +} diff --git a/fedac/native/lib/platform-physics.test.mjs b/fedac/native/lib/platform-physics.test.mjs new file mode 100644 index 0000000000..2adf8a8e82 --- /dev/null +++ b/fedac/native/lib/platform-physics.test.mjs @@ -0,0 +1,10 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import {createWorld,step,STEP,body} from './platform-physics.mjs'; +const run=(s,n,input={})=>{for(let i=0;i{const s=createWorld();run(s,1200);assert.ok(Math.abs(s.player.y-466)<.05);assert.equal(s.player.vy,0);for(const o of s.objects)assert.ok(Number.isFinite(o.x)&&Number.isFinite(o.y));}); +test('held jump rises higher than a tap; both land',()=>{const heights=[];for(const hold of [false,true]){const s=createWorld();run(s,10);step(s,{jumpPressed:true,jumpHeld:hold});let top=s.player.y;for(let i=0;i<160;i++){step(s,{jumpHeld:hold});top=Math.min(top,s.player.y);}heights.push(top);assert.ok(s.player.grounded);}assert.ok(heights[1]{const s=createWorld();s.player.y=455;s.player.vy=180;step(s,{jumpPressed:true});run(s,10);assert.ok(s.player.vy<0);}); +test('ball bounces and push transfers momentum to a crate',()=>{const s=createWorld();s.objects=[body('ball',150,410,24,24)];run(s,40);assert.ok(s.objects[0].vy<0);const w=createWorld();w.player.x=100;w.objects=[body('crate',130,466,28,28)];run(w,40,{axis:1});assert.ok(w.objects[0].x>140);}); +test('spring launches player and falling respawns',()=>{const s=createWorld();s.player.x=585;run(s,10);assert.ok(s.player.vy<-500);s.player.y=730;step(s,{});assert.equal(s.player.x,100);assert.equal(s.player.y,440);}); +test('stress: objects stay finite, bounded in count, and simulation deterministic',()=>{const a=createWorld(),b=createWorld();for(let i=0;i<3000;i++){const input={axis:Math.sin(i/60),jumpPressed:i%85===0,jumpHeld:i%85<30,run:true,spawn:i%50===0,shove:i%43===0,facing:1};step(a,input,STEP);step(b,input,STEP);for(const o of [a.player,...a.objects])assert.ok([o.x,o.y,o.vx,o.vy].every(Number.isFinite));assert.ok(a.objects.length<=24);}assert.deepEqual(a,b);}); diff --git a/fedac/native/lib/saved-wifi.mjs b/fedac/native/lib/saved-wifi.mjs new file mode 100644 index 0000000000..d544a929b0 --- /dev/null +++ b/fedac/native/lib/saved-wifi.mjs @@ -0,0 +1,22 @@ +// Connect only to visible saved networks; let the native WPA/DHCP attempt finish. +export function savedWifi() { + let nextScan=0,scanAt=0,credentials=[],loaded=false; + const tried=new Map(); + return function step({wifi,system},now=Date.now()) { + if(!wifi||wifi.connected)return; + if(!loaded){ + try{const value=JSON.parse(system.readFile('/mnt/wifi_creds.json')||'[]');credentials=Array.isArray(value)?value.filter(c=>typeof c.ssid==='string'&&typeof c.pass==='string'):[];}catch{credentials=[];} + loaded=true; + } + // OFF=0, SCANNING=1, SCAN_DONE=2, CONNECTING=3, CONNECTED=4, FAILED=5. + if(wifi.state===1||wifi.state===3||wifi.state===4)return; + if(now>=nextScan){wifi.scan?.();scanAt=now;nextScan=now+15000;return;} + if(now-scanAt<1500)return; + const candidates=(wifi.networks||[]) + .filter(n=>credentials.some(c=>c.ssid===n.ssid)&&now-(tried.get(n.ssid)??-Infinity)>=65000) + .sort((a,b)=>(Number(credentials.find(c=>c.ssid===b.ssid)?.priority)||0)-(Number(credentials.find(c=>c.ssid===a.ssid)?.priority)||0)||b.signal-a.signal); + if(!candidates.length)return; + const cred=credentials.find(c=>c.ssid===candidates[0].ssid); + tried.set(cred.ssid,now);wifi.connect(cred.ssid,cred.pass);nextScan=now+65000; + }; +} diff --git a/fedac/native/lib/saved-wifi.test.mjs b/fedac/native/lib/saved-wifi.test.mjs new file mode 100644 index 0000000000..753bfab081 --- /dev/null +++ b/fedac/native/lib/saved-wifi.test.mjs @@ -0,0 +1,12 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {savedWifi} from './saved-wifi.mjs'; +function fixture(){ + const calls=[]; + const api={system:{readFile:()=>JSON.stringify([{ssid:'away',pass:'a'},{ssid:'home',pass:'b'}])},wifi:{state:0,connected:false,networks:[{ssid:'home',signal:-40}],scan:()=>calls.push('scan'),connect:(ssid)=>calls.push(ssid)}}; + return {step:savedWifi(),api,calls}; +} +test('joins a visible saved network instead of retrying the absent venue',()=>{const {step,api,calls}=fixture();step(api,1000);api.wifi.state=2;step(api,3000);assert.deepEqual(calls,['scan','home']);}); +test('does not cancel or restart an in-progress association or DHCP',()=>{const {step,api,calls}=fixture();step(api,1000);api.wifi.state=3;step(api,90000);assert.deepEqual(calls,['scan']);}); +test('never connects to an unknown network',()=>{const {step,api,calls}=fixture();api.wifi.networks=[{ssid:'unknown',signal:-10}];step(api,1000);api.wifi.state=2;step(api,3000);assert.deepEqual(calls,['scan']);}); +test('leaves an existing connection alone',()=>{const {step,api,calls}=fixture();api.wifi.connected=true;step(api,1000);assert.deepEqual(calls,[]);}); diff --git a/fedac/native/pieces/doody-blit.mjs b/fedac/native/pieces/doody-blit.mjs new file mode 100644 index 0000000000..cffdb0fd1c --- /dev/null +++ b/fedac/native/pieces/doody-blit.mjs @@ -0,0 +1,141 @@ +// Doody Blit, 26.10.04 +// Persistent transparent sprite stamps: arrows change count, C clears. +import { savedWifi } from '../lib/saved-wifi.mjs'; +const connectSaved = savedWifi(); +const pixels = [ + '................', + '.........##.....', + '........#h#.....', + '.......#hh#.....', + '......#hbb##....', + '.....#hhbbb#....', + '....#hhbbbbb#...', + '....#########...', + '...#hbbbbbbbb#..', + '..#hhWWbbbWWbb#.', + '..#hbWKbbbWKbb#.', + '..#hbbbbbbbbbb#.', + '.#hhbbbKKKbbbbb#', + '.#hbbbbbWbbbbbb#', + '.##############.', + '................', +]; +const palette = { '#':[65,32,35], b:[141,77,47], h:[192,122,66], W:[255,250,231], K:[38,22,32] }; +const artworks = [pixels, [ + '................','................','...###....###...','..#hhh#..#bbb#..', + '.#hhbbb##bbbbb#.','.#hbbbbbbbbbbb#.','.#bbbbbbbbbbbb#.','..#bbbbbbbbbb#..', + '...#bbbbbbbb#...','....#bbbbbb#....','.....#bbbb#.....','......#bb#......', + '.......##.......','................','................','................', +], [ + '................','.......##.......','......#hh#......','......#hb#......', + '.....#hbbb#.....','.#####hbbb#####.','..#hhbbbbbbbb#..','...#bbbbbbbb#...', + '....#bbbbbb#....','....#bbbbbb#....','...#bbbbbbbb#...','...#bbb##bbb#...', + '..#bb##..##bb#..','..###......###..','................','................', +], [ + '................','................','.....######.....','...##hhhhbb##...', + '..#hhhbbbbbbb#..','..#hhbbbbbbbb#..','.#hhbbbbbbbbbb#.','.#hbbbbbbbbbbb#.', + '.#bbWWWbbWWWbb#.','.#bbWKWbbWKWbb#.','.#bbbbbbbbbbbb#.','..#bbbbKKbbbb#..', + '..#bbbbbbbbbb#..','...###bbbb###...','......####......','................', +]]; +const palettes = [palette, + {...palette,'#':[119,30,75],b:[243,77,128],h:[255,166,183]}, + {...palette,'#':[157,95,22],b:[255,208,63],h:[255,247,178]}, + {...palette,'#':[24,73,116],b:[63,177,210],h:[150,234,238]}, +]; +const counts = [4,8,32,128,512]; +const melody = [48,52,55,52,50,53,57,53,47,50,55,50,48,43,40,43]; +let sprites=[], background, countIndex=0, paused=false, muted=false, clearRequested=false; +let frame=0, fps=0, samples=0, sampledAt=0, nextBeat=0, beat=0, hudDirty=true; +const size=72; +const hz = midi => 440*Math.pow(2,(midi-69)/12); +export function boot({system}) { system.startSSH?.(); sampledAt=Date.now(); } +export function sim(api) { + connectSaved(api); + if(!paused)frame++; + const now=Date.now(); + if(nowapi.sound.synth({attack:.003,decay:.10,duration:.12,volume:.09,...options}); + synth({type:'sine',tone:hz(melody[beat%melody.length])}); + if(beat%4===2)synth({type:'sine',tone:hz([55,53,52,50][Math.floor(beat/4)%4]),volume:.065,duration:.30,decay:.27}); + if(beat%4===0)synth({type:'sine',tone:hz([36,41,43,36][Math.floor(beat/4)%4]),volume:.16,duration:.18,decay:.16}); + if(beat%2===1)synth({type:'sine',tone:82,volume:.025,duration:.025,decay:.02}); + // A three-note rubbery doody burble every two bars. + if(beat%16===12)synth({type:'sine',tone:58,volume:.07,duration:.09,decay:.07}); + if(beat%16===13)synth({type:'sine',tone:91,volume:.12,duration:.09,decay:.07}); + if(beat%16===14)synth({type:'sine',tone:47,volume:.13,duration:.16,decay:.14}); + } + beat++; +} +function prepare(api) { + const {painting,page,wipe,ink,box,write,screen,paste}=api; + sprites=[]; + for(let kind=0;kindArray.from(row).forEach((pixel,x)=>{ + let c=pixel; + if(f===3||f===4){if(y===9&&c==='W')c='b';if(y===10&&(c==='W'||c==='K'))c='K';} + if(f===6&&y===13&&c==='W')c='K'; + if(!palettes[kind][c])return; + const wiggle=y<8 ? [0,1,1,0,-1,-1,0,0][f] : 0; + const squash=(f===2||f===6)&&y<12 ? Math.floor((12-y)/3) : 0; + ink(...palettes[kind][c]);box(4+(x+wiggle)*4,4+y*4+squash,4,4); + }));frames.push(sprite); + } + sprites.push(frames); + } + background=painting(screen.width,screen.height);page(background);wipe(20,25,42); + ink(27,34,53); + for(let y=0;y=500){fps=samples*1000/(now-sampledAt);samples=0;sampledAt=now;hudDirty=true;} + // Leave every previous stamp intact. Only an explicit C press resets them. + if(clearRequested){paste(background,0,0);clearRequested=false;hudDirty=true;} + const w=screen.width,h=screen.height,count=counts[countIndex]; + for(let i=0;i=7){paste(background,x,y,x,y,width,height);return;} + // Older runtimes lack source rectangles; restore the same checker locally. + // This touches only a sprite footprint, never clears the screen. + ink(20,25,42);box(x,y,width,height);ink(27,34,53); + for(let yy=Math.floor(y/16)*16;yy1200){page=(page+1)%pages.length;frames=0;} + const p=pages[page],w=screen.width,h=screen.height; + wipe(9,11,18); + const margin=Math.round(w*.06), titleSize=Math.max(2,Math.floor(Math.min(w/110,h/62))); + const bodySize=Math.max(1,Math.floor(Math.min((w-margin*2)/(46*6),h/140))); + const titleY=Math.round(h*.20), step=titleSize*12; + // An original moving field of 512 marks, an illustration of selection. + const spacing=Math.max(4,Math.floor(Math.min(w*.40/32,h*.70/16))); + const ox=w-margin-spacing*31,oy=Math.round(h*.12); + for(let i=0;i<512;i++){ + const pulse=(Math.sin(i*.31+frames*.02)+1)/2; + const selected=i===Math.floor(frames/10)%512; + ink(selected?249:30+Math.floor(25*pulse),selected?132:34,selected?163:57+Math.floor(25*pulse)); + box(ox+(i%32)*spacing,oy+Math.floor(i/32)*spacing,selected?3:1,selected?3:1); + } + ink(249,132,163);write('FERAL FILE / 4 OCT 2026',{x:margin,y:Math.round(h*.07),size:bodySize,font:'font_1'}); + ink(248,245,237); + p.title.forEach((text,i)=>write(text,{x:margin,y:titleY+i*step,size:titleSize,font:'font_1'})); + const bodyY=Math.max(titleY+step*2+18,Math.round(h*.56)); + ink(220,222,231); + p.body.forEach((text,i)=>write(text,{x:margin,y:bodyY+i*bodySize*14,size:bodySize,font:'font_1'})); + ink(142,151,173);write(p.source,{x:margin,y:h-margin*.65-bodySize*10,size:Math.max(1,bodySize-1),font:'font_1'}); + const navY=h-14; + for(let i=0;i ({x: (Math.random()-.5)*2, y: (Math.random()-.5)*2, z: Math.random()+.01, hue: Math.random()})); +} +export function paint({wipe, ink, box, line, write, screen}) { + t += 1/60; + const w=screen.width, h=screen.height, cx=w/2, cy=h/2; + wipe(3, 3, 15); + const scale=Math.min(w,h)*.68; + for (const s of stars) { + const prev=s.z; s.z-=.0024; + if(s.z<.025) {s.z=1; s.x=(Math.random()-.5)*2; s.y=(Math.random()-.5)*2; continue;} + const drift=Math.sin(t*.14)*.035; + const x=cx+(s.x+drift)*scale/s.z, y=cy+s.y*scale/s.z; + if(x<0||x>w||y<0||y>h) {s.z=1; s.x=(Math.random()-.5)*2; s.y=(Math.random()-.5)*2; continue;} + const glow=Math.round(80+175*(1-s.z)); + ink(s.hue>.55 ? glow : Math.round(glow*.6), Math.round(glow*.65), glow); + line(x,y,cx+(s.x+drift)*scale/prev,cy+s.y*scale/prev); + const size=s.z<.24?2:1; box(Math.round(x),Math.round(y),size,size); + } + const text='love u fia'; + const size=Math.max(2,Math.floor(Math.min(w/(text.length*6+12),h/28))); + const x=Math.round((w-text.length*6*size)/2), y=Math.round(cy-5*size+Math.sin(t*.8)*3); + ink(3,3,15,215); box(x-14,y-10,text.length*6*size+28,10*size+20); + ink(93,34,110); write(text,{x:x+2,y:y+2,size,font:'font_1'}); + ink(255,177+Math.round(Math.sin(t)*14),217); write(text,{x,y,size,font:'font_1'}); +} +export function act({event,system}) { if(event.is('keyboard:down:escape')) system.jump('prompt'); } diff --git a/fedac/native/pieces/hdmi.mjs b/fedac/native/pieces/hdmi.mjs new file mode 100644 index 0000000000..d7f6323a69 --- /dev/null +++ b/fedac/native/pieces/hdmi.mjs @@ -0,0 +1,86 @@ +// HDMI, 26.10.04 +// Sharpness, color, motion and overscan checks for an attached display. +import { savedWifi } from '../lib/saved-wifi.mjs'; +const connectSaved=savedWifi(); +export function sim(api){connectSaved(api);} +let page=0,frame=0,fps=0,fpsFrames=0,fpsAt=0; +const names=['PIXEL GRID','TYPE + EDGES','COLOR','MOTION','DISPLAY INFO']; +let info='',output=null,probe=null,requestSerial=0,notice=''; +export function boot({system}) { system.startSSH?.(); frame=0;page=0;fpsAt=Date.now();fpsFrames=0;info=system.readFile?.('/tmp/ac-hdmi-info.txt')||'Display details unavailable'; } +export function paint({wipe,ink,box,line,circle,write,screen,system}) { + frame++; + fpsFrames++; + const now=Date.now(); + if(now-fpsAt>=500){fps=fpsFrames*1000/(now-fpsAt);fpsFrames=0;fpsAt=now;} + if(frame===1||frame%30===0){ + try{output=JSON.parse(system.readFile?.('/tmp/ac-hdmi-state.json')||'null');}catch{output=null;} + try{probe=JSON.parse(system.readFile?.('/tmp/ac-hdmi-probe.json')||'null');}catch{probe=null;} + info=system.readFile?.('/tmp/ac-hdmi-info.txt')||info; + } + const w=screen.width,h=screen.height,m=Math.max(8,Math.round(w*.035)); + const size=Math.max(1,Math.floor(w/340)),head=Math.max(2,Math.floor(w/190)); + wipe(12,12,16); + ink(255,255,255);line(0,0,w-1,0);line(w-1,0,w-1,h-1);line(w-1,h-1,0,h-1);line(0,h-1,0,0); + for(const [x,y] of [[0,0],[w-9,0],[0,h-9],[w-9,h-9]]) {ink(255,60,130);box(x,y,9,9);} + const fpsText=fps ? `${fps.toFixed(1)} FPS` : '-- FPS'; + ink(110,255,170);write(fpsText,{x:w-m-fpsText.length*6*head,y:m,size:head,font:'font_1'}); + ink(255,255,255);write(names[page],{x:m,y:m,size:head,font:'font_1'}); + const scale=output?.width ? Number(Math.min(output.width/w,output.height/h).toFixed(2)) : null; + ink(185,190,205);write(output?.width ? `HDMI ${output.width}x${output.height} @ ${output.hz} Hz` : 'HDMI output: unavailable',{x:m,y:m+head*12,size,font:'font_1'}); + write(`SOURCE ${w}x${h} SCALE ${scale??'?'}x NEAREST`,{x:m,y:m+head*12+size*13,size,font:'font_1'}); + const top=m+head*12+size*29, bottom=h-m-54, ph=bottom-top; + if(page===0) { + const gap=8,pw=Math.floor((w-2*m-2*gap)/3); + for(let panel=0;panel<3;panel++) { + const x=m+panel*(pw+gap),step=panel+1; + ink(255,255,255);box(x,top,pw,ph-18); + ink(0,0,0); + for(let i=0;i{ink(...c);box(m+Math.floor(i*(w-2*m)/7),top,Math.ceil((w-2*m)/7),ph*.64);}); + for(let i=0;i<16;i++){ink(i*17,i*17,i*17);box(m+Math.floor(i*(w-2*m)/16),top+ph*.69,Math.ceil((w-2*m)/16),ph*.27);} + } else if(page===3) { + ink(32,35,43);for(let x=m;xwrite(t.slice(0,Math.floor((w-2*m)/(6*size))),{x:m,y:top+i*size*14,size,font:'font_1'})); + } + ink(185,190,205); + const modeLine=probe?.pending ? `TEST ${probe.width}x${probe.height}@${probe.hz}: ENTER keeps; otherwise reverts` : (probe ? '1: 720p60 2: 1080p60 3: 4K30 Enter: keep test' : ''); + ink(...(probe?.pending?[255,191,90]:[185,190,205]));write(modeLine,{x:m,y:h-m-39,size:1,font:'font_1'}); + write('Ctrl -: finer pixels Ctrl +: larger Ctrl 0: reset',{x:m,y:h-m-26,size:1,font:'font_1'}); + write('Arrows / tap: test F: Fia N: news Esc: prompt',{x:m,y:h-m-13,size:1,font:'font_1'}); +} +export function act({event:e,system}) { + const modes={'1':[1280,720,60],'2':[1920,1080,60],'3':[3840,2160,30]}; + if(e.is('keyboard:down')&&modes[e.key]){ + if(!probe){notice='Mode tests unavailable on this runtime';return;} + requestSerial=Math.max(requestSerial+1,Math.floor(Date.now()/1000)); + if(system.writeFile('/tmp/ac-hdmi-request',modes[e.key].join(' ')+' '+requestSerial))notice='Mode requested; waiting for HDMI'; + else notice='Could not request mode'; + return; + } + if(e.is('keyboard:down:enter')&&probe?.pending){ + if(output?.width!==probe.width||output?.height!==probe.height||output?.hz!==probe.hz){notice='Waiting for the requested output';return;} + system.writeFile('/tmp/ac-hdmi-confirm',String(probe.serial)); + const saved=system.writeFile('/mnt/hdmi-mode.txt',`${probe.width} ${probe.height} ${probe.hz} ${probe.serial}`); + notice=saved?'Mode kept and saved':'Mode kept for this session; USB save failed';return; + } + if(e.is('keyboard:down:arrowright')||e.is('touch'))page=(page+1)%names.length; + if(e.is('keyboard:down:arrowleft'))page=(page+names.length-1)%names.length; + if(e.is('keyboard:down:f'))system.jump('fia-stars'); + if(e.is('keyboard:down:n'))system.jump('feral-file-now'); + if(e.is('keyboard:down:escape'))system.jump('prompt'); +} diff --git a/fedac/native/pieces/little-platformer.mjs b/fedac/native/pieces/little-platformer.mjs new file mode 100644 index 0000000000..7c36a63982 --- /dev/null +++ b/fedac/native/pieces/little-platformer.mjs @@ -0,0 +1,90 @@ +// Little Platformer, 26.10.04 +// Controller playground: momentum, variable jumps, springs, balls and crates. +import { savedWifi } from '../lib/saved-wifi.mjs'; +import { createWorld, step, STEP, clamp } from '../lib/platform-physics.mjs'; +const connectSaved=savedWifi(),keys=new Set(); +const names=[[0x100,'UP'],[0x200,'DOWN'],[0x400,'LEFT'],[0x800,'RIGHT'],[0x10,'A'],[0x20,'B'],[0x40,'X'],[0x80,'Y'],[0x1000,'LB'],[0x2000,'RB'],[0x4000,'LS'],[0x8000,'RS'],[4,'MENU'],[8,'VIEW']]; +let world=createWorld(),pad={},held=[],previous=0,facing=1,last=0,accum=0,cx=0,cy=240; +let fps=0,frames=0,fpsAt=0,helperAt=0,pending={},keyboardPressed=new Set(),soundOn=true,lastTone=0; +let guys=[]; +function startReader(system){system.pty2?.spawn('/bin/sh',['-c','exec /mnt/tools/ac-usb-controller'],40,10);helperAt=Date.now();} +export function boot({system}){system.startSSH?.();last=fpsAt=Date.now();try{pad=JSON.parse(system.readFile('/tmp/ac-controller.json')||'{}');}catch{}if(!pad.at||Date.now()-pad.at>1500)startReader(system);} +function tone(api,hz){const t=Date.now();if(soundOn&&t-lastTone>75){lastTone=t;api.sound?.synth?.({type:'sine',tone:hz,volume:.08,duration:.16,attack:.006,decay:.15});}} +export function sim(api){ + connectSaved(api);const now=Date.now(),dt=clamp((now-last)/1000,0,.1);last=now;accum=Math.min(.1,accum+dt); + try{pad=JSON.parse(api.system.readFile('/tmp/ac-controller.json')||'{}');}catch{pad={};} + if(!pad.at||now-pad.at>1500){pad={};if(now-helperAt>5000)startReader(api.system);} + const b=pad.connected&&pad.ready?pad.buttons:0,pressed=b&~previous;previous=b; + held=names.filter(([bit])=>b&bit).map(([,name])=>name); + if(pad.lt>100)held.push('LT');if(pad.rt>100)held.push('RT');if(pad.guide)held.push('XBOX'); + let axis=(b&0x800||keys.has('arrowright')?1:0)-(b&0x400||keys.has('arrowleft')?1:0); + if(!axis&&pad.ready&&Math.abs(pad.lx)>6500)axis=clamp(pad.lx/32767,-1,1); + if(axis)facing=axis<0?-1:1; + if(pressed&0x80||keyboardPressed.has('r')){world=createWorld();accum=0;cx=0;cy=240;} + pending.jumpPressed ||= !!(pressed&0x10)||keyboardPressed.has('space')||keyboardPressed.has('arrowup'); + pending.shove ||= !!(pressed&0x20)||keyboardPressed.has('b'); + pending.spawn ||= !!(pressed&0x40)||keyboardPressed.has('x'); + keyboardPressed.clear(); + const input={axis,jumpHeld:!!(b&0x10)||keys.has('space')||keys.has('arrowup'),run:!!(b&0x2000)||pad.rt>100||keys.has('shift'),facing}; + const landed=world.landings,bumps=world.bumps; + while(accum>=STEP){ + if(pending.jumpPressed)tone(api,147); + step(world,{...input,...pending},STEP);pending={};accum-=STEP; + } + if(world.bumps>bumps)tone(api,110);else if(world.landings>landed)tone(api,65); + const h=api.screen.height-54,w=api.screen.width; + const tx=clamp(world.player.x-w*.38,0,Math.max(0,2700-w)); + const ty=clamp(world.player.y-h*.65,0,Math.max(0,600-h)); + cx+=(tx-cx)*(1-Math.exp(-9*dt));cy+=(ty-cy)*(1-Math.exp(-6*dt)); +} +function prepare({painting,page,ink,box}){ + for(let f=0;f<4;f++){ + const p=painting(26,34);page(p); + ink(47,34,64);box(6,5,14,13);box(4,9,18,8);box(7,18,12,11); + ink(249,179,189);box(7,7,12,10);box(5,10,16,5); + ink(242,83,140);box(7,18,12,10);box(4,19,3,7);box(19,19,3,7); + ink(255,217,124);box(5,4,16,3);box(9,1,10,4); + ink(49,38,62);box(8,27,4,f%2?4:6);box(15,27,4,f%2?6:4); + guys.push(p); + }page(); +} +export function paint(api){ + const {wipe,ink,box,line,circle,write,paste,screen}=api;if(!guys.length)prepare(api); + const w=screen.width,h=screen.height,vh=h-54,X=Math.round(cx),Y=Math.round(cy),p=world.player; + wipe(26,27,52); + // Distant hills and stars move more slowly than the playable foreground. + for(let i=0;i<65;i++){const sx=((i*137-X*.18)%(w+60)+w+60)%(w+60)-30,sy=((i*i*19)%240)-Y*.08;ink(i%3===0?185:98,130,175);box(sx,sy,i%4===0?2:1,2);} + ink(239,207,153);circle(w-105-X*.05,45-Y*.04,18,true);ink(26,27,52);circle(w-97-X*.05,39-Y*.04,17,true); + for(let i=-1;i<14;i++){const bx=i*90-(X*.32%90),by=vh-58-((i+40)%3)*24;ink(40,45,72);box(bx,by,80,vh-by);ink(44,53,79);box(bx+12,by-16,56,22);} + for(const r of world.solids){ + const x=r.x-X,y=r.y-Y;if(x+r.w<0||x>w||y>vh||y+r.h<0)continue; + ink(91,66,89);box(x,y,r.w,r.h);ink(113,77,103);box(x,y+7,r.w,8);ink(167,137,126);box(x,y,r.w,4); + ink(68,52,76);if(r.h>23)for(let bx=Math.max(r.x,Math.floor(X/32)*32);bxw||y-o.h>vh)continue; + if(o.kind==='ball'){ink(219,117,145);circle(x,y,o.w/2,true);ink(255,193,161);circle(x-3,y-4,o.w/2-4,true);ink(255,232,180);box(x-5,y-7,4,3);} + else{const left=x-o.w/2,top=y-o.h/2;ink(83,54,66);box(left,top,o.w,o.h);ink(190,128,84);box(left+2,top+2,o.w-4,o.h-4);ink(237,184,121);box(left+3,top+3,o.w-6,3);line(left+4,top+6,left+o.w-5,top+o.h-5);line(left+o.w-5,top+6,left+4,top+o.h-5);} + } + const px=Math.round(p.x-X),py=Math.round(p.y-Y),f=p.grounded&&Math.abs(p.vx)>15?Math.floor(world.time*12)%4:0; + paste(guys[f],px-13,py-19); + ink(43,35,58);box(px+(facing>0?2:-7),py-8,2,3);box(px+(facing>0?7:-2),py-8,2,3); + if(!p.grounded){ink(255,191,151);box(px-12,py+1,3,3);box(px+10,py+1,3,3);} + ink(229,196,151);write('LOVE U FIA',{x:45-X,y:420-Y,size:1,font:'font_1'}); + if(2600-X=500){fps=frames*1000/(now-fpsAt);frames=0;fpsAt=now;} + ink(18,23,39);box(0,vh,w,54); + ink(250,212,150);write(`HELD: ${held.length?held.join(' '):'-'}`,{x:10,y:vh+8,size:1,font:'font_1'}); + ink(137,233,183);write(`${fps.toFixed(0)} FPS ${Math.abs(p.vx).toFixed(0)} PX/S`,{x:w-138,y:vh+8,size:1,font:'font_1'}); + ink(203,208,221);write('A: jump (hold higher) B: shove X: ball Y: reset',{x:10,y:vh+25,size:1,font:'font_1'}); + ink(147,166,187);write('Pad/stick: move RB/RT: run M: sound Green pads: spring',{x:10,y:vh+40,size:1,font:'font_1'}); + if(!pad.ready||!pad.connected||now-pad.at>1500){ink(255,190,140);write('CONTROLLER DISCONNECTED - ARROWS + SPACE WORK',{x:10,y:10,size:1,font:'font_1'});} +} +export function act({event:e,system}){ + if(e.is('keyboard:down')){if(!keys.has(e.key))keyboardPressed.add(e.key);keys.add(e.key);} + if(e.is('keyboard:up'))keys.delete(e.key); + if(e.is('keyboard:down:m'))soundOn=!soundOn; + if(e.is('keyboard:down:escape'))system.jump('prompt'); +} diff --git a/fedac/native/pieces/little-world.mjs b/fedac/native/pieces/little-world.mjs new file mode 100644 index 0000000000..2d81f3db62 --- /dev/null +++ b/fedac/native/pieces/little-world.mjs @@ -0,0 +1,128 @@ +// Little World, 26.10.04 +// Walk an 8BitDo-controlled little guy through a scrolling pixel meadow. +import { savedWifi } from '../lib/saved-wifi.mjs'; +const connectSaved=savedWifi(); +const TILE=32, COLS=40, ROWS=32, WIDTH=COLS*TILE, HEIGHT=ROWS*TILE; +const bindings=[[0x100,'UP'],[0x200,'DOWN'],[0x400,'LEFT'],[0x800,'RIGHT'],[0x10,'A'],[0x20,'B'],[0x40,'X'],[0x80,'Y'],[0x1000,'LB'],[0x2000,'RB'],[0x4000,'LS'],[0x8000,'RS'],[4,'MENU'],[8,'VIEW']]; +const keys=new Set(); +let map,guys=[],pad={},held=[],x=336,y=336,cameraX=0,cameraY=0,walk=0,facing=1; +let last=0,now=0,previousButtons=0,jump=0,flowers=[],stars=[],collected=0,music=true,nextNote=0,note=0; +let fps=0,fpsAt=0,frames=0,helperAt=0; +const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); +const hash=(a,b)=>{let n=Math.imul(a+71,374761393)^Math.imul(b+137,668265263);n=Math.imul(n^(n>>>13),1274126177);return (n^(n>>>16))>>>0;}; +function terrain(tx,ty){ + if(tx<1||ty<1||tx>=COLS-1||ty>=ROWS-1)return 'tree'; + if(((tx-25)/5)**2+((ty-12)/4)**2<1)return 'water'; + if(tx===10||ty===10||ty===22&&tx>=10&&tx<=31)return 'path'; + if(tx>=5&&tx<=8&&ty>=5&&ty<=7)return 'house'; + return hash(tx,ty)%17===0?'tree':'grass'; +} +function open(px,py){const t=terrain(Math.floor(px/TILE),Math.floor(py/TILE));return t!=='water'&&t!=='tree'&&t!=='house';} +function startReader(system){ + // One detached reader per machine; its flock makes re-entry harmless. + system.pty2?.spawn('/bin/sh',['-c','exec /mnt/tools/ac-usb-controller'],40,10); + helperAt=Date.now(); +} +export function boot({system}){ + system.startSSH?.();last=fpsAt=Date.now(); + try{pad=JSON.parse(system.readFile('/tmp/ac-controller.json')||'{}');}catch{} + if(!pad.at||Date.now()-pad.at>1500)startReader(system); + for(let ty=3;ty5000)startReader(api.system);} + const active=!!(fresh&&pad.connected&&pad.ready),b=active?pad.buttons:0; + held=active?bindings.filter(([bit])=>b&bit).map(([,name])=>name):[]; + if(active&&pad.lt>100)held.push('LT');if(active&&pad.rt>100)held.push('RT');if(active&&pad.guide)held.push('XBOX'); + const axis=v=>Math.abs(v||0)<6500?0:clamp(v/32767,-1,1); + let dx=(!!(b&0x800)||keys.has('arrowright')?1:0)-(!!(b&0x400)||keys.has('arrowleft')?1:0); + let dy=(!!(b&0x200)||keys.has('arrowdown')?1:0)-(!!(b&0x100)||keys.has('arrowup')?1:0); + if(!dx&&active)dx=axis(pad.lx);if(!dy&&active)dy=axis(pad.ly); + const length=Math.hypot(dx,dy);if(length>1){dx/=length;dy/=length;} + const speed=active&&(pad.rt>100||b&0x2000)?155:90; + const nx=clamp(x+dx*speed*dt,16,WIDTH-16),ny=clamp(y+dy*speed*dt,16,HEIGHT-16); + if(open(nx-6,y)&&open(nx+6,y))x=nx; + if(open(x,ny-4)&&open(x,ny+4))y=ny; + if(length){walk+=dt*10;if(dx)facing=dx<0?-1:1;} + const pressed=b&~previousButtons;previousButtons=b; + if(pressed&0x10){jump=.45;if(music)tone(api,164,.07);} + if(pressed&0x20){flowers.push({x:Math.round(x),y:Math.round(y)});if(flowers.length>256)flowers.shift();if(music)tone(api,130,.06);} + jump=Math.max(0,jump-dt); + for(const s of stars)if(!s.taken&&Math.hypot(x-s.x,y-s.y)<16){s.taken=true;collected++;if(music)tone(api,220,.06,.2);} + cameraX=clamp(Math.round(x-api.screen.width/2),0,WIDTH-api.screen.width); + cameraY=clamp(Math.round(y-(api.screen.height-72)/2),0,HEIGHT-(api.screen.height-72)); + if(music&&now>=nextNote){nextNote=now+500;tone(api,[65.41,82.41,98,73.42,65.41,98,82.41,73.42][note++%8],.045,.38);} +} +function prepare(api){ + const {painting,page,wipe,ink,box,line,write}=api; + map=painting(WIDTH,HEIGHT);page(map);wipe(67,117,79); + for(let ty=0;ty>>8)%25,2,4);box(px+(n>>>4)%25,py+(n>>>12)%25,4,2); + if(n%7===0){ink(243,200,129);box(px+18,py+15,3,3);} + } + } + // One little cottage and a sign are landmarks while the camera scrolls. + ink(109,58,67);box(154,145,140,20);box(163,133,122,12);box(175,123,98,10); + ink(70,57,55);box(210,217,26,39);ink(137,201,201);box(169,190,25,23);box(253,190,25,23); + ink(97,70,55);box(355,302,4,25);ink(231,208,156);box(347,291,102,20); + ink(74,62,51);write('LOVE U FIA',{x:351,y:297,size:1,font:'font_1'}); + // The cached RGBA frames leave untouched pixels transparent. + for(let f=0;f<4;f++){ + const p=painting(24,32);page(p); + ink(51,40,64);box(5,5,14,12);box(3,9,18,7);box(7,17,10,9); + ink(247,176,180);box(6,7,12,10);box(4,10,16,5); + ink(241,89,140);box(7,17,10,9);box(4,18,3,6);box(17,18,3,6); + ink(42,43,66);box(8,11,2,3);box(15,11,2,3); + ink(255,226,197);box(10,15,4,1); + ink(48,42,65);box(7,25,4,f%2?3:5);box(13,25,4,f%2?5:3); + ink(255,213,132);box(5,4,14,3);box(8,1,9,4); + guys.push(p); + } + page(); +} +export function paint(api){ + if(!map)prepare(api); + const {paste,ink,box,write,screen}=api,w=screen.width,h=screen.height; + paste(map,0,0,cameraX,cameraY,w,h-72); + for(const s of stars)if(!s.taken){ + const sx=Math.round(s.x-cameraX),sy=Math.round(s.y-cameraY); + if(sx<0||sy<0||sx>w||sy>h-72)continue; + ink(255,224,125);box(sx-2,sy-6,4,12);box(sx-6,sy-2,12,4);ink(255,248,208);box(sx-1,sy-2,2,3); + } + for(const f of flowers){const fx=f.x-cameraX,fy=f.y-cameraY;if(fx<0||fy<0||fx>w||fy>h-72)continue;ink(32,78,57);box(fx,fy-5,2,8);ink(248,132,167);box(fx-3,fy-9,8,6);ink(255,220,118);box(fx,fy-7,2,2);} + const px=Math.round(x-cameraX),py=Math.round(y-cameraY),lift=Math.round(Math.sin(jump/.45*Math.PI)*18); + ink(42,86,65);box(px-10,py-2,20,5); + paste(guys[Math.floor(walk)%4],px-12,py-29-lift); + ink(247,176,180);box(px+(facing<0?-10:8),py-19-lift,2,3); + const t=Date.now();frames++;if(t-fpsAt>=500){fps=frames*1000/(t-fpsAt);frames=0;fpsAt=t;} + ink(22,35,43);box(0,h-72,w,72); + const active=pad.connected&&pad.ready&&t-pad.at<1500; + ink(...(active?[140,237,176]:[255,192,128])); + write(active?'8BITDO USB':pad.connected?'WAITING FOR INPUT':'CONTROLLER DISCONNECTED',{x:12,y:h-64,size:1,font:'font_1'}); + ink(212,228,223);write(`${fps.toFixed(0)} FPS ${collected} STARS ${Math.round(x)},${Math.round(y)}`,{x:w-240,y:h-64,size:1,font:'font_1'}); + ink(255,222,137);write(`HELD: ${held.length?held.join(' '):'-'}`,{x:12,y:h-47,size:held.join(' ').length>39?1:2,font:'font_1'}); + ink(183,205,198);write('Move: pad/stick A: hop B: plant RB/RT: run M: music',{x:12,y:h-16,size:1,font:'font_1'}); +} +export function act({event:e,system}){ + if(e.is('keyboard:down'))keys.add(e.key); + if(e.is('keyboard:up'))keys.delete(e.key); + if(e.is('keyboard:down:m'))music=!music; + if(e.is('keyboard:down:escape'))system.jump('prompt'); +} diff --git a/fedac/native/scripts/build-and-flash-initramfs.sh b/fedac/native/scripts/build-and-flash-initramfs.sh index 00c34f1273..eb5dc7fdb0 100755 --- a/fedac/native/scripts/build-and-flash-initramfs.sh +++ b/fedac/native/scripts/build-and-flash-initramfs.sh @@ -269,6 +269,9 @@ fi # Copy shared JS libraries needed by pieces (pure JS, no browser deps) AC_LIB_DIR="${NATIVE_DIR}/../../system/public/aesthetic.computer/lib" mkdir -p "${INITRAMFS_DIR}/lib" +for lib_file in saved-wifi.mjs platform-physics.mjs; do + cp "${NATIVE_DIR}/lib/${lib_file}" "${INITRAMFS_DIR}/lib/" +done for lib_file in melody-parser.mjs notepat-convert.mjs note-colors.mjs percussion.mjs num.mjs; do if [ -f "${AC_LIB_DIR}/${lib_file}" ]; then cp "${AC_LIB_DIR}/${lib_file}" "${INITRAMFS_DIR}/lib/" diff --git a/fedac/native/scripts/build-and-flash.sh b/fedac/native/scripts/build-and-flash.sh index 7bef38f4f9..c6539b8e8e 100755 --- a/fedac/native/scripts/build-and-flash.sh +++ b/fedac/native/scripts/build-and-flash.sh @@ -199,6 +199,9 @@ cp "${NATIVE_DIR}/lib/spatial-rehearsal.mjs" "${INITRAMFS_DIR}/lib/" # Aesel's Claude bridge translator (pieces/aesel.mjs imports it from /lib). cp "${NATIVE_DIR}/lib/aesel-bridge.mjs" "${INITRAMFS_DIR}/lib/" cp "${NATIVE_DIR}/lib/aesel-ac.mjs" "${INITRAMFS_DIR}/lib/" +for lib_file in saved-wifi.mjs platform-physics.mjs; do + cp "${NATIVE_DIR}/lib/${lib_file}" "${INITRAMFS_DIR}/lib/" +done cp "${NATIVE_DIR}/scores/"*.nsscore "${INITRAMFS_DIR}/scores/" cp "${NATIVE_DIR}/scores/notespatial-native.nsscore" "${INITRAMFS_DIR}/pieces/spatial-rehearsal.nsscore" diff --git a/fedac/native/scripts/dev-push.sh b/fedac/native/scripts/dev-push.sh index c3d84203e9..1f9ed6bdd0 100755 --- a/fedac/native/scripts/dev-push.sh +++ b/fedac/native/scripts/dev-push.sh @@ -48,6 +48,9 @@ PIECE_FILE="$NATIVE/pieces/$PIECE.mjs" # Same list as docker-build.sh §2p — modules pieces import as ../lib/X.mjs. LIB_FILES=() +for libmjs in saved-wifi.mjs platform-physics.mjs; do + LIB_FILES+=("$NATIVE/lib/$libmjs") +done for libmjs in melody-parser.mjs notepat-convert.mjs note-colors.mjs num.mjs percussion.mjs synth.mjs nom.mjs; do [ -f "$WEB_LIB/$libmjs" ] && LIB_FILES+=("$WEB_LIB/$libmjs") done diff --git a/fedac/native/src/ac-native.c b/fedac/native/src/ac-native.c index 20214f215d..0147a672d9 100644 --- a/fedac/native/src/ac-native.c +++ b/fedac/native/src/ac-native.c @@ -3932,6 +3932,13 @@ int main(int argc, char *argv[]) { } } } + const char *scale_env = getenv("AC_PIXEL_SCALE"); + if (scale_env) { + char *end; + long requested = strtol(scale_env, &end, 10); + if (*scale_env && !*end && requested >= 1 && requested <= 16) + pixel_scale = (int)requested; + } ac_log("pixel_scale=%d (display %dx%d -> screen %dx%d)\n", pixel_scale, display->width, display->height, display->width / pixel_scale, display->height / pixel_scale); diff --git a/fedac/native/src/drm-display.c b/fedac/native/src/drm-display.c index cf9d765dd5..f570d8b04f 100644 --- a/fedac/native/src/drm-display.c +++ b/fedac/native/src/drm-display.c @@ -432,10 +432,24 @@ ACDisplay *drm_init(void) { return fbdev_init(); } - // Prefer internal panel (eDP/LVDS) over external (HDMI/DP) to avoid slow EDID probes + // Explicit closed-lid TV use: make HDMI primary, so its aspect and pixel + // grid define the piece instead of mirroring the internal panel's canvas. + const char *hdmi_env = getenv("AC_HDMI_ONLY"); + int hdmi_only = hdmi_env && strcmp(hdmi_env, "1") == 0; drmModeConnector *conn = NULL; + if (hdmi_only) { + for (int i = 0; i < res->count_connectors; i++) { + drmModeConnector *c = drmModeGetConnector(d->fd, res->connectors[i]); + if (!c) continue; + if (c->connection == DRM_MODE_CONNECTED && c->count_modes > 0 && + (c->connector_type == DRM_MODE_CONNECTOR_HDMIA || + c->connector_type == DRM_MODE_CONNECTOR_HDMIB)) { conn = c; break; } + drmModeFreeConnector(c); + } + if (!conn) hdmi_only = 0; // Unplugged TV: keep the laptop usable. + } // First pass: look for internal panel - for (int i = 0; i < res->count_connectors; i++) { + for (int i = 0; !conn && i < res->count_connectors; i++) { drmModeConnector *c = drmModeGetConnector(d->fd, res->connectors[i]); if (!c) continue; if (c->connection == DRM_MODE_CONNECTED && c->count_modes > 0 && @@ -469,6 +483,15 @@ ACDisplay *drm_init(void) { d->connector_id = conn->connector_id; d->mode = conn->modes[0]; + if (hdmi_only) { + for (int i = 0; i < conn->count_modes; i++) { + drmModeModeInfo *m = &conn->modes[i]; + if (m->hdisplay == 1920 && m->vdisplay == 1080 && m->vrefresh == 60 && + !(m->flags & (DRM_MODE_FLAG_INTERLACE | DRM_MODE_FLAG_DBLSCAN))) { + d->mode = *m; break; + } + } + } d->width = d->mode.hdisplay; d->height = d->mode.vdisplay; fprintf(stderr, "[drm] Display: %dx%d @ %dHz\n", @@ -523,6 +546,18 @@ ACDisplay *drm_init(void) { return fbdev_init(); } + if (hdmi_only) { + // Disable other scanouts only after HDMI successfully mode-sets. + drmModeRes *outputs = drmModeGetResources(d->fd); + if (outputs) { + for (int i = 0; i < outputs->count_crtcs; i++) + if (outputs->crtcs[i] != d->crtc_id) + drmModeSetCrtc(d->fd, outputs->crtcs[i], 0, 0, 0, NULL, 0, NULL); + drmModeFreeResources(outputs); + } + ac_log("[drm] HDMI-only primary: %dx%d@%d; other CRTCs disabled\n", + d->width, d->height, d->mode.vrefresh); + } fprintf(stderr, "[drm] Ready\n"); return d; } @@ -696,6 +731,8 @@ static int drm_pick_secondary_mode(drmModeConnector *conn, int screen_w, int scr } ACSecondaryDisplay *drm_init_secondary(ACDisplay *primary, int screen_w, int screen_h) { + const char *hdmi_only = getenv("AC_HDMI_ONLY"); + if (hdmi_only && strcmp(hdmi_only, "1") == 0) return NULL; if (!primary || primary->is_fbdev || primary->fd < 0) return NULL; if (primary->is_sdl) return NULL; @@ -851,6 +888,8 @@ ACSecondaryDisplay *drm_init_secondary(ACDisplay *primary, int screen_w, int scr } int drm_secondary_is_connected(ACDisplay *primary) { + const char *hdmi_only = getenv("AC_HDMI_ONLY"); + if (hdmi_only && strcmp(hdmi_only, "1") == 0) return 0; if (!primary || primary->is_fbdev || primary->fd < 0) return 0; drmModeRes *res = drmModeGetResources(primary->fd); if (!res) return 0; diff --git a/fedac/native/src/js-bindings.c b/fedac/native/src/js-bindings.c index 0a6786f2c4..2879d58a64 100644 --- a/fedac/native/src/js-bindings.c +++ b/fedac/native/src/js-bindings.c @@ -2935,7 +2935,9 @@ static JSValue js_page(JSContext *ctx, JSValueConst this_val, int argc, JSValueC return proxy; } -// paste(painting, dx?, dy?) — alpha-composite a painting onto the current render target +// paste(painting, dx?, dy?, sx?, sy?, width?, height?) — alpha-composite a +// painting or source rectangle. Cropped copies restore sprite backgrounds +// without clearing or copying the whole screen. static JSValue js_paste(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { (void)this_val; if (!current_rt || !current_rt->graph) return JS_UNDEFINED; @@ -2948,7 +2950,25 @@ static JSValue js_paste(JSContext *ctx, JSValueConst this_val, int argc, JSValue if (argc >= 2) JS_ToInt32(ctx, &dx, argv[1]); if (argc >= 3) JS_ToInt32(ctx, &dy, argv[2]); - graph_paste(current_rt->graph, src, dx, dy); + if (argc >= 7) { + int sx, sy, width, height; + if (JS_ToInt32(ctx, &sx, argv[3]) || JS_ToInt32(ctx, &sy, argv[4]) || + JS_ToInt32(ctx, &width, argv[5]) || JS_ToInt32(ctx, &height, argv[6])) + return JS_EXCEPTION; + // Reject out-of-bounds source rectangles; do not form a pointer until + // subtraction-based bounds checks have ruled out overflow. + if (sx < 0 || sy < 0 || width < 1 || height < 1 || + sx >= src->width || sy >= src->height || + width > src->width - sx || height > src->height - sy) + return JS_UNDEFINED; + ACFramebuffer crop = { + .pixels = src->pixels + (size_t)sy * src->stride + sx, + .width = width, .height = height, .stride = src->stride + }; + graph_paste(current_rt->graph, &crop, dx, dy); + } else { + graph_paste(current_rt->graph, src, dx, dy); + } return JS_UNDEFINED; } @@ -8498,7 +8518,7 @@ static JSValue build_api(JSContext *ctx, ACRuntime *rt, const char *phase) { JS_SetPropertyStr(ctx, api, "painting", JS_NewCFunction(ctx, js_painting, "painting", 3)); // paste, page (real implementations), layer, sharpen (stubs) - JS_SetPropertyStr(ctx, api, "paste", JS_NewCFunction(ctx, js_paste, "paste", 3)); + JS_SetPropertyStr(ctx, api, "paste", JS_NewCFunction(ctx, js_paste, "paste", 7)); JS_SetPropertyStr(ctx, api, "page", JS_NewCFunction(ctx, js_page, "page", 1)); JS_SetPropertyStr(ctx, api, "layer", JS_NewCFunction(ctx, js_noop, "layer", 1)); JS_SetPropertyStr(ctx, api, "sharpen", JS_NewCFunction(ctx, js_noop, "sharpen", 1)); diff --git a/fedac/native/tools/ac-keyboard-remote-test.c b/fedac/native/tools/ac-keyboard-remote-test.c new file mode 100644 index 0000000000..eec7cb85e9 --- /dev/null +++ b/fedac/native/tools/ac-keyboard-remote-test.c @@ -0,0 +1,52 @@ +// Compile on Linux: gcc -Wall -Wextra -Werror ac-keyboard-remote-test.c -o /tmp/ac-remote-test +#define ioctl test_ioctl +#define main receiver_main +#include "ac-keyboard-remote.c" +#undef main +#undef ioctl +#include +#include + +static unsigned physical_key; +int test_ioctl(int fd, unsigned long request, ...) { + (void)fd; + va_list args; va_start(args, request); + unsigned char *bits = va_arg(args, unsigned char *); va_end(args); + if (physical_key) bits[physical_key / 8] |= 1u << (physical_key % 8); + return 0; +} +static void expect(int fd, unsigned code, int value) { + struct input_event events[2]; + assert(read(fd, events, sizeof(events)) == sizeof(events)); + assert(events[0].type == EV_KEY && events[0].code == code && events[0].value == value); + assert(events[1].type == EV_SYN && events[1].code == SYN_REPORT); +} +static void empty(int fd) { + struct input_event event; + assert(read(fd, &event, sizeof(event)) == -1 && errno == EAGAIN); +} +int main(void) { + int events[2]; assert(pipe(events) == 0); + keyboard = events[1]; assert(fcntl(events[0], F_SETFL, O_NONBLOCK) == 0); + assert(command("K 30 1") == 0); expect(events[0], KEY_A, 1); + assert(command("K 30 2") == 0); expect(events[0], KEY_A, 2); + assert(command("K 30 0") == 0); expect(events[0], KEY_A, 0); + assert(command("K 30 0") == 0); empty(events[0]); + // A local physical hold must not become owned/released by the remote. + physical_key = KEY_LEFTSHIFT; + assert(command("K 42 1") == 0); assert(command("R") == 0); empty(events[0]); + physical_key = 0; + assert(command("K 42 1") == 0); expect(events[0], KEY_LEFTSHIFT, 1); + assert(command("K 30 1") == 0); expect(events[0], KEY_A, 1); + assert(command("R") == 0); expect(events[0], KEY_A, 0); expect(events[0], KEY_LEFTSHIFT, 0); + // Disconnect uses the same release path; repeats without an owned hold vanish. + assert(command("K 30 2") == 0); empty(events[0]); + assert(command("K 30 1") == 0); expect(events[0], KEY_A, 1); + assert(release() == 0); expect(events[0], KEY_A, 0); + const char *bad[] = {"K 116 1", "K 65535 1", "K -1 1", "K 30 3", "K 30 -1", "K 30 1 extra", "R extra", "garbage", "P bad nonce"}; + for (unsigned i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) assert(command(bad[i]) == -1); + empty(events[0]); + close(events[0]); close(events[1]); + puts("PASS: key lifecycle, repeats, physical holds, release, invalid commands"); + return 0; +} diff --git a/fedac/native/tools/ac-keyboard-remote.c b/fedac/native/tools/ac-keyboard-remote.c new file mode 100644 index 0000000000..46f5edab3a --- /dev/null +++ b/fedac/native/tools/ac-keyboard-remote.c @@ -0,0 +1,125 @@ +// Keyboard input over an authenticated SSH stdin stream; no listening socket. +// K <0=up|1=down|2=repeat>, R=release, P =heartbeat. +// Requires an existing evdev keyboard. Releases owned keys on EOF or timeout. +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int keyboard = -1; +static unsigned char held[KEY_MAX + 1]; +static volatile sig_atomic_t stopping; +static void stop(int sig) { (void)sig; stopping = 1; } +static int bit(const unsigned char *bits, unsigned n) { + return !!(bits[n / 8] & (1u << (n % 8))); +} +static int allowed(unsigned code) { + return (code >= KEY_ESC && code <= KEY_KPDOT) || + (code >= KEY_102ND && code <= KEY_DELETE) || + (code >= KEY_MUTE && code <= KEY_VOLUMEUP) || + code == KEY_KPEQUAL || code == KEY_PAUSE || code == KEY_KPCOMMA || + code == KEY_LEFTMETA || code == KEY_RIGHTMETA || + code == KEY_COMPOSE || (code >= KEY_F13 && code <= KEY_F24); +} +static int emit(unsigned code, int value) { + struct input_event events[2] = { + {.type = EV_KEY, .code = (unsigned short)code, .value = value}, + {.type = EV_SYN, .code = SYN_REPORT, .value = 0} + }; + ssize_t n; + do { n = write(keyboard, events, sizeof(events)); } while (n < 0 && errno == EINTR); + return n == sizeof(events) ? 0 : -1; +} +static int release(void) { + int result = 0; + for (unsigned code = 1; code <= KEY_MAX; code++) { + if (held[code] && emit(code, 0)) result = -1; + held[code] = 0; + } + return result; +} +static int open_keyboard(void) { + int fallback = -1; + for (int i = 0; i < 64; i++) { + char path[64], name[256] = {0}; + snprintf(path, sizeof(path), "/dev/input/event%d", i); + int fd = open(path, O_RDWR | O_CLOEXEC); + if (fd < 0) continue; + unsigned char bits[(KEY_MAX + 8) / 8] = {0}; + if (ioctl(fd, EVIOCGBIT(EV_KEY, sizeof(bits)), bits) < 0 || + !bit(bits, KEY_A) || !bit(bits, KEY_ENTER) || !bit(bits, KEY_LEFTSHIFT)) { + close(fd); continue; + } + ioctl(fd, EVIOCGNAME(sizeof(name)), name); + if (strstr(name, "AT Translated")) { + if (fallback >= 0) close(fallback); + return fd; + } + if (fallback < 0) fallback = fd; else close(fd); + } + return fallback; +} +static int command(const char *line) { + if (!strcmp(line, "R")) return release(); + char nonce[65], extra; + if (sscanf(line, "P %64[A-Za-z0-9._-] %c", nonce, &extra) == 1) { + printf("P %s\n", nonce); return fflush(stdout); + } + unsigned code; int value; + if (sscanf(line, "K %u %d %c", &code, &value, &extra) != 2 || + !allowed(code) || value < 0 || value > 2) return -1; + if (value == 1 && !held[code]) { + unsigned char physical[(KEY_MAX + 8) / 8] = {0}; + if (ioctl(keyboard, EVIOCGKEY(sizeof(physical)), physical) < 0) return -1; + // Do not take ownership of a key already held on the physical laptop. + if (bit(physical, code)) return 0; + if (emit(code, 1)) return -1; + held[code] = 1; + } else if (held[code]) { + if (emit(code, value == 0 ? 0 : 2)) return -1; + if (!value) held[code] = 0; + } + return 0; +} +int main(void) { + int lock = open("/tmp/ac-keyboard-remote.lock", O_CREAT | O_RDWR | O_CLOEXEC, 0600); + if (lock < 0 || flock(lock, LOCK_EX | LOCK_NB)) { + fputs("Remote keyboard already in use\n", stderr); return 1; + } + keyboard = open_keyboard(); + if (keyboard < 0) { fputs("No writable evdev keyboard\n", stderr); return 1; } + struct sigaction action = {.sa_handler = stop}; + sigemptyset(&action.sa_mask); + sigaction(SIGTERM, &action, NULL); sigaction(SIGINT, &action, NULL); + sigaction(SIGHUP, &action, NULL); signal(SIGPIPE, SIG_IGN); + puts("READY"); fflush(stdout); + char line[128], chunk[512]; size_t used = 0; int result = 0; + while (!stopping) { + struct pollfd input = {.fd = STDIN_FILENO, .events = POLLIN}; + int ready = poll(&input, 1, 3500); + if (ready < 0 && errno == EINTR) continue; + if (ready <= 0) break; + ssize_t n = read(STDIN_FILENO, chunk, sizeof(chunk)); + if (n <= 0) break; + for (ssize_t i = 0; i < n; i++) { + if (chunk[i] == '\n') { + line[used] = 0; + if (command(line)) { result = 1; stopping = 1; break; } + used = 0; + } else if (used + 1 < sizeof(line) && chunk[i] >= 32 && chunk[i] <= 126) { + line[used++] = chunk[i]; + } else { result = 1; stopping = 1; break; } + } + } + if (release()) result = 1; + close(keyboard); close(lock); + return result; +} diff --git a/fedac/native/tools/ac-usb-controller-test.c b/fedac/native/tools/ac-usb-controller-test.c new file mode 100644 index 0000000000..f82ca91984 --- /dev/null +++ b/fedac/native/tools/ac-usb-controller-test.c @@ -0,0 +1,22 @@ +// Linux: gcc -Wall -Wextra -Werror ac-usb-controller-test.c -o /tmp/controller-test +#define main controller_main +#include "ac-usb-controller.c" +#undef main +#include +int main(void) { + unsigned char packet[36]={0x20,0,1,32,0x10,0x09,0xff,3,0,2,0,0x80,0xff,0x7f,0xff,0x7f,0,0x80}; + reset();assert(decode(packet,sizeof(packet))==1); + assert(ready&&reports==1&&buttons==0x0910&<==1023&&rt==512); + assert(lx==-32768&&ly==-32767&&rx==32767&&ry==32768); + // Invalid/truncated reports must never clear or overwrite a held control. + for(int n=0;n<36;n++)assert(decode(packet,n)==0); + assert(buttons==0x0910&&reports==1); + packet[3]=0;assert(decode(packet,sizeof(packet))==0); + unsigned char xbox[]={7,0x30,2,1,1};assert(decode(xbox,5)&&guide==1); + xbox[4]=0;assert(decode(xbox,5)&&guide==0); + memset(packet+4,0,sizeof(packet)-4);packet[3]=32; + assert(decode(packet,sizeof(packet))&&buttons==0&<==0&&lx==0); + buttons=65535;guide=1;lt=1023;reset(); + assert(!buttons&&!guide&&!lt&&!ready&&!lx); + puts("PASS: held combinations, signed axes, triggers, guide, release, malformed packets, disconnect reset"); +} diff --git a/fedac/native/tools/ac-usb-controller.c b/fedac/native/tools/ac-usb-controller.c new file mode 100644 index 0000000000..fb354d7308 --- /dev/null +++ b/fedac/native/tools/ac-usb-controller.c @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// USB reader for the 8BitDo Arcade Controller for Xbox (2dc8:202c). +// GIP packet layout/init follows Linux drivers/input/joystick/xpad.c: +// https://github.com/torvalds/linux/blob/master/drivers/input/joystick/xpad.c +// For native kernels without xpad. Never detaches an existing kernel driver. +// Publishes atomic state in RAM; retries discovery after unplug/replug. +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static volatile sig_atomic_t running=1; +static unsigned buttons,guide,lt,rt,reports; +static int lx,ly,rx,ry,ready; +static void stop(int sig) { (void)sig; running=0; } +static long long millis(void) { struct timespec t; clock_gettime(CLOCK_REALTIME,&t); return (long long)t.tv_sec*1000+t.tv_nsec/1000000; } +static unsigned le16(const unsigned char *p) { return p[0]|((unsigned)p[1]<<8); } +static void reset(void) { buttons=guide=lt=rt=0; lx=ly=rx=ry=ready=0; } +static void publish(int connected) { + FILE *f=fopen("/tmp/ac-controller.json.tmp","w"); if(!f)return; + fprintf(f,"{\"connected\":%s,\"ready\":%s,\"at\":%lld,\"reports\":%u,\"buttons\":%u,\"guide\":%u,\"lt\":%u,\"rt\":%u,\"lx\":%d,\"ly\":%d,\"rx\":%d,\"ry\":%d}\n",connected?"true":"false",ready?"true":"false",millis(),reports,buttons,guide,lt,rt,lx,ly,rx,ry); + if(fclose(f)==0)rename("/tmp/ac-controller.json.tmp","/tmp/ac-controller.json"); +} +static int number(const char *dir,const char *name,int base) { + char path[512],buf[64]; snprintf(path,sizeof(path),"%s/%s",dir,name); + FILE *f=fopen(path,"r"); if(!f)return -1; + int ok=fgets(buf,sizeof(buf),f)!=NULL; fclose(f); return ok?(int)strtol(buf,NULL,base):-1; +} +static int discover(char *path,size_t size) { + DIR *d=opendir("/sys/bus/usb/devices"); if(!d)return 0; + struct dirent *e; int found=0; + while((e=readdir(d))) { + char dir[512]; snprintf(dir,sizeof(dir),"/sys/bus/usb/devices/%s",e->d_name); + if(number(dir,"idVendor",16)!=0x2dc8||number(dir,"idProduct",16)!=0x202c)continue; + int bus=number(dir,"busnum",10),dev=number(dir,"devnum",10); + if(bus>0&&dev>0) { snprintf(path,size,"/dev/bus/usb/%03d/%03d",bus,dev); found=1; break; } + } + closedir(d);return found; +} +static int send_packet(int fd,unsigned char *data,unsigned length) { + struct usbdevfs_bulktransfer t={.ep=0x01,.len=length,.timeout=500,.data=data}; + int n=ioctl(fd,USBDEVFS_BULK,&t); + if(n!=(int)length)fprintf(stderr,"USB output %02x: %s (%d)\n",data[0],strerror(errno),n); + return n==(int)length; +} +static void initialize(int fd) { + unsigned char power[]={5,0x20,1,1,0}; + unsigned char led[]={10,0x20,2,3,0,1,0x14}; + unsigned char auth[]={6,0x20,3,2,1,0}; + send_packet(fd,power,sizeof(power)); + send_packet(fd,led,sizeof(led)); + send_packet(fd,auth,sizeof(auth)); +} +static int decode(const unsigned char *p,int n) { + if(n<4||p[3]>(unsigned)(n-4))return 0; + if(p[0]==0x20&&p[3]>=14&&n>=18) { + buttons=le16(p+4);lt=le16(p+6);rt=le16(p+8); + lx=(int16_t)le16(p+10);ly=-(int16_t)le16(p+12); + rx=(int16_t)le16(p+14);ry=-(int16_t)le16(p+16); + ready=1;reports++;return 1; + } + if(p[0]==7&&p[3]>=1&&n>=5) { guide=!!(p[4]&3);return 1; } + return 0; +} +#ifndef AC_CONTROLLER_TEST +int main(void) { + int lock=open("/tmp/ac-usb-controller.lock",O_CREAT|O_RDWR|O_CLOEXEC,0600); + if(lock<0||flock(lock,LOCK_EX|LOCK_NB)<0)return 0; + signal(SIGTERM,stop);signal(SIGINT,stop);signal(SIGHUP,SIG_IGN); + while(running) { + char path[128];reset();publish(0); + if(!discover(path,sizeof(path))) { usleep(500000);continue; } + int fd=open(path,O_RDWR|O_CLOEXEC);if(fd<0){usleep(500000);continue;} + unsigned iface=0; + if(ioctl(fd,USBDEVFS_CLAIMINTERFACE,&iface)<0){fprintf(stderr,"claim: %s\n",strerror(errno));close(fd);sleep(1);continue;} + unsigned char buffer[64]={0}; + struct usbdevfs_urb urb={.type=USBDEVFS_URB_TYPE_INTERRUPT,.endpoint=0x81,.buffer=buffer,.buffer_length=sizeof(buffer)}; + if(ioctl(fd,USBDEVFS_SUBMITURB,&urb)<0){perror("submit");close(fd);sleep(1);continue;} + fprintf(stderr,"Connected %s (8BitDo Arcade Controller)\n",path); + initialize(fd);publish(1); + long long last=millis();unsigned logged=0; + while(running) { + void *done=NULL;int status=ioctl(fd,USBDEVFS_REAPURBNDELAY,&done); + if(status==0) { + if(done!=&urb||urb.status<0)break; + int n=urb.actual_length; + if(logged++<6){fprintf(stderr,"GIP:");for(int j=0;j=5&&buffer[0]==7&&buffer[1]==0x30){ + unsigned char ack[]={1,0x20,buffer[2],9,0,7,0x20,2,0,0,0,0,0}; + send_packet(fd,ack,sizeof(ack)); + } + if(n>=4&&buffer[0]==2)initialize(fd); + if(changed){publish(1);last=millis();} + if(ioctl(fd,USBDEVFS_SUBMITURB,&urb)<0)break; + } else if(errno!=EAGAIN&&errno!=EINTR)break; + if(millis()-last>=100){publish(1);last=millis();} + struct pollfd wait={.fd=fd,.events=POLLOUT}; + if(poll(&wait,1,8)>0&&(wait.revents&(POLLERR|POLLHUP|POLLNVAL)))break; + } + ioctl(fd,USBDEVFS_DISCARDURB,&urb); + // Closing usbfs cancels and drains all pending URBs before storage dies. + close(fd);reset();publish(0);usleep(250000); + } + reset();publish(0);close(lock);return 0; +} +#endif diff --git a/fedac/native/tools/hdmi-mode-probe.c b/fedac/native/tools/hdmi-mode-probe.c new file mode 100644 index 0000000000..349e1bd5ec --- /dev/null +++ b/fedac/native/tools/hdmi-mode-probe.c @@ -0,0 +1,56 @@ +// Temporary HDMI diagnostic for existing AC OS builds. LD_PRELOAD into ac-native. +// Filters only advertised progressive HDMI modes, leaving primary eDP untouched. +// A request must be confirmed within 20 seconds or the previous selection returns. +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include + +typedef struct { unsigned w,h,hz,serial; } Choice; +static Choice good,active; +static unsigned seen; +static int pending,initialized,disconnect_once; +static double deadline; +static double seconds(void){struct timespec t;clock_gettime(CLOCK_MONOTONIC,&t);return t.tv_sec+t.tv_nsec/1e9;} +static Choice request(void){Choice c={0};FILE*f=fopen("/tmp/ac-hdmi-request","r");if(f){if(fscanf(f,"%u %u %u %u",&c.w,&c.h,&c.hz,&c.serial)!=4)c=(Choice){0};fclose(f);}return c;} +static void publish(const char *error){ + FILE*f=fopen("/tmp/ac-hdmi-probe.json.new","w");if(!f)return; + fprintf(f,"{\"pending\":%s,\"serial\":%u,\"width\":%u,\"height\":%u,\"hz\":%u,\"seconds\":%.0f,\"error\":\"%s\"}\n",pending?"true":"false",active.serial,active.w,active.h,active.hz,pending?deadline-seconds():0,error?error:""); + fclose(f);rename("/tmp/ac-hdmi-probe.json.new","/tmp/ac-hdmi-probe.json"); +} +static int find_mode(drmModeConnector*c,Choice v){ + if(!v.w&&!v.h&&!v.hz)return -2; + for(int i=0;icount_modes;i++){drmModeModeInfo*m=&c->modes[i]; + if(!(m->flags&(DRM_MODE_FLAG_INTERLACE|DRM_MODE_FLAG_DBLSCAN))&&m->hdisplay==v.w&&m->vdisplay==v.h&&m->vrefresh==v.hz)return i; + }return -1; +} +drmModeConnectorPtr drmModeGetConnector(int fd,uint32_t id){ + static drmModeConnectorPtr(*real)(int,uint32_t); + if(!real)real=dlsym(RTLD_NEXT,"drmModeGetConnector"); + if(!real)return NULL; + drmModeConnectorPtr c=real(fd,id);if(!c)return c; + if(c->connection!=DRM_MODE_CONNECTED||!c->count_modes|| + (c->connector_type!=DRM_MODE_CONNECTOR_HDMIA&&c->connector_type!=DRM_MODE_CONNECTOR_HDMIB))return c; + // First call is initialization; no request means the normal runtime picker. + if(!initialized){initialized=1;good=active=(Choice){0};publish(NULL);} + Choice next=request(); + if(next.serial!=seen){ + seen=next.serial; + if(find_mode(c,next)==-1){publish("Mode not offered by this HDMI connection");} + else {active=next;pending=1;deadline=seconds()+20;disconnect_once=1;publish(NULL);} + } + if(pending){ + unsigned confirm=0;FILE*f=fopen("/tmp/ac-hdmi-confirm","r");if(f){if(fscanf(f,"%u",&confirm)!=1)confirm=0;fclose(f);} + if(confirm==active.serial&&confirm){good=active;pending=0;publish(NULL);} + else if(seconds()>=deadline){active=good;pending=0;disconnect_once=1;publish("Reverted: test was not confirmed");} + else publish(NULL); + } + if(disconnect_once){disconnect_once=0;c->connection=DRM_MODE_DISCONNECTED;return c;} + int index=find_mode(c,active); + if(index>=0){c->modes[0]=c->modes[index];c->count_modes=1;} + return c; +} diff --git a/fedac/native/tools/hdmi-monitor.sh b/fedac/native/tools/hdmi-monitor.sh new file mode 100755 index 0000000000..873e0651d5 --- /dev/null +++ b/fedac/native/tools/hdmi-monitor.sh @@ -0,0 +1,12 @@ +#!/bin/sh +while :; do + awk ' + /^crtc\[/ {kind="crtc"; key=$2 " " $3} + /^connector\[/ {kind="connector"; connector=$2} + kind=="crtc" && /mode:/ {resolution=$2; gsub(/[":]/,"",resolution); modes[key]=resolution; rates[key]=$3} + kind=="connector" && /crtc=/ && connector ~ /^HDMI/ {pipe=$0; sub(/^[[:space:]]*crtc=/,"",pipe); if(pipe!="(null)"){selected=pipe; name=connector}} + END {split(modes[selected],size,"x"); printf "{\"connector\":\"%s\",\"width\":%d,\"height\":%d,\"hz\":%d}\n",name,size[1],size[2],rates[selected]} + ' /sys/kernel/debug/dri/0/state > /tmp/ac-hdmi-state.json.new + mv /tmp/ac-hdmi-state.json.new /tmp/ac-hdmi-state.json + sleep 2 +done diff --git a/slab/menubar-swift/README.md b/slab/menubar-swift/README.md index d2f1245ee3..33409ad1c2 100644 --- a/slab/menubar-swift/README.md +++ b/slab/menubar-swift/README.md @@ -43,6 +43,27 @@ network, SSD, and GPU. Hover expands it into readable local history rows with current values; click pins the same panel. It does not poll or display other machines. +## AC keyboard remote + +Press **Command–Option–L** or click the **AC** keyboard item to connect. Once +**Remote → ac0** appears, keys go to the AC laptop. Repeat the shortcut or click +the item to return to the Mac; Escape goes to AC's prompt. Capture starts only +after the receiver answers. Sleep, session lock, a broken connection, or a lost +event tap releases remote keys and returns local typing. Leave Menu Band's +performance mode before connecting; its Command–Command gesture stays unchanged. + +The user-local `~/.config/slab/ac-remote.json` contains an SSH host alias and a +short display label: `{"host":"ac0-remote","label":"ac0"}`. Configure that alias +in `~/.ssh/config` with a verified host key and key-based login. The receiver +must be installed as `/mnt/tools/ac-keyboard-remote` on AC OS; its source lives +in `fedac/native/tools/ac-keyboard-remote.c`. One persistent SSH stream carries +physical key codes and a one-second heartbeat. Keyboard positions follow the +US layout, with the ISO extra key supported; this does not translate IME text. +No typed keys are logged. Slab requires its existing Accessibility grant. + +Run `tests/ac-remote-test.sh` for synthetic keyboard and failure-path tests; +it creates events in memory and never posts or captures desktop input. + ## Window zoom Double-tap Control over a window to zoom; repeat to exit. Command–Option–arrow diff --git a/slab/menubar-swift/Sources/SlabMenubar/ACKeyboardRemote.swift b/slab/menubar-swift/Sources/SlabMenubar/ACKeyboardRemote.swift new file mode 100644 index 0000000000..99f3b86770 --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/ACKeyboardRemote.swift @@ -0,0 +1,307 @@ +import AppKit +import ApplicationServices +import Carbon +import Darwin + +/// Physical US keyboard positions mapped to Linux evdev. No text or key logging. +enum ACRemoteKeys { + static let codes: [UInt16: Int] = [ + 0:30, 1:31, 2:32, 3:33, 4:35, 5:34, 6:44, 7:45, 8:46, 9:47, + 10:86, 11:48, 12:16, 13:17, 14:18, 15:19, 16:21, 17:20, 18:2, 19:3, + 20:4, 21:5, 22:7, 23:6, 24:13, 25:10, 26:8, 27:12, 28:9, 29:11, + 30:27, 31:24, 32:22, 33:26, 34:23, 35:25, 36:28, 37:38, + 38:36, 39:40, 40:37, 41:39, 42:43, 43:51, 44:53, 45:49, + 46:50, 47:52, 48:15, 49:57, 50:41, 51:14, 53:1, + 54:126, 55:125, 56:42, 57:58, 58:56, 59:29, 60:54, 61:100, 62:97, + 65:83, 67:55, 69:78, 71:69, 75:98, 76:96, 78:74, 81:117, + 82:82, 83:79, 84:80, 85:81, 86:75, 87:76, 88:77, 89:71, 91:72, 92:73, + 96:63, 97:64, 98:65, 99:61, 100:66, 101:67, 103:87, 105:183, + 106:186, 107:184, 109:68, 111:88, 113:185, 114:110, 115:102, + 116:104, 117:111, 118:62, 119:107, 120:60, 121:109, 122:59, + 123:105, 124:106, 125:108, 126:103, + ] + // NX_DEVICE* masks distinguish two held Shift/Control/Option/Command keys. + static let modifierMasks: [UInt16: UInt64] = [ + 54:0x10, 55:0x08, 56:0x02, 58:0x20, 59:0x01, + 60:0x04, 61:0x40, 62:0x2000, + ] + static func isToggle(_ key: UInt16, _ flags: CGEventFlags) -> Bool { + key == 37 && flags.contains([.maskCommand, .maskAlternate]) + && flags.intersection([.maskShift, .maskControl]).isEmpty + } +} + +/// An explicit, fail-open keyboard handoff over one SSH stream. The event tap +/// exists only after the receiver is READY; a dropped heartbeat returns the Mac +/// keyboard immediately. The Linux peer independently releases keys on timeout. +final class ACKeyboardRemote: NSObject { + struct Configuration: Decodable { + var host: String = "ac0-remote" + var label: String = "ac0" + } + var onCaptureChanged: ((Bool) -> Void)? + var canConnect: (() -> Bool)? + private var configuration = Configuration() + private var hotkey: GlobalHotkey? + private var item: NSStatusItem? + private var process: Process? + private var input: FileHandle? + private var output: FileHandle? + private var timer: Timer? + private var tap: CFMachPort? + private var source: CFRunLoopSource? + private var buffer = Data() + private var generation = 0 + private var startedAt: TimeInterval = 0 + private var lastPong: TimeInterval = 0 + private var nonce = 0 + private var outstandingPings = Set() + private var pressed = Set() + private var waitingForModifiers = true + private var capturing = false + private var stopping = false + private var now: TimeInterval { ProcessInfo.processInfo.systemUptime } + + func start() { + let status = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item = status + status.button?.target = self + status.button?.action = #selector(toggle) + status.button?.image = NSImage(systemSymbolName: "keyboard", accessibilityDescription: "AC keyboard remote") + status.button?.imagePosition = .imageLeading + show("AC", detail: "AC keyboard remote · ⌘⌥L to connect") + setPerformanceFocused(!(canConnect?() ?? true)) + NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(suspend), + name: NSWorkspace.willSleepNotification, object: nil) + NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(suspend), + name: NSWorkspace.sessionDidResignActiveNotification, object: nil) + } + + func setPerformanceFocused(_ focused: Bool) { + if focused { + disconnect() + hotkey?.unregister() + hotkey = nil + } else if hotkey == nil { + let shortcut = GlobalHotkey(id: 20) { [weak self] in self?.toggle() } + if shortcut.register(keyCode: UInt32(kVK_ANSI_L), modifiers: UInt32(cmdKey | optionKey)) { + hotkey = shortcut + } else { + show("AC !", detail: "⌘⌥L is unavailable. Click to connect.") + } + } + } + + func shutdown() { + disconnect() + hotkey?.unregister() + hotkey = nil + NSWorkspace.shared.notificationCenter.removeObserver(self) + if let item { NSStatusBar.system.removeStatusItem(item) } + item = nil + } + + @objc private func suspend() { disconnect() } + + @objc private func toggle() { + if process != nil { disconnect(); return } + guard canConnect?() ?? true else { + fail("Leave Menu Band performance mode before connecting.") + return + } + guard AXIsProcessTrusted(), ProcessInfo.processInfo.environment["SLAB_DISABLE_EVENT_TAPS"] != "1" else { + fail("Enable Slab Menubar in System Settings → Privacy & Security → Accessibility.") + return + } + let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/slab/ac-remote.json") + if FileManager.default.fileExists(atPath: url.path) { + guard let data = try? Data(contentsOf: url), + let config = try? JSONDecoder().decode(Configuration.self, from: data), + !config.host.isEmpty, config.host.range(of: "^[A-Za-z0-9][A-Za-z0-9._-]*$", options: .regularExpression) != nil, + !config.label.isEmpty, config.label.count <= 32 else { + fail("Invalid ~/.config/slab/ac-remote.json; expected host and label.") + return + } + configuration = config + } + generation += 1 + let revision = generation + let task = Process() + let stdinPipe = Pipe(), stdoutPipe = Pipe() + task.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + task.arguments = ["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=4", + "-o", "ServerAliveInterval=2", "-o", "ServerAliveCountMax=2", + configuration.host, "/mnt/tools/ac-keyboard-remote"] + task.standardInput = stdinPipe + task.standardOutput = stdoutPipe + task.standardError = FileHandle.nullDevice // No remote output or input is logged. + process = task + input = stdinPipe.fileHandleForWriting + output = stdoutPipe.fileHandleForReading + let descriptor = stdinPipe.fileHandleForWriting.fileDescriptor + _ = fcntl(descriptor, F_SETFL, fcntl(descriptor, F_GETFL) | O_NONBLOCK) + _ = fcntl(descriptor, F_SETNOSIGPIPE, 1) + stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + DispatchQueue.main.async { + guard let self, self.generation == revision else { return } + if data.isEmpty { self.fail("AC connection closed.") } + else { self.receive(data) } + } + } + task.terminationHandler = { [weak self] _ in + DispatchQueue.main.async { + guard let self, self.generation == revision else { return } + self.fail("AC connection closed. Click or press ⌘⌥L to reconnect.") + } + } + startedAt = now + lastPong = now + show("AC …", detail: "Connecting to \(configuration.label) · click or ⌘⌥L to cancel") + do { try task.run() } + catch { fail("Could not start SSH."); return } + let heartbeat = Timer(timeInterval: 1, repeats: true) { [weak self] _ in + guard let self else { return } + if !self.capturing { + if self.now - self.startedAt > 6 { self.fail("AC receiver did not become ready.") } + return + } + guard self.now - self.lastPong < 3 else { self.fail("AC connection lost; keyboard returned to this Mac."); return } + self.nonce += 1 + let token = String(self.nonce) + self.outstandingPings.insert(token) + self.send("P \(token)\n") + } + timer = heartbeat + RunLoop.main.add(heartbeat, forMode: .common) + } + + private func receive(_ data: Data) { + buffer.append(data) + guard buffer.count < 4096 else { fail("Unexpected AC receiver response."); return } + while let end = buffer.firstIndex(of: 10) { + let line = String(decoding: buffer[.. Bool { + let mask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) + | (1 << CGEventType.flagsChanged.rawValue) + guard let port = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap, + options: .defaultTap, eventsOfInterest: CGEventMask(mask), callback: { _, type, event, context in + guard let context else { return Unmanaged.passUnretained(event) } + let remote = Unmanaged.fromOpaque(context).takeUnretainedValue() + return remote.handle(type, event) ? nil : Unmanaged.passUnretained(event) + }, userInfo: Unmanaged.passUnretained(self).toOpaque()) else { return false } + tap = port + source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, port, 0) + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + waitingForModifiers = !CGEventSource.flagsState(.combinedSessionState) + .intersection([.maskCommand, .maskAlternate, .maskShift, .maskControl]).isEmpty + capturing = true + onCaptureChanged?(true) + CGEvent.tapEnable(tap: port, enable: true) + return true + } + + private func handle(_ type: CGEventType, _ event: CGEvent) -> Bool { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + // Never silently resume after losing key-up events. + fail("Keyboard capture interrupted; keyboard returned to this Mac.") + return false + } + guard capturing else { return false } + let key = UInt16(event.getIntegerValueField(.keyboardEventKeycode)) + if type == .keyDown && ACRemoteKeys.isToggle(key, event.flags) { + disconnect() + return true + } + if waitingForModifiers { + waitingForModifiers = !event.flags.intersection([.maskCommand, .maskAlternate, .maskShift, .maskControl]).isEmpty + return true // Do not forward the shortcut's still-held modifiers. + } + guard let code = ACRemoteKeys.codes[key] else { return true } + if type == .flagsChanged { + if key == 57 { send("K 58 1\nK 58 0\n"); return true } + guard let mask = ACRemoteKeys.modifierMasks[key] else { return true } + let down = event.flags.rawValue & mask != 0 + if down != pressed.contains(code) { + if down { pressed.insert(code) } else { pressed.remove(code) } + send("K \(code) \(down ? 1 : 0)\n") + } + } else if type == .keyDown { + let value = pressed.contains(code) ? 2 : 1 + pressed.insert(code) + send("K \(code) \(value)\n") + } else if type == .keyUp, pressed.remove(code) != nil { + send("K \(code) 0\n") + } + return true + } + + private func send(_ line: String) { + guard let input else { return } + let bytes = Array(line.utf8) + // Each protocol message fits in PIPE_BUF: EAGAIN fails open, never blocks + // the event-tap callback behind SSH/network backpressure. + let count = bytes.withUnsafeBytes { Darwin.write(input.fileDescriptor, $0.baseAddress, $0.count) } + if count != bytes.count && !stopping { fail("AC connection stalled; keyboard returned to this Mac.") } + } + + func disconnect() { + guard !stopping else { return } + stopping = true + generation += 1 + if let tap { CGEvent.tapEnable(tap: tap, enable: false); CFMachPortInvalidate(tap) } + if let source { CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) } + tap = nil + source = nil + let wasCapturing = capturing + capturing = false + send("R\n") + pressed.removeAll() + outstandingPings.removeAll() + buffer.removeAll() + timer?.invalidate() + timer = nil + output?.readabilityHandler = nil + try? input?.close() + try? output?.close() + input = nil + output = nil + if let process { + process.terminationHandler = nil + // Closing stdin lets the peer release first. Bound an unresponsive SSH. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + if process.isRunning { process.terminate() } + } + } + process = nil + if wasCapturing { onCaptureChanged?(false) } + show("AC", detail: "AC keyboard remote · ⌘⌥L to connect") + stopping = false + } + + private func show(_ title: String, detail: String) { + item?.button?.title = title + item?.button?.toolTip = detail + item?.button?.setAccessibilityLabel(detail) + } + + private func fail(_ reason: String) { + disconnect() + show("AC !", detail: reason) + NSLog("slab AC remote: %@", reason) + } +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index db0831015b..8977c4d828 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -188,6 +188,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// System-wide ⌘⌥T → re-tile agent terminals. Kept alive for the app's /// lifetime; unregistered in `applicationWillTerminate`. private var tileHotkey: GlobalHotkey? + private let acKeyboardRemote = ACKeyboardRemote() /// System-wide ⌘⌥S → scatter every session into tiny confetti windows. /// A Carbon global hotkey, not just a menu keyEquivalent: a status-bar /// app's menu shortcuts only fire when it's frontmost, so ⌘⌥S would @@ -261,6 +262,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { object: nil ) menuBandPerformanceFocused = Self.menuBandPerformanceFocusIsLive() + acKeyboardRemote.canConnect = { [weak self] in + !(self?.menuBandPerformanceFocused ?? true) + } + acKeyboardRemote.onCaptureChanged = { [weak self] capturing in + guard let self else { return } + if capturing { + PromptSigilOverlayController.shared.endKeyboardFocus() + self.navHoldTap?.stop() + self.zoomLensTap?.stop() + NavHoldHint.shared.endHold() + } else { + _ = self.navHoldTap?.start() + _ = self.zoomLensTap?.start() + } + } + acKeyboardRemote.start() // Bring the fleet control plane up before any optional probes. A // wedged Messages, Terminal, or system-metrics subprocess must not @@ -457,6 +474,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } func applicationWillTerminate(_ notification: Notification) { + acKeyboardRemote.shutdown() tileHotkey?.unregister() scatterHotkey?.unregister() promptRockFocusHotkey?.unregister() @@ -500,6 +518,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { ?? Self.menuBandPerformanceFocusIsLive() guard focused != menuBandPerformanceFocused else { return } menuBandPerformanceFocused = focused + acKeyboardRemote.setPerformanceFocused(focused) if focused { promptRockFocusHotkey?.unregister() promptRockFocusHotkey = nil diff --git a/slab/menubar-swift/tests/ac-remote-test.sh b/slab/menubar-swift/tests/ac-remote-test.sh new file mode 100755 index 0000000000..5397dc682e --- /dev/null +++ b/slab/menubar-swift/tests/ac-remote-test.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -euo pipefail +root="$(cd "$(dirname "$0")/.." && pwd)" +build="$(mktemp -d /tmp/ac-remote-test.XXXXXX)" +trap 'rm -rf "$build"' EXIT +cat "$root/Sources/SlabMenubar/ACKeyboardRemote.swift" "$root/tests/ac-remote-test.swift" > "$build/main.swift" +nice -n 8 swiftc "$root/Sources/SlabMenubar/GlobalHotkey.swift" "$build/main.swift" -o "$build/test" +"$build/test" diff --git a/slab/menubar-swift/tests/ac-remote-test.swift b/slab/menubar-swift/tests/ac-remote-test.swift new file mode 100644 index 0000000000..776212179e --- /dev/null +++ b/slab/menubar-swift/tests/ac-remote-test.swift @@ -0,0 +1,80 @@ +// Appended to ACKeyboardRemote.swift in a temporary translation unit by the +// runner. Swift extensions in the same file can inspect private state without +// exposing test-only capture controls in the installed application. +extension ACKeyboardRemote { + static func runTests() { + func fixture() -> (ACKeyboardRemote, Pipe) { + let remote = ACKeyboardRemote(), pipe = Pipe() + remote.input = pipe.fileHandleForWriting + remote.capturing = true + remote.waitingForModifiers = false + for fd in [pipe.fileHandleForReading.fileDescriptor, pipe.fileHandleForWriting.fileDescriptor] { + _ = fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK) + _ = fcntl(fd, F_SETNOSIGPIPE, 1) + } + return (remote, pipe) + } + func drain(_ pipe: Pipe) -> String { + var bytes = [UInt8](repeating: 0, count: 16384) + let count = Darwin.read(pipe.fileHandleForReading.fileDescriptor, &bytes, bytes.count) + return count > 0 ? String(decoding: bytes.prefix(count), as: UTF8.self) : "" + } + func event(_ key: UInt16, _ type: CGEventType, _ flags: CGEventFlags = []) -> CGEvent { + let event = CGEvent(keyboardEventSource: nil, virtualKey: key, keyDown: type != .keyUp)! + event.type = type + event.flags = flags + return event + } + let (remote, pipe) = fixture() + for type in [CGEventType.keyDown, .keyDown, .keyUp] { + assert(remote.handle(type, event(0, type))) + } + assert(drain(pipe) == "K 30 1\nK 30 2\nK 30 0\n") + // Two shifts: releasing the left one must leave the right one down. + for (key, flags): (UInt16, UInt64) in [(56, 0x20002), (60, 0x20006), (56, 0x20004), (60, 0)] { + _ = remote.handle(.flagsChanged, event(key, .flagsChanged, CGEventFlags(rawValue: flags))) + } + assert(drain(pipe) == "K 42 1\nK 54 1\nK 42 0\nK 54 0\n") + _ = remote.handle(.keyDown, event(53, .keyDown)) + _ = remote.handle(.keyUp, event(53, .keyUp)) + assert(drain(pipe) == "K 1 1\nK 1 0\n", "Escape must reach the AC prompt") + assert(ACRemoteKeys.codes[10] == 86 && ACRemoteKeys.codes[123] == 105) + assert(!ACRemoteKeys.isToggle(37, [.maskCommand, .maskAlternate, .maskShift])) + _ = remote.handle(.keyDown, event(37, .keyDown, [.maskCommand, .maskAlternate])) + assert(!remote.capturing && remote.input == nil) + assert(drain(pipe) == "R\n", "The exit shortcut must never become a remote L") + assert(!remote.handle(.keyDown, event(0, .keyDown)), "Idle mode must pass through") + + let (arming, armPipe) = fixture() + arming.waitingForModifiers = true + _ = arming.handle(.keyDown, event(0, .keyDown, [.maskCommand])) + _ = arming.handle(.flagsChanged, event(55, .flagsChanged)) + assert(drain(armPipe).isEmpty) + _ = arming.handle(.keyDown, event(0, .keyDown)) + assert(drain(armPipe) == "K 30 1\n") + _ = arming.handle(.tapDisabledByTimeout, event(0, .keyUp)) + assert(!arming.capturing && drain(armPipe) == "R\n") + + let (backpressure, fullPipe) = fixture() + let fill = [UInt8](repeating: 120, count: 4096) + while fill.withUnsafeBytes({ Darwin.write(fullPipe.fileHandleForWriting.fileDescriptor, $0.baseAddress, $0.count) }) > 0 {} + // Atomic writes under PIPE_BUF must fail promptly instead of freezing + // the event tap when SSH has stopped consuming its pipe. + let start = ProcessInfo.processInfo.systemUptime + backpressure.send("K 30 1\n") + assert(!backpressure.capturing) + assert(ProcessInfo.processInfo.systemUptime - start < 0.5) + + let (heartbeat, heartbeatPipe) = fixture() + heartbeat.lastPong = 0 + heartbeat.outstandingPings = ["42"] + heartbeat.receive(Data("P 41\nP ".utf8)) + assert(heartbeat.lastPong == 0) + heartbeat.receive(Data("42\n".utf8)) + assert(heartbeat.lastPong > 0 && heartbeat.outstandingPings.isEmpty) + heartbeat.disconnect() + assert(drain(heartbeatPipe) == "R\n") + print("AC remote tests passed: key lifecycle, modifier overlap, escape, local exit, arming, disabled tap, nonblocking backpressure, heartbeat framing.") + } +} +ACKeyboardRemote.runTests() -- 2.51.2