KPJoy/kpjoy.c
2026-10-13 14:05:43 -05:00

591 lines
20 KiB
C

// Part of KPJOY, Copyright (c) 2026 Scott Duensing, under the MIT License.
// See LICENSE.md for license information.
// SPDX-License-Identifier: MIT
//
// kpjoy.c -- KPJOY.DRV, a joystick driver for Windows 3.1: the PC game
// port's two sticks for MMSYSTEM's joystick functions (joyGetPos,
// joyGetDevCaps and the rest).
//
// Windows 3.1 has the joystick API but no joystick driver of its own;
// MMSYSTEM loads the one SYSTEM.INI names ([drivers] joystick=kpjoy.drv)
// as an installable driver and talks to it through DriverProc.
//
// The game port (I/O port 201h): writing it fires four one-shots, one per
// axis (bits 0-3: stick A's X and Y, stick B's X and Y), each of which
// holds its bit at 1 for a time set by the axis's position, about 24 us at
// one end to about 1.35 ms at the other. Bits 4-7 are the buttons (stick
// A's 1 and 2, stick B's 1 and 2), 0 while pressed. An axis with nothing
// plugged in never clears.
//
// The driver times each axis by counting reads of the port until its bit
// clears. The counts depend on the CPU's speed, so they are only ever used
// against each other: an axis's rest is taken from its first readings, as
// the stick is centred then, and a position is reported in 0..65535 with
// the rest at 32768 (the stick's travel to either side is learned as it
// moves). Anything that takes the CPU away during a count (an interrupt,
// an emulator slowing down) only ever shortens it, so the rest settles on
// the longest of the first readings that are not a push; and readings
// within DEAD_BAND_PERCENT of the rest report exactly 32768, so a centred
// stick reads centred through that jitter. A program, or a calibration
// tool, can set explicit calibration instead (JDD_SETCALIBRATION), which
// is used as it is. How long a missing axis is waited for is measured
// against the BIOS tick at enable, so it does not depend on the CPU either.
//
// One read of the port times all four axes, and serves both sticks: a
// program asking for stick A and then stick B gets the same reading, so the
// port is busy-waited on once per pair. A stick that is not there is looked
// for again now and then, so plugging one in is noticed.
//
// The code and data are fixed: joySetCapture's timer calls in at interrupt
// time.
#include <windows.h>
#include <mmsystem.h>
#include <conio.h>
#include <stdbool.h>
#include <stdint.h>
// ----- Constants -----
// Logging to C:\KPJOY.LOG (build with -DKPJOY_LOG=1): every message but
// JDD_GETPOS, and each change in what a stick reports, with its counts.
#ifndef KPJOY_LOG
#define KPJOY_LOG 0
#endif
// The joystick driver messages (the Windows 3.1 DDK's mmddk.h).
#define JDD_GETNUMDEVS (DRV_RESERVED + 0x0001)
#define JDD_GETDEVCAPS (DRV_RESERVED + 0x0002)
#define JDD_GETPOS (DRV_RESERVED + 0x0101)
#define JDD_SETCALIBRATION (DRV_RESERVED + 0x0102)
// The game port and its bits.
#define GAME_PORT 0x201
#define AXIS_COUNT 4
#define BUTTON_SHIFT 4
#define BUTTON_MASK_ALL 0x0F
#define BUTTONS_PER_STICK 2
#define BUTTON_STICK_MASK 0x03
// Two sticks of two axes each: stick A is axes 0 and 1, stick B 2 and 3.
#define STICK_COUNT 2
#define AXES_PER_STICK 2
#define STICK_AXIS_MASK 0x03
#define AXIS_X 0
#define AXIS_Y 1
// Positions as JOYCAPS reports their range.
#define POS_MIN 0u
#define POS_MAX 65535u
#define POS_REST 32768u
// A full push to one side is taken to be at least twice the rest's count
// until the stick shows more.
#define TRAVEL_FACTOR 2u
// The rest settles over an axis's first readings: one longer than the rest
// so far is the rest unless it is half as long again (a push).
#define REST_SETTLE_READINGS 64u
#define REST_PUSH_NUMERATOR 3u
#define REST_PUSH_DENOMINATOR 2u
// Readings this close to the rest (percent of the travel to either end)
// report the rest itself.
#define DEAD_BAND_PERCENT 4u
// (In 32 bits: an int is 16 here, and POS_REST * 4 would overflow it.)
#define DEAD_BAND ((uint16_t)((uint32_t)POS_REST * DEAD_BAND_PERCENT / 100u))
// A missing axis is waited for this long; the longest a real one holds its
// bit is about 1.35 ms.
#define TIMEOUT_MS 4u
// The BIOS tick: its count's place in the BIOS data area, and its period.
#define BIOS_DATA_SEGMENT 0x0040
#define BIOS_TICK_OFFSET 0x006C
#define BIOS_TICK_MS 55u
// Reads of the port counted while waiting for a tick, at most: a tick that
// never comes must not hang the enable.
#define CALIBRATE_READS_MAX 50000000UL
// The fewest reads a timeout is made of, whatever the measurement says.
#define TIMEOUT_READS_MIN 1000UL
// DPMI: a selector for a real-mode segment.
#define DPMI_INT 0x31
#define DPMI_SEG_TO_DESC 0x0002
// A missing stick is looked for again after this many readings.
#define PROBE_READINGS 64u
// What JOYCAPS says of the driver: no registered manufacturer or product,
// and the polling periods joySetCapture may ask for (ms).
#define KPJOY_MID 0
#define KPJOY_PID 0
#define KPJOY_NAME "KPJOY Game Port Joystick"
#define PERIOD_MIN_MS 10
#define PERIOD_MAX_MS 1000
#if KPJOY_LOG
#define LOG_FILE "C:\\KPJOY.LOG"
#define LOG_LINE_MAX 160
#define LOG_SEEK_END 2
// A line: wsprintf into gLogLine (DGROUP: with SS != DS a buffer on the
// stack cannot be handed to Windows by a near pointer), then appended.
#define LOG(...) logWrite(wsprintf(gLogLine, __VA_ARGS__))
#else
#define LOG(...)
#endif
// ----- Types -----
// Calibration as JDD_SETCALIBRATION gives it (mmddk.h's JOYCALIBRATE):
// position = (count - base) * delta, for X, Y and Z.
typedef struct {
uint16_t wXbase;
uint16_t wXdelta;
uint16_t wYbase;
uint16_t wYdelta;
uint16_t wZbase;
uint16_t wZdelta;
} JoyCalibrateT;
// What is known of one axis: its rest, the readings left for it to
// settle, and the longest reading yet.
typedef struct {
bool restKnown;
uint8_t settle;
uint32_t rest;
uint32_t high;
} AxisT;
// One stick: whether it is there, whether it has explicit calibration and
// what, and whether it has yet to report the last reading.
typedef struct {
bool present;
bool calibrated;
bool fresh;
JoyCalibrateT calibration;
} StickT;
// ----- Prototypes -----
static uint16_t axisPosition(uint8_t axis, uint32_t count);
static uint16_t biosSelector(void);
static uint16_t biosTick(void);
static uint16_t calibratedPosition(uint32_t count, uint16_t base, uint16_t delta);
static void driverEnable(void);
LRESULT FAR PASCAL _export DriverProc(DWORD driverId, HDRVR driver, UINT message, LPARAM param1, LPARAM param2);
static LRESULT getDevCaps(JOYCAPS FAR *caps, UINT size);
static LRESULT getPos(uint8_t stick, JOYINFO FAR *info);
int FAR PASCAL LibMain(HINSTANCE instance, WORD dataSeg, WORD heapSize, LPSTR cmdLine);
#if KPJOY_LOG
static void logWrite(int length);
#endif
static uint16_t lowPosition(uint32_t count, uint32_t rest);
static void readPort(void);
static LRESULT setCalibration(uint8_t stick, const JoyCalibrateT FAR *set, JoyCalibrateT FAR *previous);
static uint8_t stickOf(DWORD driverId);
int FAR PASCAL _export WEP(int param);
// ----- Module state -----
static StickT gSticks[STICK_COUNT];
static AxisT gAxes[AXIS_COUNT];
// The last reading: each axis's count (gTimeoutReads if it never cleared)
// and the button bits (1 = pressed, stick A's in bits 0-1, B's in 2-3).
static uint32_t gCounts[AXIS_COUNT];
static uint8_t gButtons;
static uint32_t gTimeoutReads = TIMEOUT_READS_MIN;
static uint16_t gReadings;
static uint16_t gBiosSelector;
// JDD_GETDEVCAPS's answer, in DGROUP (see LOG).
static JOYCAPS gCaps;
#if KPJOY_LOG
// What each stick last reported.
static UINT gLogX[STICK_COUNT];
static UINT gLogY[STICK_COUNT];
static UINT gLogButtons[STICK_COUNT];
static char gLogLine[LOG_LINE_MAX];
static OFSTRUCT gLogFile;
#endif
// Open Watcom's license (the Sybase Open Watcom Public License, section
// 2.2(d)) asks for a notice, in the driver and in its documentation, that the
// source of the Open Watcom code it carries (its DLL start-up and C library
// helpers) is available, and where. This is the driver's copy, never shown;
// it is not static, so the compiler keeps it although nothing reads it.
// README.md gives the same text for the documentation.
const char owplNotice[] = "This software contains code from Open Watcom. Its source code is available under the terms of the Sybase Open Watcom Public License version 1.0 from https://github.com/open-watcom/open-watcom-v2";
// ----- Functions (alphabetical) -----
// An axis's count as a position: explicit calibration if its stick has
// one, else around the rest.
static uint16_t axisPosition(uint8_t axis, uint32_t count) {
AxisT *a;
StickT *s;
bool isY;
uint16_t travel;
uint16_t position;
a = &gAxes[axis];
s = &gSticks[axis / AXES_PER_STICK];
isY = (axis % AXES_PER_STICK) == AXIS_Y;
if (s->calibrated) {
if (isY) {
return calibratedPosition(count, s->calibration.wYbase, s->calibration.wYdelta);
}
return calibratedPosition(count, s->calibration.wXbase, s->calibration.wXdelta);
}
if (!a->restKnown) {
a->rest = count;
a->high = count * TRAVEL_FACTOR;
a->settle = REST_SETTLE_READINGS;
a->restKnown = true;
} else if (a->settle > 0) {
a->settle--;
if (count > a->rest && count * REST_PUSH_DENOMINATOR < a->rest * REST_PUSH_NUMERATOR) {
a->rest = count;
if (a->high < count * TRAVEL_FACTOR) {
a->high = count * TRAVEL_FACTOR;
}
}
}
if (count > a->high) {
a->high = count;
}
if (count <= a->rest) {
position = lowPosition(count, a->rest);
} else if (a->high <= a->rest) {
position = (uint16_t)POS_MAX;
} else {
travel = lowPosition(count - a->rest, a->high - a->rest);
if (travel > POS_MAX - POS_REST) {
travel = (uint16_t)(POS_MAX - POS_REST);
}
position = (uint16_t)(POS_REST + travel);
}
if (position >= POS_REST - DEAD_BAND && position <= POS_REST + DEAD_BAND) {
return (uint16_t)POS_REST;
}
return position;
}
// A selector for the BIOS data area, from DPMI, made once.
static uint16_t biosSelector(void) {
uint16_t sel;
if (gBiosSelector == 0) {
// 0 stays when DPMI refuses (carry set).
sel = 0;
_asm {
mov ax, DPMI_SEG_TO_DESC
mov bx, BIOS_DATA_SEGMENT
int DPMI_INT
jc refused
mov sel, ax
refused:
}
gBiosSelector = sel;
}
return gBiosSelector;
}
// The BIOS tick count's low word.
static uint16_t biosTick(void) {
uint16_t FAR *tick;
tick = (uint16_t FAR *)MAKELP(biosSelector(), BIOS_TICK_OFFSET);
return *tick;
}
// position = (count - base) * delta, kept to 0..65535.
static uint16_t calibratedPosition(uint32_t count, uint16_t base, uint16_t delta) {
uint32_t position;
if (count <= base) {
return (uint16_t)POS_MIN;
}
count -= base;
if (count > POS_MAX) {
return (uint16_t)POS_MAX;
}
position = count * delta;
if (position > POS_MAX) {
return (uint16_t)POS_MAX;
}
return (uint16_t)position;
}
// DRV_ENABLE: how many reads of the port make TIMEOUT_MS, counted over one
// whole BIOS tick (the first loop waits for a tick to begin), then a first
// look for both sticks. Rests and calibration start again.
static void driverEnable(void) {
uint16_t start;
uint32_t reads;
uint8_t i;
reads = 0;
if (biosSelector() != 0) {
start = biosTick();
while (biosTick() == start && reads < CALIBRATE_READS_MAX) {
reads++;
}
start = biosTick();
reads = 0;
while (biosTick() == start && reads < CALIBRATE_READS_MAX) {
(void)inp(GAME_PORT);
reads++;
}
}
gTimeoutReads = reads / BIOS_TICK_MS * TIMEOUT_MS;
if (gTimeoutReads < TIMEOUT_READS_MIN) {
gTimeoutReads = TIMEOUT_READS_MIN;
}
for (i = 0; i < AXIS_COUNT; i++) {
gAxes[i].restKnown = false;
}
for (i = 0; i < STICK_COUNT; i++) {
gSticks[i].present = true;
gSticks[i].calibrated = false;
gSticks[i].fresh = false;
}
gReadings = 0;
readPort();
LOG("enable: %lu reads per tick, timeout %lu reads; sticks %d %d", reads, gTimeoutReads, (int)gSticks[0].present, (int)gSticks[1].present);
}
LRESULT FAR PASCAL _export DriverProc(DWORD driverId, HDRVR driver, UINT message, LPARAM param1, LPARAM param2) {
if (message != JDD_GETPOS) {
LOG("message %04x id %lx param1 %lx param2 %lx", message, driverId, param1, param2);
}
switch (message) {
case DRV_LOAD:
case DRV_FREE:
case DRV_CLOSE:
case DRV_DISABLE:
return 1L;
case DRV_ENABLE:
driverEnable();
return 1L;
case DRV_OPEN:
// MMSYSTEM opens an instance for each stick, lParam2 0 and 1; the
// answer is the ID the stick's messages come with (stickOf).
if (param2 >= 0 && param2 < STICK_COUNT) {
return (LRESULT)(param2 + 1);
}
return 1L;
case DRV_QUERYCONFIGURE:
return 0L;
case DRV_INSTALL:
case DRV_REMOVE:
return (LRESULT)DRVCNF_OK;
case JDD_GETNUMDEVS:
return (LRESULT)STICK_COUNT;
case JDD_GETDEVCAPS:
return getDevCaps((JOYCAPS FAR *)param1, (UINT)param2);
case JDD_GETPOS:
return getPos(stickOf(driverId), (JOYINFO FAR *)param1);
case JDD_SETCALIBRATION:
return setCalibration(stickOf(driverId), (const JoyCalibrateT FAR *)param1, (JoyCalibrateT FAR *)param2);
default:
return DefDriverProc(driverId, driver, message, param1, param2);
}
}
// JDD_GETDEVCAPS: as much of JOYCAPS as the caller's size holds.
static LRESULT getDevCaps(JOYCAPS FAR *caps, UINT size) {
const char *from;
char *to;
UINT i;
if (caps == NULL) {
return (LRESULT)JOYERR_PARMS;
}
gCaps.wMid = KPJOY_MID;
gCaps.wPid = KPJOY_PID;
gCaps.wXmin = POS_MIN;
gCaps.wXmax = POS_MAX;
gCaps.wYmin = POS_MIN;
gCaps.wYmax = POS_MAX;
gCaps.wZmin = POS_MIN;
gCaps.wZmax = POS_MIN;
gCaps.wNumButtons = BUTTONS_PER_STICK;
gCaps.wPeriodMin = PERIOD_MIN_MS;
gCaps.wPeriodMax = PERIOD_MAX_MS;
from = KPJOY_NAME;
to = gCaps.szPname;
while (*from != '\0' && to < gCaps.szPname + MAXPNAMELEN - 1) {
*to++ = *from++;
}
*to = '\0';
for (i = 0; i < size && i < sizeof(gCaps); i++) {
((char FAR *)caps)[i] = ((char *)&gCaps)[i];
}
return (LRESULT)JOYERR_NOERROR;
}
// JDD_GETPOS: a new reading of the port unless this stick has not had the
// last one yet.
static LRESULT getPos(uint8_t stick, JOYINFO FAR *info) {
StickT *s;
uint8_t first;
if (info == NULL) {
return (LRESULT)JOYERR_PARMS;
}
s = &gSticks[stick];
if (!s->fresh) {
readPort();
}
s->fresh = false;
if (!s->present) {
return (LRESULT)JOYERR_UNPLUGGED;
}
first = (uint8_t)(stick * AXES_PER_STICK);
info->wXpos = axisPosition((uint8_t)(first + AXIS_X), gCounts[first + AXIS_X]);
info->wYpos = axisPosition((uint8_t)(first + AXIS_Y), gCounts[first + AXIS_Y]);
info->wZpos = 0;
info->wButtons = (gButtons >> (stick * BUTTONS_PER_STICK)) & BUTTON_STICK_MASK;
#if KPJOY_LOG
if (info->wXpos != gLogX[stick] || info->wYpos != gLogY[stick] || info->wButtons != gLogButtons[stick]) {
LOG("reading %u: stick %d at %u %u buttons %u, counts %lu %lu, rests %lu %lu", gReadings, (int)stick, info->wXpos, info->wYpos, info->wButtons, gCounts[first + AXIS_X], gCounts[first + AXIS_Y], gAxes[first + AXIS_X].rest, gAxes[first + AXIS_Y].rest);
gLogX[stick] = info->wXpos;
gLogY[stick] = info->wYpos;
gLogButtons[stick] = info->wButtons;
}
#endif
return (LRESULT)JOYERR_NOERROR;
}
int FAR PASCAL LibMain(HINSTANCE instance, WORD dataSeg, WORD heapSize, LPSTR cmdLine) {
(void)instance;
(void)dataSeg;
(void)heapSize;
(void)cmdLine;
return 1;
}
#if KPJOY_LOG
// Append gLogLine's length characters, and a line end, to LOG_FILE.
static void logWrite(int length) {
HFILE file;
gLogLine[length++] = '\r';
gLogLine[length++] = '\n';
file = OpenFile(LOG_FILE, &gLogFile, OF_WRITE);
if (file == HFILE_ERROR) {
file = OpenFile(LOG_FILE, &gLogFile, OF_CREATE | OF_WRITE);
}
if (file == HFILE_ERROR) {
return;
}
_llseek(file, 0L, LOG_SEEK_END);
_lwrite(file, gLogLine, (UINT)length);
_lclose(file);
}
#endif
// count / rest as 0..32768: both are cut down first so the product fits.
static uint16_t lowPosition(uint32_t count, uint32_t rest) {
while (rest > 0xFFFFUL) {
rest >>= 1;
count >>= 1;
}
if (rest == 0 || count >= rest) {
return (uint16_t)POS_REST;
}
return (uint16_t)(count * POS_REST / rest);
}
// One reading: fire the one-shots and count reads of the port until each
// awaited axis clears. The axes of the sticks that are there are awaited;
// now and then all four are, so a stick plugged in since is found. A stick
// that is there and does not clear is gone.
static void readPort(void) {
uint32_t reads;
uint8_t pending;
uint8_t cleared;
uint8_t value;
uint8_t axis;
uint8_t i;
bool probe;
probe = gReadings % PROBE_READINGS == 0;
gReadings++;
pending = 0;
for (i = 0; i < STICK_COUNT; i++) {
if (probe || gSticks[i].present) {
pending |= (uint8_t)(STICK_AXIS_MASK << (i * AXES_PER_STICK));
}
}
for (axis = 0; axis < AXIS_COUNT; axis++) {
gCounts[axis] = gTimeoutReads;
}
outp(GAME_PORT, 0);
reads = 0;
while (pending != 0 && reads < gTimeoutReads) {
value = (uint8_t)inp(GAME_PORT);
cleared = (uint8_t)(pending & ~value);
if (cleared != 0) {
for (axis = 0; axis < AXIS_COUNT; axis++) {
if ((cleared & (1u << axis)) != 0) {
gCounts[axis] = reads;
}
}
pending &= (uint8_t)~cleared;
}
reads++;
}
value = (uint8_t)inp(GAME_PORT);
gButtons = (uint8_t)((~value >> BUTTON_SHIFT) & BUTTON_MASK_ALL);
for (i = 0; i < STICK_COUNT; i++) {
if (probe || gSticks[i].present) {
gSticks[i].present = gCounts[i * AXES_PER_STICK + AXIS_X] < gTimeoutReads && gCounts[i * AXES_PER_STICK + AXIS_Y] < gTimeoutReads;
}
gSticks[i].fresh = true;
}
}
// JDD_SETCALIBRATION: the stick's calibration from now on; the one it had
// goes to previous.
static LRESULT setCalibration(uint8_t stick, const JoyCalibrateT FAR *set, JoyCalibrateT FAR *previous) {
StickT *s;
s = &gSticks[stick];
if (previous != NULL) {
*previous = s->calibration;
}
if (set == NULL) {
return (LRESULT)JOYERR_PARMS;
}
s->calibration = *set;
s->calibrated = true;
return (LRESULT)JOYERR_NOERROR;
}
// The stick a driver instance is for: DRV_OPEN returned its index plus 1.
static uint8_t stickOf(DWORD driverId) {
if (driverId >= 1 && driverId <= STICK_COUNT) {
return (uint8_t)(driverId - 1);
}
return 0;
}
int FAR PASCAL _export WEP(int param) {
(void)param;
return 1;
}