// 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 #include #include #include #include // ----- 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; }