nx_input_abstract.nx source
↩ module page · 235 lines · 9983 B
1// nx_input_abstract.nx -- SOVEREIGN device-agnostic input core (F1102, game-platform lane).
2// ONE semantic action state (up/down/left/right/A/B/menu/pause) driven by FOUR sources -- keyboard,
3// gamepad buttons, gamepad axes, touch -- so a game loop reads ACTIONS, never devices. This moves the
4// key->meaning mapping OUT of the JS shim and INTO the sovereign organ (the shim stays pure transport),
5// and it is the enabler for platform-desktop-native (F1105) and vr-runtime (F1106): a VR controller
6// surfaces as gamepad buttons/axes (WebXR gamepad), so it enters through the SAME ia_pad/ia_axis doors.
7//
8// Base-relative + integer-only + no syscalls CALLED (imports only for consts/compose) => the identical
9// code runs natively (base=mmap) and in wasm (base=0), like nx_wasm_breeders.
10//
11// Per-source held masks are kept SEPARATE and unioned on read, so releasing a key can never eat a
12// gamepad hold (the classic cross-device release bug -- gate tooth T4). Edge detection lives here too:
13// ia_frame() returns the RISING edge, so "interact fires every frame while held" cannot recur (T2).
14// Bindings are DATA rows (code,action) seeded by ia_init and rewritable at runtime via ia_bind (T6).
15// Touch composes the EXISTING nx_touch_zone SSOT (virtual d-pad -> legacy keycode) then routes the
16// code through the SAME binding table, so touch inherits rebinds for free.
17// license_tier: ORIGINAL
18import "nx_syscalls.nx"
19import "_hdl_build/nx_game_touch.nx"
20
21// semantic actions (bitmask). 16-action space since 2026-07-28 (debt seq1136: the first 3D consumer
22// saturated 8 bits) -- the original 8 values are FROZEN; new actions extend upward only.
23const IA_UP: i64 = 1
24const IA_DOWN: i64 = 2
25const IA_LEFT: i64 = 4
26const IA_RIGHT: i64 = 8
27const IA_A: i64 = 16
28const IA_B: i64 = 32
29const IA_MENU: i64 = 64
30const IA_PAUSE: i64 = 128
31const IA_JUMP: i64 = 256
32const IA_LUP: i64 = 512 // look up (keyboard pitch fallback)
33const IA_LDOWN: i64 = 1024 // look down
34const IA_MOD: i64 = 2048 // spare modifier
35const IA_HOME: i64 = 4096 // return-to-spawn (void-fall rescue)
36
37// arena slot layout (i64 words; caller provides a zeroed region of IA_WORDS words)
38const IA_KB: i64 = 0 // keyboard-held action mask
39const IA_PAD: i64 = 1 // gamepad-button-held action mask
40const IA_AX: i64 = 2 // gamepad-axis-derived action mask (hysteresis state)
41const IA_TOUCH: i64 = 3 // touch-held action mask
42const IA_PREV: i64 = 4 // union mask latched by the previous ia_frame (edge detector)
43const IA_AXX: i64 = 5 // last raw x axis, milli (-1000..1000)
44const IA_AXY: i64 = 6 // last raw y axis, milli (+ = down, browser gamepad convention)
45const IA_NBIND: i64 = 7 // live binding rows
46const IA_BROW0: i64 = 8 // rows begin: (code, action) pairs
47const IA_MAXBIND: i64 = 24
48const IA_LOOKX: i64 = 56 // analog LOOK channel: accumulated relative deltas (mouse/touch-drag),
49const IA_LOOKY: i64 = 57 // drained once per frame by ia_look_take -- actions are bits, look is motion
50const IA_KEYROWS: i64 = 58 // physical keyboard holds by binding row; uses reserved arena slack
51const IA_LOOK_CLAMP: i64 = 30000
52const IA_WORDS: i64 = 64 // 8 + 2*24 = 56 bind area, +2 look, rounded up for slack
53
54// axis hysteresis (milli): assert above PRESS, keep until below HOLD -- no flicker at one boundary
55const IA_TH_PRESS: i64 = 350
56const IA_TH_HOLD: i64 = 250
57
58func ia_words() -> i64 { return IA_WORDS }
59
60// code -> action bit; 0 = unbound. rows with action 0 are dead (unbind leaves a dead row).
61func ia_lookup(p: *i64, code: i64) -> i64 {
62 var i: i64 = 0
63 var hit: i64 = 0
64 while i < p[IA_NBIND] {
65 if p[IA_BROW0 + i*2] == code { if p[IA_BROW0 + i*2 + 1] != 0 { hit = p[IA_BROW0 + i*2 + 1] } }
66 i = i + 1
67 }
68 return hit
69}
70
71// Recompute from physical holds: releasing one alias must preserve the other.
72func ia_keyboard_union(p: *i64) -> i64 {
73 var mask: i64 = 0
74 var i: i64 = 0
75 while i < p[IA_NBIND] {
76 if (p[IA_KEYROWS] & (1 << i)) != 0 { mask = mask | p[IA_BROW0 + i*2 + 1] }
77 i = i + 1
78 }
79 p[IA_KB] = mask
80 return mask
81}
82
83// upsert a binding row; act=0 unbinds. returns 1 on success, 0 when the table is full.
84func ia_bind(p: *i64, code: i64, act: i64) -> i64 {
85 var i: i64 = 0
86 var done: i64 = 0
87 while i < p[IA_NBIND] {
88 if p[IA_BROW0 + i*2] == code { if done == 0 {
89 if p[IA_BROW0 + i*2 + 1] != act {
90 // A remap clears this physical hold, avoiding a synthetic action under the new binding.
91 let rowbit: i64 = 1 << i
92 p[IA_KEYROWS] = p[IA_KEYROWS] - (p[IA_KEYROWS] & rowbit)
93 p[IA_BROW0 + i*2 + 1] = act
94 ia_keyboard_union(p)
95 }
96 done = 1
97 } }
98 i = i + 1
99 }
100 if done == 1 { return 1 }
101 if p[IA_NBIND] >= IA_MAXBIND { return 0 }
102 p[IA_BROW0 + p[IA_NBIND]*2] = code
103 p[IA_BROW0 + p[IA_NBIND]*2 + 1] = act
104 p[IA_NBIND] = p[IA_NBIND] + 1
105 return 1
106}
107
108// zero the arena and seed the default keyboard map: WASD + arrows + E/space(A) + B + enter/P.
109// the arrow/space codes are nx_game_touch's NX_KEY_* consts, so touch zones land on live rows.
110func ia_init(p: *i64) -> i64 {
111 var k: i64 = 0
112 while k < IA_WORDS { p[k] = 0; k = k + 1 }
113 ia_bind(p, 87, IA_UP) // W
114 ia_bind(p, 65, IA_LEFT) // A
115 ia_bind(p, 83, IA_DOWN) // S
116 ia_bind(p, 68, IA_RIGHT) // D
117 ia_bind(p, NX_KEY_UP, IA_UP) // arrows (38/37/40/39)
118 ia_bind(p, NX_KEY_LEFT, IA_LEFT)
119 ia_bind(p, NX_KEY_DOWN, IA_DOWN)
120 ia_bind(p, NX_KEY_RIGHT, IA_RIGHT)
121 ia_bind(p, 69, IA_A) // E interact
122 ia_bind(p, NX_KEY_SPACE, IA_A) // space = touch center zone
123 ia_bind(p, 66, IA_B) // B secondary
124 ia_bind(p, 13, IA_MENU) // enter
125 ia_bind(p, 80, IA_PAUSE) // P
126 return 0
127}
128
129// keyboard event: code through the binding table into the kb mask. unbound codes are no-ops.
130func ia_key(p: *i64, code: i64, down: i64) -> i64 {
131 var i: i64 = 0
132 while i < p[IA_NBIND] {
133 if p[IA_BROW0 + i*2] == code {
134 let action: i64 = p[IA_BROW0 + i*2 + 1]
135 let rowbit: i64 = 1 << i
136 if down != 0 && action != 0 { p[IA_KEYROWS] = p[IA_KEYROWS] | rowbit }
137 else { p[IA_KEYROWS] = p[IA_KEYROWS] - (p[IA_KEYROWS] & rowbit) }
138 ia_keyboard_union(p)
139 return action
140 }
141 i = i + 1
142 }
143 return 0
144}
145
146// W3C standard-gamepad button index -> action (12/13/14/15 = dpad, 0=A, 1=B, 9=start, 8=select).
147// this is the published gamepad SPEC, not tunable config, so it is a fixed decode.
148func ia_pad_decode(btn: i64) -> i64 {
149 if btn == 12 { return IA_UP }
150 if btn == 13 { return IA_DOWN }
151 if btn == 14 { return IA_LEFT }
152 if btn == 15 { return IA_RIGHT }
153 if btn == 0 { return IA_A }
154 if btn == 1 { return IA_B }
155 if btn == 9 { return IA_MENU }
156 if btn == 8 { return IA_PAUSE }
157 return 0
158}
159
160func ia_pad(p: *i64, btn: i64, down: i64) -> i64 {
161 let bit: i64 = ia_pad_decode(btn)
162 if bit == 0 { return 0 }
163 if down != 0 { p[IA_PAD] = p[IA_PAD] | bit }
164 if down == 0 { p[IA_PAD] = p[IA_PAD] - (p[IA_PAD] & bit) }
165 return bit
166}
167
168// one direction of one axis with hysteresis: v = signed milli TOWARD this direction.
169func ia_ax1(active: i64, v: i64) -> i64 {
170 if active == 0 { if v >= IA_TH_PRESS { return 1 } return 0 }
171 if v >= IA_TH_HOLD { return 1 }
172 return 0
173}
174
175// gamepad axis sample: idx 0 = x (+right), idx 1 = y (+down), value in milli. recomputes the
176// whole axis mask from the stored raw pair so each direction keeps its own hysteresis state.
177func ia_axis(p: *i64, idx: i64, milli: i64) -> i64 {
178 if idx == 0 { p[IA_AXX] = milli }
179 if idx == 1 { p[IA_AXY] = milli }
180 let m: i64 = p[IA_AX]
181 var nm: i64 = 0
182 if ia_ax1(m & IA_RIGHT, p[IA_AXX]) == 1 { nm = nm | IA_RIGHT }
183 if ia_ax1(m & IA_LEFT, 0 - p[IA_AXX]) == 1 { nm = nm | IA_LEFT }
184 if ia_ax1(m & IA_DOWN, p[IA_AXY]) == 1 { nm = nm | IA_DOWN }
185 if ia_ax1(m & IA_UP, 0 - p[IA_AXY]) == 1 { nm = nm | IA_UP }
186 p[IA_AX] = nm
187 return nm
188}
189
190// touch: virtual-d-pad zone (nx_touch_zone SSOT) -> legacy keycode -> binding table -> action.
191// single-touch rung: the mask IS the current touch's action; up clears it.
192func ia_touch(p: *i64, tx: i64, ty: i64, w: i64, h: i64, down: i64) -> i64 {
193 if down == 0 { p[IA_TOUCH] = 0; return 0 }
194 let code: i64 = nx_touch_zone(tx, ty, w, h)
195 p[IA_TOUCH] = ia_lookup(p, code)
196 return p[IA_TOUCH]
197}
198
199// union of every source -- the mask a game loop reads for HELD movement.
200func ia_held(p: *i64) -> i64 {
201 return p[IA_KB] | p[IA_PAD] | p[IA_AX] | p[IA_TOUCH]
202}
203
204// frame boundary: returns the RISING-edge mask (newly pressed since the last call) and latches.
205// call once per game tick; use the result for one-shot actions (interact/menu), ia_held for movement.
206func ia_frame(p: *i64) -> i64 {
207 let held: i64 = ia_held(p)
208 let pressed: i64 = held - (held & p[IA_PREV])
209 p[IA_PREV] = held
210 return pressed
211}
212
213// analog look: accumulate a relative delta (mouse movement, touch drag). Clamped so a stall
214// between frames can never bank an unbounded spin.
215func ia_look(p: *i64, dx: i64, dy: i64) -> i64 {
216 var nx2: i64 = p[IA_LOOKX] + dx
217 if nx2 > IA_LOOK_CLAMP { nx2 = IA_LOOK_CLAMP }
218 if nx2 < 0 - IA_LOOK_CLAMP { nx2 = 0 - IA_LOOK_CLAMP }
219 p[IA_LOOKX] = nx2
220 var ny2: i64 = p[IA_LOOKY] + dy
221 if ny2 > IA_LOOK_CLAMP { ny2 = IA_LOOK_CLAMP }
222 if ny2 < 0 - IA_LOOK_CLAMP { ny2 = 0 - IA_LOOK_CLAMP }
223 p[IA_LOOKY] = ny2
224 return 0
225}
226
227// drain the accumulated look delta (once per game tick): out[0]=dx out[1]=dy, then zeroed --
228// the same delta can never be applied twice.
229func ia_look_take(p: *i64, out: *i64) -> i64 {
230 out[0] = p[IA_LOOKX]
231 out[1] = p[IA_LOOKY]
232 p[IA_LOOKX] = 0
233 p[IA_LOOKY] = 0
234 return 0
235}