fixed various gamepad bugs in issue #193 (i think) and added unit tests

This commit is contained in:
Steven Hugg committed 2026-08-18 00:05:59 +02:00
1 parent 9491bdce74
commit b4ae363ebb
7 files changed
+299 -35

No files matched your search

+2
View File
@@ -941,11 +941,13 @@ export abstract class BaseMachinePlatform<T extends Machine> extends BaseDebugPl
resume() {
this.timer.start();
this.audio && this.audio.start();
this.poller && this.poller.start();
}
pause() {
this.timer.stop();
this.audio && this.audio.stop();
this.poller && this.poller.stop();
}
// so probe views stick around TODO: must be a better way?
+39 -24
View File
@@ -565,9 +565,13 @@ export function makeKeycodeMap(table: [KeyDef, number, number][]): KeyCodeMap {
return map;
}
// TODO: P2_GP_B and P2_GP_C share keycodes with P2_B and P2_A, so they can't
// be listed here without button 1/2 double-binding on player 2.
const DEFAULT_CONTROLLER_KEYS: KeyDef[] = [
Keys.UP, Keys.DOWN, Keys.LEFT, Keys.RIGHT, Keys.A, Keys.B, Keys.SELECT, Keys.START,
Keys.GP_A, Keys.GP_B, Keys.GP_C, Keys.GP_D, Keys.OPTION,
Keys.P2_UP, Keys.P2_DOWN, Keys.P2_LEFT, Keys.P2_RIGHT, Keys.P2_A, Keys.P2_B, Keys.P2_SELECT, Keys.P2_START,
Keys.P2_GP_A, Keys.P2_GP_D,
];
// Gamepad helper
@@ -589,21 +593,26 @@ export class ControllerPoller {
});
}
reset() {
this.active = typeof navigator.getGamepads === 'function';
if (this.active) {
let numGamepads = navigator.getGamepads().length;
if (typeof navigator.getGamepads === 'function') {
let numGamepads = navigator.getGamepads()?.length;
this.state = new Array(numGamepads);
this.lastState = new Array(numGamepads);
for (var i = 0; i < numGamepads; i++) {
this.state[i] = new Int32Array(64);
this.lastState[i] = new Int32Array(64);
}
console.log(this);
this.active = this.state?.length > 0;
}
}
start() {
this.reset();
}
stop() {
this.active = false;
}
poll() {
if (!this.active) return;
var gamepads = navigator.getGamepads();
var gamepads = navigator.getGamepads && navigator.getGamepads();
for (var gpi = 0; gpi < gamepads.length; gpi++) {
let state = this.state[gpi];
let lastState = this.lastState[gpi];
@@ -628,28 +637,34 @@ export class ControllerPoller {
}
handleStateChange(gpi: number, k: number) {
var axis = k - this.AXIS0;
var state = this.state[gpi][k];
var lastState = this.lastState[gpi][k];
// TODO: this is slow
for (var def of DEFAULT_CONTROLLER_KEYS) {
// is this a gamepad entry? same player #?
if (def && def.plyr == gpi) {
var code = def.c;
var state = this.state[gpi][k];
var lastState = this.lastState[gpi][k];
// check for button/axis match
if (k == def.button || (axis == 0 && def.xaxis == state) || (axis == 1 && def.yaxis == state)) {
//console.log("Gamepad", gpi, code, state);
if (state != 0) {
this.handler(code, 0, KeyFlags.KeyDown);
} else {
this.handler(code, 0, KeyFlags.KeyUp);
}
break;
}
// joystick released?
else if (state == 0 && (axis == 0 && def.xaxis == lastState) || (axis == 1 && def.yaxis == lastState)) {
this.handler(code, 0, KeyFlags.KeyUp);
break;
}
if (!def || def.plyr != gpi) continue;
// does this key map to the button/axis that changed?
// an axis has to be compared against the direction the key stands for,
// so that e.g. a left-to-right flick releases LEFT and presses RIGHT
var active: boolean, wasActive: boolean;
if (def.button != null && k == def.button) {
active = state != 0;
wasActive = lastState != 0;
} else if (axis == 0 && def.xaxis != null) {
active = def.xaxis == state;
wasActive = def.xaxis == lastState;
} else if (axis == 1 && def.yaxis != null) {
active = def.yaxis == state;
wasActive = def.yaxis == lastState;
} else {
continue;
}
// don't break -- several keys can share a button (e.g. A and GP_A)
//console.log("Gamepad", gpi, def.c, state);
if (active && !wasActive) {
this.handler(def.c, 0, KeyFlags.KeyDown);
} else if (!active && wasActive) {
this.handler(def.c, 0, KeyFlags.KeyUp);
}
}
}
+12 -6
View File
@@ -47,7 +47,7 @@ var AtariVectorPlatform = function(mainElement) {
var cpuCyclesPerNMI = Math.round(cpuFrequency*12/(XTAL/4096)); // ~250 Hz
var cpuCyclesPerFrame = Math.round(cpuFrequency/60);
var cpu, cpuram, dvgram, rom, vecrom, bus, dvg;
var video, audio, timer;
var video, audio, timer, poller;
var clock;
var watchdog = 0;
var switches = new RAM(16).mem;
@@ -98,9 +98,11 @@ var AtariVectorPlatform = function(mainElement) {
audio = newPOKEYAudio(2);
video.create();
timer = new AnimationTimer(60, this.nextFrame.bind(this));
setKeyboardFromMap(video, switches, ASTEROIDS_KEYCODE_MAP);
poller = setKeyboardFromMap(video, switches, ASTEROIDS_KEYCODE_MAP);
}
this.pollControls = function() { poller && poller.poll(); }
this.advance = (novideo) => {
if (!novideo) video.clear();
var debugCond = this.getDebugCallback();
@@ -189,7 +191,7 @@ var AtariColorVectorPlatform = function(mainElement) {
var cpuCyclesPerNMI = Math.round(cpuFrequency / nmiFrequency);
var cpuCyclesPerFrame = Math.round(cpuFrequency / 60);
var cpu, cpuram, dvgram, rom, vecrom, bus, dvg, earom;
var video, audio, timer;
var video, audio, timer, poller;
var clock;
var switches = new RAM(16).mem;
var nmicount = cpuCyclesPerNMI;
@@ -259,9 +261,11 @@ var AtariColorVectorPlatform = function(mainElement) {
audio = newPOKEYAudio(2);
video.create();
timer = new AnimationTimer(60, this.nextFrame.bind(this));
setKeyboardFromMap(video, switches, GRAVITAR_KEYCODE_MAP);
poller = setKeyboardFromMap(video, switches, GRAVITAR_KEYCODE_MAP);
}
this.pollControls = function() { poller && poller.poll(); }
this.advance = (novideo) => {
if (!novideo) video.clear();
var debugCond = this.getDebugCallback();
@@ -343,7 +347,7 @@ var Z80ColorVectorPlatform = function(mainElement, proto) {
var cpuFrequency = 4000000.0;
var cpuCyclesPerFrame = Math.round(cpuFrequency/60);
var cpu, cpuram, dvgram, rom, bus, dvg;
var video, audio, timer;
var video, audio, timer, poller;
var clock;
var switches = new RAM(16).mem;
var mathram = new RAM(16).mem;
@@ -404,9 +408,11 @@ var Z80ColorVectorPlatform = function(mainElement, proto) {
audio = newPOKEYAudio(2);
video.create();
timer = new AnimationTimer(60, this.nextFrame.bind(this));
setKeyboardFromMap(video, switches, GRAVITAR_KEYCODE_MAP);
poller = setKeyboardFromMap(video, switches, GRAVITAR_KEYCODE_MAP);
}
this.pollControls = function() { poller && poller.poll(); }
this.advance = (novideo) => {
if (!novideo) video.clear();
this.runCPU(cpu, cpuCyclesPerFrame);
+5 -2
View File
@@ -1,6 +1,6 @@
import { Platform, BaseZ80Platform, Base6502Platform, Base6809Platform } from "../common/baseplatform";
import { PLATFORMS, newAddressDecoder, padBytes, noise, setKeyboardFromMap, AnimationTimer, VectorVideo, Keys, makeKeycodeMap } from "../common/emu";
import { PLATFORMS, newAddressDecoder, padBytes, noise, setKeyboardFromMap, AnimationTimer, VectorVideo, Keys, makeKeycodeMap, ControllerPoller } from "../common/emu";
import { hex, lzgmini, stringToByteArray, safe_extend } from "../common/util";
import { MasterAudio, AY38910_Audio } from "../common/audio";
import { ProbeRecorder } from "../common/probe";
@@ -785,6 +785,7 @@ class VectrexPlatform extends Base6809Platform {
psg: AY38910_Audio;
audio;
timer: AnimationTimer;
poller: ControllerPoller;
constructor(mainElement) {
super();
@@ -831,9 +832,11 @@ class VectrexPlatform extends Base6809Platform {
this.psg = new AY38910_Audio(this.audio);
this.video.create();
this.timer = new AnimationTimer(60, this.nextFrame.bind(this));
setKeyboardFromMap(this.video, this.inputs, VECTREX_KEYCODE_MAP); // true = always send function);
this.poller = setKeyboardFromMap(this.video, this.inputs, VECTREX_KEYCODE_MAP); // true = always send function);
}
pollControls() { this.poller && this.poller.poll(); }
// TODO: loadControlsState
updateControls() {
// joystick (analog simulation)
+4 -2
View File
@@ -31,7 +31,7 @@ var WilliamsPlatform = function(mainElement, proto, options) {
var pia6821 = new RAM(8).mem;
var blitregs = new RAM(8).mem;
var video, timer, pixels, displayPCs;
var video, timer, pixels, displayPCs, poller;
var screenNeedsRefresh = false;
var membus;
var video_counter;
@@ -317,11 +317,13 @@ var WilliamsPlatform = function(mainElement, proto, options) {
if (displayPCs) console.log(x, y, hex(addr, 4), "PC", hex(displayPCs[addr], 4));
});
var idata = video.getFrameData();
setKeyboardFromMap(video, pia6821, KEYCODE_MAP);
poller = setKeyboardFromMap(video, pia6821, KEYCODE_MAP);
pixels = video.getFrameData();
timer = new AnimationTimer(60, this.nextFrame.bind(this));
}
this.pollControls = function() { poller && poller.poll(); }
this.getRasterScanline = function() { return video_counter; }
this.advance = function(novideo: boolean) {
+87
View File
@@ -404,3 +404,90 @@ describe('Platform Replay', () => {
});
});
});
describe('Platform Input', () => {
// node's global navigator is a getter, so it has to be replaced this way
const realNavigator = Object.getOwnPropertyDescriptor(global, 'navigator');
let pads;
function fakePads() {
pads = [0, 1].map(() => ({
axes: [0, 0],
buttons: new Array(16).fill(null).map(() => ({ pressed: false })),
}));
Object.defineProperty(global, 'navigator', {
value: { getGamepads: () => pads }, configurable: true, writable: true
});
}
after(() => {
if (realNavigator === undefined) delete global.navigator;
else Object.defineProperty(global, 'navigator', realNavigator);
});
// starts a platform with a gamepad plugged in, then runs `press` (which can
// poke the pads or call keycallback) and reports the input port before/after
async function withGamepad(platid, romname, press, addr) {
fakePads();
var platform = new emu.PLATFORMS[platid](document.getElementById('emulator'));
await platform.start();
platform.loadROM("ROM", new Uint8Array(fs.readFileSync('./test/roms/' + platid + '/' + romname)));
platform.resume();
// the poller only wakes up once a pad announces itself
window.dispatchEvent(new window.Event('gamepadconnected'));
for (var i = 0; i < 4; i++) platform.nextFrame();
var before = platform.readAddress(addr);
press(pads);
platform.nextFrame();
var after = platform.readAddress(addr);
platform.pause();
return { before, after };
}
// Robotron-style twin stick: player 1's stick is bound to the P2_* keys,
// so it comes from gamepad #1, and firing comes from gamepad #0
it('Should read a gamepad on williams-z80', async () => {
var r = await withGamepad('williams-z80', 'game1.c.rom',
(pads) => { pads[1].axes[0] = -1; }, 0xc804); // input0, LEFT1 = 0x4
assert.equal(r.before & 0x4, 0);
assert.equal(r.after & 0x4, 0x4, "gamepad left should set LEFT1");
});
it('Should read gamepad buttons on williams-z80', async () => {
var r = await withGamepad('williams-z80', 'game1.c.rom',
(pads) => { pads[0].buttons[8].pressed = true; }, 0xc80c); // input2, COIN1 = 0x10
assert.equal(r.before & 0x10, 0);
assert.equal(r.after & 0x10, 0x10, "gamepad button 8 should set COIN1");
});
// GRAVITAR_KEYCODE_MAP is active low, so a press clears the bit
it('Should read a gamepad on vector-z80color', async () => {
var r = await withGamepad('vector-z80color', 'game.c.rom',
(pads) => { pads[0].axes[0] = -1; }, 0x8001); // input1, LEFT1 = 0x8
assert.equal(r.before & 0x8, 0x8);
assert.equal(r.after & 0x8, 0, "gamepad left should clear LEFT1");
});
it('Should read gamepad axis 1 on vector-z80color', async () => {
var r = await withGamepad('vector-z80color', 'game.c.rom',
(pads) => { pads[0].axes[1] = -1; }, 0x8001); // input1, UP1 = 0x10
assert.equal(r.before & 0x10, 0x10);
assert.equal(r.after & 0x10, 0, "gamepad up should clear UP1");
});
// the coin slots are on the number keys, which no gamepad button reaches
it('Should read the coin keys on vector-z80color', async () => {
var r = await withGamepad('vector-z80color', 'game.c.rom',
() => { keycallback(Keys.VK_5.c, Keys.VK_5.c, 1); }, 0x8000); // input0, COIN1 = 0x2
assert.equal(r.before & 0x2, 0);
assert.equal(r.after & 0x2, 0x2, "'5' should set COIN1");
});
it('Should read the coin keys on williams-z80', async () => {
var r = await withGamepad('williams-z80', 'game1.c.rom',
() => { keycallback(Keys.SELECT.c, Keys.SELECT.c, 1); }, 0xc80c); // input2, COIN1 = 0x10
assert.equal(r.before & 0x10, 0);
assert.equal(r.after & 0x10, 0x10, "'\\' should set COIN1");
});
});
+150 -1
View File
@@ -4,7 +4,7 @@ import {
noise, getNoiseSeed, setNoiseSeed, KeyFlags, RAM, EmuHalt,
AnimationTimer, dumpRAM, Keys, makeKeycodeMap, newKeyboardHandler,
_setKeyboardEvents, padBytes, AddressDecoder, newAddressDecoder,
getMousePos, drawCrosshair,
getMousePos, drawCrosshair, ControllerPoller,
} from "../../src/common/emu";
describe('Noise PRNG', function () {
@@ -469,6 +469,155 @@ describe('drawCrosshair', function () {
});
});
describe('ControllerPoller', function () {
const realWindow = (global as any).window;
// node defines a getter-only global navigator, so it can't just be assigned
const realNavigator = Object.getOwnPropertyDescriptor(global, 'navigator');
function setNavigator(nav) {
Object.defineProperty(global, 'navigator', { value: nav, configurable: true, writable: true });
}
let pads: any[];
let listeners: { [name: string]: (e: any) => void };
let events: [number, number][];
// builds a poller wired to two fake standard-layout gamepads
function newPoller() {
pads = [0, 1].map(() => ({
axes: [0, 0],
buttons: new Array(16).fill(null).map(() => ({ pressed: false })),
}));
listeners = {};
events = [];
(global as any).window = { addEventListener: (n, f) => { listeners[n] = f; } };
setNavigator({ getGamepads: () => pads });
return new ControllerPoller((key, code, flags) => { events.push([key, flags]); });
}
// polls and returns the keys that went down/up since the last call
function poll(poller: ControllerPoller) {
events = [];
poller.poll();
return events;
}
function down(k) { return [k.c, KeyFlags.KeyDown]; }
function up(k) { return [k.c, KeyFlags.KeyUp]; }
after(function () {
if (realWindow === undefined) delete (global as any).window;
else (global as any).window = realWindow;
if (realNavigator === undefined) delete (global as any).navigator;
else Object.defineProperty(global, 'navigator', realNavigator);
});
it('should stay inactive until a gamepad connects', function () {
const poller = newPoller();
assert.strictEqual(poller.active, false);
pads[0].buttons[0].pressed = true;
assert.deepStrictEqual(poll(poller), []); // no state array yet, must not throw
});
it('should activate on the gamepadconnected event', function () {
const poller = newPoller();
listeners['gamepadconnected']({});
assert.strictEqual(poller.active, true);
assert.deepStrictEqual(poll(poller), []); // nothing pressed yet
});
it('should report button presses and releases once each', function () {
const poller = newPoller();
listeners['gamepadconnected']({});
poll(poller);
pads[0].buttons[9].pressed = true;
assert.deepStrictEqual(poll(poller), [down(Keys.START)]);
assert.deepStrictEqual(poll(poller), []); // held, not re-sent
pads[0].buttons[9].pressed = false;
assert.deepStrictEqual(poll(poller), [up(Keys.START)]);
});
it('should send every key sharing a button, not just the first', function () {
// A and GP_A are both button 0 -- platforms bind one or the other
const poller = newPoller();
listeners['gamepadconnected']({});
poll(poller);
pads[0].buttons[0].pressed = true;
assert.deepStrictEqual(poll(poller), [down(Keys.A), down(Keys.GP_A)]);
});
it('should map the face buttons past B', function () {
const poller = newPoller();
listeners['gamepadconnected']({});
poll(poller);
pads[0].buttons[2].pressed = true;
pads[0].buttons[3].pressed = true;
assert.deepStrictEqual(poll(poller), [down(Keys.GP_C), down(Keys.GP_D)]);
});
it('should translate axes into direction keys', function () {
const poller = newPoller();
listeners['gamepadconnected']({});
poll(poller);
pads[0].axes[0] = -1;
assert.deepStrictEqual(poll(poller), [down(Keys.LEFT)]);
pads[0].axes[0] = 0;
assert.deepStrictEqual(poll(poller), [up(Keys.LEFT)]);
pads[0].axes[1] = 1;
assert.deepStrictEqual(poll(poller), [down(Keys.DOWN)]);
pads[0].axes[1] = 0;
assert.deepStrictEqual(poll(poller), [up(Keys.DOWN)]);
});
it('should release the old direction when an axis flicks across center', function () {
const poller = newPoller();
listeners['gamepadconnected']({});
poll(poller);
pads[0].axes[0] = -1;
assert.deepStrictEqual(poll(poller), [down(Keys.LEFT)]);
// -1 to +1 in one poll: LEFT must not stay stuck down
pads[0].axes[0] = 1;
assert.deepStrictEqual(poll(poller), [up(Keys.LEFT), down(Keys.RIGHT)]);
pads[0].axes[1] = -1;
assert.deepStrictEqual(poll(poller), [down(Keys.UP)]);
pads[0].axes[1] = 1;
assert.deepStrictEqual(poll(poller), [up(Keys.UP), down(Keys.DOWN)]);
});
it('should route the second gamepad to the player 2 keys', function () {
const poller = newPoller();
listeners['gamepadconnected']({});
poll(poller);
pads[1].axes[1] = -1;
pads[1].buttons[9].pressed = true;
assert.deepStrictEqual(poll(poller), [down(Keys.P2_UP), down(Keys.P2_START)]);
// player 1 keys must not fire for player 2's pad
assert.ok(!events.some(e => e[0] == Keys.UP.c || e[0] == Keys.START.c));
});
it('should ignore axes a key does not use', function () {
// A is button-only, so it must not react to any axis movement
const poller = newPoller();
listeners['gamepadconnected']({});
poll(poller);
pads[0].axes[0] = 1;
pads[0].axes[1] = 1;
const codes = poll(poller).map(e => e[0]);
assert.ok(!codes.includes(Keys.A.c));
assert.deepStrictEqual(codes, [Keys.RIGHT.c, Keys.DOWN.c]); // x axis polled before y
});
it('should drop gamepad state on disconnect', function () {
const poller = newPoller();
listeners['gamepadconnected']({});
poll(poller);
pads[0].buttons[0].pressed = true;
poll(poller);
listeners['gamepaddisconnected']({});
// state is cleared, so a held button reads as a fresh press
assert.deepStrictEqual(poll(poller), [down(Keys.A), down(Keys.GP_A)]);
});
});
describe('getMousePos', function () {
const realWindow = (global as any).window;