diff --git a/pkg/worker/caged/libretro/nanoarch/input.go b/pkg/worker/caged/libretro/nanoarch/input.go index e095bbd8..59b5b014 100644 --- a/pkg/worker/caged/libretro/nanoarch/input.go +++ b/pkg/worker/caged/libretro/nanoarch/input.go @@ -2,7 +2,6 @@ package nanoarch import ( "encoding/binary" - "sync" "sync/atomic" ) @@ -19,36 +18,11 @@ void input_cache_clear(void); import "C" const ( - Released C.int16_t = iota - Pressed + maxPort = 4 + numAxes = 4 + RetrokLast = int(C.RETROK_LAST) ) -const RetrokLast = int(C.RETROK_LAST) - -// InputState stores full controller state. -// It consists of: -// - uint16 button values -// - int16 analog stick values -type InputState [maxPort]RetroPadState - -type ( - RetroPadState struct { - keys uint32 - axes [dpadAxes]int32 - } - KeyboardState struct { - keys [RetrokLast]byte - mod uint16 - mu sync.Mutex - } - MouseState struct { - dx, dy atomic.Int32 - buttons atomic.Int32 - } -) - -type MouseBtnState int32 - type Device byte const ( @@ -62,128 +36,110 @@ const ( MouseButton ) +type MouseBtnState int32 + const ( MouseLeft MouseBtnState = 1 << iota MouseRight MouseMiddle ) -const ( - maxPort = 4 - dpadAxes = 4 -) +// InputState stores controller state for all ports. +// - uint16 button bitmask +// - int16 analog axes x4 +type InputState [maxPort]struct { + keys uint32 + axes [numAxes]int32 +} -// Input sets input state for some player in a game session. -func (s *InputState) Input(port int, data []byte) { - atomic.StoreUint32(&s[port].keys, uint32(uint16(data[1])<<8+uint16(data[0]))) - for i, axes := 0, len(data); i < dpadAxes && i<<1+3 < axes; i++ { - axis := i<<1 + 2 - atomic.StoreInt32(&s[port].axes[i], int32(data[axis+1])<<8+int32(data[axis])) +// SetInput sets input state for a player. +// +// [BTN:2][AX0:2][AX1:2][AX2:2][AX3:2] +func (s *InputState) SetInput(port int, data []byte) { + atomic.StoreUint32(&s[port].keys, uint32(binary.LittleEndian.Uint16(data))) + for i := 0; i < numAxes && i*2+3 < len(data); i++ { + atomic.StoreInt32(&s[port].axes[i], int32(int16(binary.LittleEndian.Uint16(data[i*2+2:])))) } } -// IsKeyPressed checks if some button is pressed by any player. -func (s *InputState) IsKeyPressed(port uint, key int) C.int16_t { - return C.int16_t((atomic.LoadUint32(&s[port].keys) >> uint(key)) & 1) +// Button check +func (s *InputState) Button(port, key uint) C.int16_t { + return C.int16_t((atomic.LoadUint32(&s[port].keys) >> key) & 1) } -// IsDpadTouched checks if D-pad is used by any player. -func (s *InputState) IsDpadTouched(port uint, axis uint) (shift C.int16_t) { - return C.int16_t(atomic.LoadInt32(&s[port].axes[axis])) -} - -// SyncToCache syncs the entire input state to the C-side cache. -// Call this once before each Run() instead of having C call back into Go. +// SyncToCache syncs input state to C-side cache before Run(). func (s *InputState) SyncToCache() { - for port := uint(0); port < maxPort; port++ { - buttons := atomic.LoadUint32(&s[port].keys) - axis0 := C.int16_t(atomic.LoadInt32(&s[port].axes[0])) - axis1 := C.int16_t(atomic.LoadInt32(&s[port].axes[1])) - axis2 := C.int16_t(atomic.LoadInt32(&s[port].axes[2])) - axis3 := C.int16_t(atomic.LoadInt32(&s[port].axes[3])) - C.input_cache_set_port(C.uint(port), C.uint32_t(buttons), axis0, axis1, axis2, axis3) + for p := uint(0); p < maxPort; p++ { + a := &s[p].axes + C.input_cache_set_port(C.uint(p), C.uint32_t(atomic.LoadUint32(&s[p].keys)), + C.int16_t(atomic.LoadInt32(&a[0])), C.int16_t(atomic.LoadInt32(&a[1])), + C.int16_t(atomic.LoadInt32(&a[2])), C.int16_t(atomic.LoadInt32(&a[3]))) } } +// KeyboardState tracks keys of the keyboard. +type KeyboardState struct { + keys [6]atomic.Uint64 // 342 keys packed into 6 uint64s (384 bits) + mod atomic.Uint32 +} + // SetKey sets keyboard state. // -// 0 1 2 3 4 5 6 -// [ KEY ] P MOD +// [KEY:4][P:1][MOD:2] // -// KEY contains Libretro code of the keyboard key (4 bytes). -// P contains 0 or 1 if the key is pressed (1 byte). -// MOD contains bitmask for Alt | Ctrl | Meta | Shift keys press state (2 bytes). -// -// Returns decoded state from the input bytes. +// KEY - Libretro key code, P - pressed (0/1), MOD - modifier bitmask func (ks *KeyboardState) SetKey(data []byte) (pressed bool, key uint, mod uint16) { if len(data) != 7 { return } - - press := data[4] - pressed = press == 1 key = uint(binary.BigEndian.Uint32(data)) mod = binary.BigEndian.Uint16(data[5:]) - ks.mu.Lock() - ks.keys[key] = press - ks.mod = mod - ks.mu.Unlock() + pressed = data[4] == 1 + + idx, bit := key/64, uint64(1)<<(key%64) + if pressed { + ks.keys[idx].Or(bit) + } else { + ks.keys[idx].And(^bit) + } + ks.mod.Store(uint32(mod)) + return } -func (ks *KeyboardState) Pressed(key uint) C.int16_t { - ks.mu.Lock() - press := ks.keys[key] - ks.mu.Unlock() - if press == 1 { - return Pressed - } - return Released -} - -// SyncToCache syncs keyboard state to the C-side cache. +// SyncToCache syncs keyboard state to C-side cache. func (ks *KeyboardState) SyncToCache() { - ks.mu.Lock() - defer ks.mu.Unlock() - for id, pressed := range ks.keys { + for id := 0; id < RetrokLast; id++ { + pressed := (ks.keys[id/64].Load() >> (id % 64)) & 1 C.input_cache_set_keyboard_key(C.uint(id), C.uint8_t(pressed)) } } -// ShiftPos sets mouse relative position state. +// MouseState tracks mouse delta and buttons. +type MouseState struct { + dx, dy atomic.Int32 + buttons atomic.Int32 +} + +// ShiftPos adds relative mouse movement. // -// 0 1 2 3 -// [dx] [dy] -// -// dx and dy are relative mouse coordinates +// [dx:2][dy:2] func (ms *MouseState) ShiftPos(data []byte) { if len(data) != 4 { return } - dxy := binary.BigEndian.Uint32(data) - ms.dx.Add(int32(int16(dxy >> 16))) - ms.dy.Add(int32(int16(dxy))) + ms.dx.Add(int32(int16(binary.BigEndian.Uint16(data[:2])))) + ms.dy.Add(int32(int16(binary.BigEndian.Uint16(data[2:])))) } -func (ms *MouseState) PopX() C.int16_t { return C.int16_t(ms.dx.Swap(0)) } -func (ms *MouseState) PopY() C.int16_t { return C.int16_t(ms.dy.Swap(0)) } - -// SetButtons sets the state MouseBtnState of mouse buttons. -func (ms *MouseState) SetButtons(data byte) { ms.buttons.Store(int32(data)) } +func (ms *MouseState) SetButtons(b byte) { ms.buttons.Store(int32(b)) } func (ms *MouseState) Buttons() (l, r, m bool) { - mbs := MouseBtnState(ms.buttons.Load()) - l = mbs&MouseLeft != 0 - r = mbs&MouseRight != 0 - m = mbs&MouseMiddle != 0 - return + b := MouseBtnState(ms.buttons.Load()) + return b&MouseLeft != 0, b&MouseRight != 0, b&MouseMiddle != 0 } -// SyncToCache syncs mouse state to the C-side cache. -// This consumes the delta values (swaps to 0). +// SyncToCache syncs mouse state to C-side cache, consuming deltas. func (ms *MouseState) SyncToCache() { - dx := C.int16_t(ms.dx.Swap(0)) - dy := C.int16_t(ms.dy.Swap(0)) - buttons := C.uint8_t(ms.buttons.Load()) - C.input_cache_set_mouse(dx, dy, buttons) + C.input_cache_set_mouse(C.int16_t(ms.dx.Swap(0)), C.int16_t(ms.dy.Swap(0)), C.uint8_t(ms.buttons.Load())) } diff --git a/pkg/worker/caged/libretro/nanoarch/input_test.go b/pkg/worker/caged/libretro/nanoarch/input_test.go index 042d108e..4cda73f1 100644 --- a/pkg/worker/caged/libretro/nanoarch/input_test.go +++ b/pkg/worker/caged/libretro/nanoarch/input_test.go @@ -7,21 +7,215 @@ import ( "testing" ) -func TestConcurrentInput(t *testing.T) { +func TestInputState_SetInput(t *testing.T) { + tests := []struct { + name string + port int + data []byte + keys uint32 + axes [4]int32 + }{ + { + name: "buttons only", + port: 0, + data: []byte{0xFF, 0x01}, + keys: 0x01FF, + }, + { + name: "buttons and axes", + port: 1, + data: []byte{0x03, 0x00, 0x10, 0x27, 0xF0, 0xD8, 0x00, 0x80, 0xFF, 0x7F}, + keys: 0x0003, + axes: [4]int32{10000, -10000, -32768, 32767}, + }, + { + name: "partial axes", + port: 2, + data: []byte{0x01, 0x00, 0x64, 0x00}, + keys: 0x0001, + axes: [4]int32{100, 0, 0, 0}, + }, + { + name: "max port", + port: 3, + data: []byte{0xFF, 0xFF}, + keys: 0xFFFF, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + state := InputState{} + state.SetInput(test.port, test.data) + + if state[test.port].keys != test.keys { + t.Errorf("keys: got %v, want %v", state[test.port].keys, test.keys) + } + for i, want := range test.axes { + if state[test.port].axes[i] != want { + t.Errorf("axes[%d]: got %v, want %v", i, state[test.port].axes[i], want) + } + } + }) + } +} + +func TestInputState_Concurrent(t *testing.T) { var wg sync.WaitGroup state := InputState{} events := 1000 - wg.Add(2 * events) + wg.Add(events) for range events { player := rand.Intn(maxPort) - go func() { state.Input(player, []byte{0, 1}); wg.Done() }() - go func() { state.IsKeyPressed(uint(player), 100); wg.Done() }() + go func() { + state.SetInput(player, []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0}) + wg.Done() + }() } wg.Wait() } -func TestMousePos(t *testing.T) { +func TestKeyboardState_SetKey(t *testing.T) { + tests := []struct { + name string + data []byte + pressed bool + key uint + mod uint16 + }{ + { + name: "key pressed", + data: []byte{0, 0, 0, 42, 1, 0, 3}, + pressed: true, + key: 42, + mod: 3, + }, + { + name: "key released", + data: []byte{0, 0, 0, 100, 0, 0, 0}, + pressed: false, + key: 100, + mod: 0, + }, + { + name: "high key code", + data: []byte{0, 0, 1, 50, 1, 0xFF, 0xFF}, + pressed: true, + key: 306, + mod: 0xFFFF, + }, + { + name: "invalid length", + data: []byte{0, 0, 0}, + pressed: false, + key: 0, + mod: 0, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ks := KeyboardState{} + pressed, key, mod := ks.SetKey(test.data) + + if pressed != test.pressed { + t.Errorf("pressed: got %v, want %v", pressed, test.pressed) + } + if key != test.key { + t.Errorf("key: got %v, want %v", key, test.key) + } + if mod != test.mod { + t.Errorf("mod: got %v, want %v", mod, test.mod) + } + }) + } +} + +func TestKeyboardState_IsPressed(t *testing.T) { + ks := KeyboardState{} + + // Initially not pressed + if ks.keys[0].Load() != 0 { + t.Error("key should not be pressed initially") + } + + // Press key + ks.SetKey([]byte{0, 0, 0, 42, 1, 0, 0}) + if (ks.keys[42/64].Load()>>(42%64))&1 != 1 { + t.Error("key should be pressed") + } + + // Release key + ks.SetKey([]byte{0, 0, 0, 42, 0, 0, 0}) + if (ks.keys[42/64].Load()>>(42%64))&1 != 0 { + t.Error("key should be released") + } +} + +func TestKeyboardState_MultipleBits(t *testing.T) { + ks := KeyboardState{} + + // Press keys in different uint64 slots + keys := []uint{0, 63, 64, 127, 128, 200, 300, 341} + for _, k := range keys { + data := make([]byte, 7) + binary.BigEndian.PutUint32(data, uint32(k)) + data[4] = 1 + ks.SetKey(data) + } + + // Check all pressed + for _, k := range keys { + if (ks.keys[k/64].Load()>>(k%64))&1 != 1 { + t.Errorf("key %d should be pressed", k) + } + } + + // Release some + for _, k := range []uint{0, 128, 341} { + data := make([]byte, 7) + binary.BigEndian.PutUint32(data, uint32(k)) + data[4] = 0 + ks.SetKey(data) + } + + // Check states + expected := map[uint]uint64{ + 0: 0, 63: 1, 64: 1, 127: 1, 128: 0, 200: 1, 300: 1, 341: 0, + } + for k, want := range expected { + got := (ks.keys[k/64].Load() >> (k % 64)) & 1 + if got != want { + t.Errorf("key %d: got %v, want %v", k, got, want) + } + } +} + +func TestKeyboardState_Concurrent(t *testing.T) { + var wg sync.WaitGroup + ks := KeyboardState{} + events := 1000 + wg.Add(events * 2) + + for range events { + key := uint(rand.Intn(RetrokLast)) + go func() { + data := make([]byte, 7) + binary.BigEndian.PutUint32(data, uint32(key)) + data[4] = byte(rand.Intn(2)) + ks.SetKey(data) + wg.Done() + }() + go func() { + _ = (ks.keys[key/64].Load() >> (key % 64)) & 1 + wg.Done() + }() + } + wg.Wait() +} + +func TestMouseState_ShiftPos(t *testing.T) { tests := []struct { name string dx int16 @@ -30,42 +224,109 @@ func TestMousePos(t *testing.T) { ry int16 b func(dx, dy int16) []byte }{ - {name: "normal", dx: -10123, dy: 5678, rx: -10123, ry: 5678, b: func(dx, dy int16) []byte { - data := []byte{0, 0, 0, 0} - binary.BigEndian.PutUint16(data, uint16(dx)) - binary.BigEndian.PutUint16(data[2:], uint16(dy)) - return data - }}, - {name: "wrong endian", dx: -1234, dy: 5678, rx: 12027, ry: 11798, b: func(dx, dy int16) []byte { - data := []byte{0, 0, 0, 0} - binary.LittleEndian.PutUint16(data, uint16(dx)) - binary.LittleEndian.PutUint16(data[2:], uint16(dy)) - return data - }}, + { + name: "positive values", + dx: 100, + dy: 200, + rx: 100, + ry: 200, + b: func(dx, dy int16) []byte { + data := make([]byte, 4) + binary.BigEndian.PutUint16(data, uint16(dx)) + binary.BigEndian.PutUint16(data[2:], uint16(dy)) + return data + }, + }, + { + name: "negative values", + dx: -10123, + dy: 5678, + rx: -10123, + ry: 5678, + b: func(dx, dy int16) []byte { + data := make([]byte, 4) + binary.BigEndian.PutUint16(data, uint16(dx)) + binary.BigEndian.PutUint16(data[2:], uint16(dy)) + return data + }, + }, + { + name: "wrong endian", + dx: -1234, + dy: 5678, + rx: 12027, + ry: 11798, + b: func(dx, dy int16) []byte { + data := make([]byte, 4) + binary.LittleEndian.PutUint16(data, uint16(dx)) + binary.LittleEndian.PutUint16(data[2:], uint16(dy)) + return data + }, + }, + { + name: "max values", + dx: 32767, + dy: -32768, + rx: 32767, + ry: -32768, + b: func(dx, dy int16) []byte { + data := make([]byte, 4) + binary.BigEndian.PutUint16(data, uint16(dx)) + binary.BigEndian.PutUint16(data[2:], uint16(dy)) + return data + }, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - data := test.b(test.dx, test.dy) - ms := MouseState{} - ms.ShiftPos(data) + ms.ShiftPos(test.b(test.dx, test.dy)) - x := int16(ms.PopX()) - y := int16(ms.PopY()) + x, y := int16(ms.dx.Swap(0)), int16(ms.dy.Swap(0)) if x != test.rx || y != test.ry { - t.Errorf("invalid state, %v = %v, %v = %v", test.rx, x, test.ry, y) + t.Errorf("got (%v, %v), want (%v, %v)", x, y, test.rx, test.ry) } if ms.dx.Load() != 0 || ms.dy.Load() != 0 { - t.Errorf("coordinates weren't cleared") + t.Error("coordinates weren't cleared") } }) } } -func TestMouseButtons(t *testing.T) { +func TestMouseState_ShiftPosAccumulates(t *testing.T) { + ms := MouseState{} + + data := make([]byte, 4) + binary.BigEndian.PutUint16(data, uint16(10)) + binary.BigEndian.PutUint16(data[2:], uint16(20)) + + ms.ShiftPos(data) + ms.ShiftPos(data) + ms.ShiftPos(data) + + if got := ms.dx.Load(); got != 30 { + t.Errorf("dx: got %v, want 30", got) + } + if got := ms.dy.Load(); got != 60 { + t.Errorf("dy: got %v, want 60", got) + } +} + +func TestMouseState_ShiftPosInvalidLength(t *testing.T) { + ms := MouseState{} + + ms.ShiftPos([]byte{1, 2, 3}) + ms.ShiftPos([]byte{1, 2, 3, 4, 5}) + + if ms.dx.Load() != 0 || ms.dy.Load() != 0 { + t.Error("invalid data should be ignored") + } +} + +func TestMouseState_Buttons(t *testing.T) { tests := []struct { name string data byte @@ -73,10 +334,13 @@ func TestMouseButtons(t *testing.T) { r bool m bool }{ - {name: "l+r+m+", data: 1 + 2 + 4, l: true, r: true, m: true}, - {name: "l-r-m-", data: 0}, - {name: "l-r+m-", data: 2, r: true}, - {name: "l+r-m+", data: 1 + 4, l: true, m: true}, + {name: "none", data: 0}, + {name: "left", data: 1, l: true}, + {name: "right", data: 2, r: true}, + {name: "middle", data: 4, m: true}, + {name: "left+right", data: 3, l: true, r: true}, + {name: "all", data: 7, l: true, r: true, m: true}, + {name: "left+middle", data: 5, l: true, m: true}, } ms := MouseState{} @@ -86,8 +350,56 @@ func TestMouseButtons(t *testing.T) { ms.SetButtons(test.data) l, r, m := ms.Buttons() if l != test.l || r != test.r || m != test.m { - t.Errorf("wrong button state: %v -> %v, %v, %v", test.data, l, r, m) + t.Errorf("got (%v, %v, %v), want (%v, %v, %v)", l, r, m, test.l, test.r, test.m) } }) } } + +func TestMouseState_Concurrent(t *testing.T) { + var wg sync.WaitGroup + ms := MouseState{} + events := 1000 + wg.Add(events * 3) + + for range events { + go func() { + data := make([]byte, 4) + binary.BigEndian.PutUint16(data, uint16(rand.Int31n(100)-50)) + binary.BigEndian.PutUint16(data[2:], uint16(rand.Int31n(100)-50)) + ms.ShiftPos(data) + wg.Done() + }() + go func() { + ms.SetButtons(byte(rand.Intn(8))) + wg.Done() + }() + go func() { + ms.Buttons() + wg.Done() + }() + } + wg.Wait() +} + +func TestConstants(t *testing.T) { + // MouseBtnState + if MouseLeft != 1 || MouseRight != 2 || MouseMiddle != 4 { + t.Error("invalid MouseBtnState constants") + } + + // Device + if RetroPad != 0 || Keyboard != 1 || Mouse != 2 { + t.Error("invalid Device constants") + } + + // Mouse events + if MouseMove != 0 || MouseButton != 1 { + t.Error("invalid mouse event constants") + } + + // Limits + if maxPort != 4 || numAxes != 4 || RetrokLast != 342 { + t.Error("invalid limit constants") + } +} diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go index 6153f80e..5d34dca3 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.go @@ -426,7 +426,7 @@ func (n *Nanoarch) Run() { func (n *Nanoarch) IsSupported() error { return graphics.TryInit() } func (n *Nanoarch) IsGL() bool { return n.Video.gl.enabled } func (n *Nanoarch) IsStopped() bool { return n.Stopped.Load() } -func (n *Nanoarch) InputRetropad(port int, data []byte) { n.retropad.Input(port, data) } +func (n *Nanoarch) InputRetropad(port int, data []byte) { n.retropad.SetInput(port, data) } func (n *Nanoarch) InputKeyboard(_ int, data []byte) { if n.keyboardCb == nil { return