Skip to content

Commit a980554

Browse files
authored
Gamepad: optional XtendedInput layout (RT throttle, LT brake) (#31)
The DirectInput pad from #8 is what Windows shows the game for an Xbox pad: both triggers on one Z axis, bound in the game's controller menu, so throttle and brake cancel each other. On Windows most players use xan1242's NFSU-XtendedInput plugin instead: the triggers are separate analog throttle and brake, and every button, in races and in the menus, is set in NFSU_XtendedInput.ini. runtime/xtinput.c ports the plugin's NFSU2 code (MIT, notice kept): - FindScannerConfig (0x572120) is overridden (OVR_NA) and returns one ScannerConfig per JoyEvent, whatever the device type. The configs point at one native entry (a dispatch_patch address) that runs the plugin's scanners: buttons, triggers and stick directions as buttons, analog values, press-only events. - The RealDriver steering handler (0x407750) becomes the plugin's: left stick plus keys, written to RealDriver+0x2C8. - Player 1's port is marked present every poll, as the plugin does; the game drops the events of a port typed 0xFF (sub_00571a60). - The keyboard goes through the same scanners with the ini's [EventsKB] keys, as with the plugin; the mouse is unchanged. Back sends 'Q' to the front end. - The pad is no longer offered to the game as a DirectInput device. gamepad.m keeps an XInput-style snapshot that the controller's valueChangedHandler fills on a queue of its own; the game thread only copies it. GameController calls made from the game thread wait for the main thread while it presents frames. Off by default. Settings › Controls has an "XtendedInput layout" switch under "Use a game controller" that sets NFSU2_XTINPUT=1 while the controller is on (disabled for the European executable: the addresses are the North American v1.2 ones). Settings come from <game>/scripts/NFSU_XtendedInput.ini when it exists (a Windows install with the plugin has one), otherwise from the plugin's stock ini, built in. Tested: North American v1.2, original engine, Flydigi APEX 4 in Xbox mode: menus, car select and races (throttle and brake on the triggers, steering, nitrous) behave as with the plugin under Windows; with the switch off the pad is the DirectInput one as before. Pad reads take about 1 us. The European build compiles to stubs; not run here (no European executable).
1 parent dfe003b commit a980554

13 files changed

Lines changed: 1457 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,9 @@ logs folder and manages saves (**Saves…**: the career profiles, backups, ZIP
288288
export and import; the current saves are backed up before an import replaces
289289
them; tests/save_manager.sh). **Settings…** (Cmd-,) holds the options on tabs:
290290
Original or HD graphics and the HUD on **Graphics**; on **Controls**, the game
291-
controller on or off and its stick dead zone (`NFSU2_PAD` / `NFSU2_PAD_DEADZONE`,
292-
which still win when set by hand); on **More**, **Make Copy…** builds a portable
291+
controller on or off, its stick dead zone and the XtendedInput layout
292+
(`NFSU2_PAD` / `NFSU2_PAD_DEADZONE` / `NFSU2_XTINPUT`, which still win when set by
293+
hand); on **More**, **Make Copy…** builds a portable
293294
app with the game files inside (APFS clones, seconds on the same disk), so nobody
294295
needs `pack.sh --embed` or a terminal for that.
295296
**Restore Original Game** sets every option back to the original, unmodified
@@ -366,6 +367,16 @@ No. Only Apple Silicon is targeted and tested.
366367
There is no wheel support and no online play.
367368
See [Known limitations](#known-limitations).
368369

370+
**Can the controller work like it does with the XtendedInput mod on Windows?**
371+
Yes, with the North American v1.2 executable: in **Settings… › Controls**, with the
372+
controller on, switch on **XtendedInput layout**. The triggers are then separate analog throttle (RT) and
373+
brake (LT), and every button, in races and in the menus, does what it does with
374+
[NFSU-XtendedInput](https://github.com/xan1242/NFSU-XtendedInput)
375+
(`runtime/xtinput.c` is a port of its NFSU2 code, MIT licence). The game
376+
folder's `scripts/NFSU_XtendedInput.ini` is used if there is one, as in a Windows
377+
install with the mod; otherwise the mod's stock settings. Without the switch the
378+
pad is a DirectInput gamepad, bound in the game's own controller options.
379+
369380
**How is this different from other NFSU2 projects?**
370381

371382
| Project | Approach | Platforms |

‎launcher/Game.swift‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,10 @@ enum Prefs {
6464
// Shared with the engine (same bundle id, same defaults domain).
6565
static let gameFolder = "OyunDizini" // name kept for existing installs
6666
static let fullscreen = "NFSUFullScreen"
67-
// Launcher only; passed to the engine as NFSU2_PAD / NFSU2_PAD_DEADZONE.
67+
// Launcher only; passed to the engine as NFSU2_PAD / NFSU2_PAD_DEADZONE / NFSU2_XTINPUT.
6868
static let controller = "LauncherController"
6969
static let stickDeadzone = "LauncherStickDeadzone"
70+
static let xtendedInput = "LauncherXtendedInput"
7071
// Launcher only.
7172
static let engine = "LauncherEngine" // "original" | "hd"
7273
static let showAtStartup = "LauncherShowAtStartup"

‎launcher/Model.swift‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ final class LauncherModel: ObservableObject {
2828
@Published var controller: Bool { didSet { defaults.set(controller, forKey: Prefs.controller) } }
2929
@Published var stickDeadzone: Double { didSet { defaults.set(stickDeadzone, forKey: Prefs.stickDeadzone) } }
3030
private var setPad = false // NFSU2_PAD / NFSU2_PAD_DEADZONE were set by us
31+
@Published var xtendedInput: Bool { didSet { defaults.set(xtendedInput, forKey: Prefs.xtendedInput) } }
32+
private let xtendedInputFromEnv = ProcessInfo.processInfo.environment["NFSU2_XTINPUT"] != nil
3133
@Published var job: Job? = nil
3234
@Published var notice: String? = nil
3335

@@ -63,8 +65,12 @@ final class LauncherModel: ObservableObject {
6365
fpsCounter = defaults.bool(forKey: Prefs.fpsCounter)
6466
controller = defaults.bool(forKey: Prefs.controller)
6567
stickDeadzone = min(0.5, max(0, defaults.double(forKey: Prefs.stickDeadzone)))
68+
xtendedInput = defaults.bool(forKey: Prefs.xtendedInput)
6669
}
6770

71+
/// runtime/xtinput.c has the North American v1.2 executable's addresses only.
72+
var canUseXtendedInput: Bool { location?.build?.engineSuffix == "-na" }
73+
6874
var location: GameLocation? {
6975
switch game {
7076
case .ready(let l), .unsupported(let l, _): return l
@@ -143,6 +149,11 @@ final class LauncherModel: ObservableObject {
143149
setenv("NFSU2_PAD_DEADZONE", String(format: "%.2f", stickDeadzone), 1)
144150
setPad = true
145151
}
152+
// XtendedInput layout (runtime/xtinput.c); only with the controller on.
153+
if !xtendedInputFromEnv {
154+
let padOn = getenv("NFSU2_PAD").map { String(cString: $0) != "0" } ?? true
155+
setenv("NFSU2_XTINPUT", padOn && xtendedInput && canUseXtendedInput ? "1" : "0", 1)
156+
}
146157
var e = engine
147158
if !hasEngine(e) { e = (e == .hd) ? .original : .hd }
148159
let path = Paths.engine(loc.build, hd: e == .hd).path

‎launcher/Settings.swift‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,13 @@ struct ControlsSettings: View {
136136
}
137137
}
138138
.disabled(!model.controller).opacity(model.controller ? 1 : 0.5)
139+
SettingRow(title: "XtendedInput layout",
140+
description: model.canUseXtendedInput
141+
? "RT accelerates, LT brakes, every button as in the NFSU-XtendedInput mod, set in scripts/NFSU_XtendedInput.ini in the game folder if there is one. Off: buttons are set in the game's Controls menu."
142+
: "Needs the North American v1.2 SPEED2.EXE.",
143+
isOn: $model.xtendedInput)
144+
.disabled(!model.controller || !model.canUseXtendedInput)
145+
.opacity(model.controller && model.canUseXtendedInput ? 1 : 0.5)
139146
}
140147
}
141148
}

‎link.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ case "${BUILD:-eu}" in
4242
*) echo "BUILD must be eu or na"; exit 1 ;;
4343
esac
4444

45-
CSRC="mem dispatch genexec win32 win32_ext com debug res fileio overrides d3dx_hle vsemu enhance perflog wsfov gheap sndlead xpatch highfps"
45+
CSRC="mem dispatch genexec win32 win32_ext com debug res fileio overrides d3dx_hle vsemu enhance perflog wsfov gheap sndlead xpatch highfps xtinput"
4646
MSRC="metal audio bundle gamepad"
4747
FW="-lm -framework Cocoa -framework Metal -framework QuartzCore -framework ImageIO -framework CoreGraphics -framework CoreServices -framework AudioToolbox -framework GameController -framework CoreText"
4848
SHARDS="$SOBJ/shard_*.o $SOBJ/fntab.o $SOBJ/stubs.o"

‎runtime/com.c‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4343,7 +4343,8 @@ int com_call(CPU *c, uint32_t magic){
43434343
* REALLY call the callback: report a keyboard and a mouse. */
43444344
uint32_t devtype=A(c,1), cb=A(c,2), ref=A(c,3);
43454345
if (cb && devtype == 4u){ /* DI8DEVCLASS_GAMECTRL: the host pad */
4346-
if (gamepad_present()){
4346+
int xtinput_active(void); /* runtime/xtinput.c reads the pad itself */
4347+
if (!xtinput_active() && gamepad_present()){
43474348
uint32_t args[2] = { make_devinst(3), ref };
43484349
call_guest(c, cb, 2, args);
43494350
fprintf(stderr," [DI] game pad offered to the game: %s\n", gamepad_name());
@@ -4745,6 +4746,7 @@ void com_report(void){
47454746
{ void gamepad_report(void); gamepad_report(); } /* gamepad.m */
47464747
{ void xpatch_report(void); xpatch_report(); }
47474748
{ void highfps_report(void); highfps_report(); } /* LOCAL PATCH: highfps.c */
4749+
{ void xtinput_report(void); xtinput_report(); } /* xtinput.c */
47484750
if (g_aspfix != 1.f)
47494751
{ fprintf(stderr," widescreen : aspfix %.4f, %lu perspective / %lu orthographic draws\n",
47504752
g_aspfix, g_asp_persp, g_asp_ortho);

‎runtime/fileio.c‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ int fileio_game_file_exists(const char *win){
9292
return resolve_in(g_root, win, p, sizeof p) && stat(p, &st) == 0 && S_ISREG(st.st_mode);
9393
}
9494

95+
/* Host path of a file in the game folder (Windows-style path, case-insensitive);
96+
* 0 if it isn't there. */
97+
int fileio_game_path(const char *win, char *out, size_t outsz){
98+
struct stat st;
99+
return resolve_in(g_root, win, out, outsz) && stat(out, &st) == 0 && S_ISREG(st.st_mode);
100+
}
101+
95102
/* --- write root (save layer) ---------------------------------------------
96103
* The game writes its saves and profiles into its OWN folder ("C:\NFSU2\NFS
97104
* Underground 2\<name>\<name>"). Writing there deletes the saves when a new

‎runtime/gamepad.m‎

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
#include <string.h>
1919
#include <stdio.h>
2020
#include <stdlib.h>
21+
#include <os/lock.h>
2122

2223
int gamepad_present(void);
2324
void gamepad_fill(unsigned char *dst);
@@ -57,11 +58,65 @@ static void parse_fake(const char *s){
5758
return nil;
5859
}
5960

61+
/* XInput snapshot for gamepad_xinput_state. The controller's value-changed handler
62+
* fills it on a queue of its own and the game thread only copies it: GameController
63+
* calls made from the game thread (+controllers in particular) wait for the main
64+
* thread, which is busy presenting frames, and stalled the game and its audio. */
65+
typedef struct { uint16_t buttons; uint8_t trig[2]; int16_t sticks[4]; int connected; } XSnap;
66+
static os_unfair_lock g_xs_lock = OS_UNFAIR_LOCK_INIT;
67+
static XSnap g_xs;
68+
static GCController *g_xs_ctl = nil; /* main thread only */
69+
static dispatch_queue_t g_xs_queue;
70+
71+
static void xs_snapshot(GCExtendedGamepad *g){
72+
static const uint16_t xbit[14] = { 0x1000, 0x2000, 0x4000, 0x8000, 0x0100, 0x0200,
73+
0x0020, 0x0010, 0x0040, 0x0080, 0x0001, 0x0002, 0x0004, 0x0008 };
74+
#define S16(v) ((int16_t)lroundf(fmaxf(-1.f, fminf(1.f, (v))) * 32767.f))
75+
#define U8(v) ((uint8_t)lroundf(fmaxf(0.f, fminf(1.f, (v))) * 255.f))
76+
XSnap s = { 0 };
77+
s.connected = 1;
78+
s.sticks[0] = S16(g.leftThumbstick.xAxis.value); s.sticks[1] = S16(g.leftThumbstick.yAxis.value);
79+
s.sticks[2] = S16(g.rightThumbstick.xAxis.value); s.sticks[3] = S16(g.rightThumbstick.yAxis.value);
80+
s.trig[0] = U8(g.leftTrigger.value); s.trig[1] = U8(g.rightTrigger.value);
81+
GCControllerButtonInput *bs[14] = { g.buttonA, g.buttonB, g.buttonX, g.buttonY,
82+
g.leftShoulder, g.rightShoulder, g.buttonOptions, g.buttonMenu,
83+
g.leftThumbstickButton, g.rightThumbstickButton,
84+
g.dpad.up, g.dpad.down, g.dpad.left, g.dpad.right };
85+
for (int i = 0; i < 14; i++) if (bs[i] && bs[i].pressed) s.buttons |= xbit[i];
86+
#undef S16
87+
#undef U8
88+
os_unfair_lock_lock(&g_xs_lock); g_xs = s; os_unfair_lock_unlock(&g_xs_lock);
89+
}
90+
91+
static void xs_attach(GCController *c){ /* main thread */
92+
GCExtendedGamepad *g = c.extendedGamepad;
93+
if (!g || g_xs_ctl) return;
94+
g_xs_ctl = c;
95+
{ const char *n = c.vendorName.UTF8String; if (n && *n) snprintf(g_name, sizeof g_name, "%s", n); }
96+
g_seen = 1;
97+
c.handlerQueue = g_xs_queue;
98+
g.valueChangedHandler = ^(GCExtendedGamepad *gp, GCControllerElement *el){ (void)el; xs_snapshot(gp); };
99+
dispatch_async(g_xs_queue, ^{ xs_snapshot(g); });
100+
fprintf(stderr, "[pad] reading %s (%s) for XtendedInput controls\n",
101+
c.vendorName.UTF8String ?: "controller", c.productCategory.UTF8String ?: "?");
102+
}
103+
104+
static void xs_detach(GCController *c){ /* main thread */
105+
if (c != g_xs_ctl) return;
106+
c.extendedGamepad.valueChangedHandler = nil;
107+
g_xs_ctl = nil;
108+
dispatch_async(g_xs_queue, ^{ /* after any snapshot still queued */
109+
os_unfair_lock_lock(&g_xs_lock); memset(&g_xs, 0, sizeof g_xs); os_unfair_lock_unlock(&g_xs_lock);
110+
});
111+
for (GCController *o in [GCController controllers]) if (o != c && o.extendedGamepad){ xs_attach(o); break; }
112+
}
113+
60114
void gamepad_install(void){
61115
const char *e = getenv("NFSU2_PAD");
62116
if (e && !strcmp(e, "0")){ g_pad_off = 1; return; }
63117
if ((e = getenv("NFSU2_PAD_DEADZONE"))) g_dead = (float)atof(e);
64118
if ((e = getenv("NFSU2_PAD_FAKE"))){ g_fake = 1; parse_fake(e); snprintf(g_name, sizeof g_name, "Fake Pad"); }
119+
g_xs_queue = dispatch_queue_create("nfsu2.pad", DISPATCH_QUEUE_SERIAL);
65120
@autoreleasepool {
66121
if (@available(macOS 11.3, *))
67122
GCController.shouldMonitorBackgroundEvents = YES;
@@ -70,12 +125,16 @@ void gamepad_install(void){
70125
usingBlock:^(NSNotification *n){
71126
GCController *c = n.object;
72127
fprintf(stderr, "[pad] connected: %s\n", c.vendorName.UTF8String ?: "controller");
128+
xs_attach(c);
73129
}];
74130
[[NSNotificationCenter defaultCenter]
75131
addObserverForName:GCControllerDidDisconnectNotification object:nil queue:nil
76132
usingBlock:^(NSNotification *n){
77133
fprintf(stderr, "[pad] disconnected\n");
134+
xs_detach(n.object);
78135
}];
136+
dispatch_async(dispatch_get_main_queue(), ^{ /* pads connected before the start */
137+
GCController *c = pick(); if (c) xs_attach(c); });
79138
}
80139
}
81140

@@ -150,9 +209,46 @@ void gamepad_fill(unsigned char *dst){
150209
for (int i = 0; i < 10; i++) if (bs[i] && bs[i].pressed){ btn[i] = 0x80; g_pressed++; }
151210
}
152211

212+
/* XInput view of the pad for runtime/xtinput.c (XINPUT_GAMEPAD: wButtons bits, triggers
213+
* 0..255, sticks -32768..32767 with up = positive). No dead zone: the caller applies its
214+
* own. Returns 0 (and a neutral state) when no pad is connected. Only copies the
215+
* snapshot above, so it never waits on GameController.
216+
* NFSU2_PAD_FAKE maps X/Y -> left stick, RX/RY -> right stick (DirectInput sense,
217+
* forward = negative), Z -> LT, RZ -> RT, B0..B9 -> A B X Y LB RB Back Start LS RS,
218+
* POV -> d-pad. */
219+
int gamepad_xinput_state(uint16_t *buttons, uint8_t trig[2], int16_t sticks[4]){
220+
*buttons = 0; trig[0] = trig[1] = 0;
221+
for (int i = 0; i < 4; i++) sticks[i] = 0;
222+
if (g_pad_off) return 0;
223+
static const uint16_t xbit[10] = { 0x1000, 0x2000, 0x4000, 0x8000, 0x0100, 0x0200,
224+
0x0020, 0x0010, 0x0040, 0x0080 };
225+
#define S16(v) ((int16_t)lroundf(fmaxf(-1.f, fminf(1.f, (v))) * 32767.f))
226+
#define U8(v) ((uint8_t)lroundf(fmaxf(0.f, fminf(1.f, (v))) * 255.f))
227+
if (g_fake){
228+
sticks[0] = S16(g_fake_ax[0]); sticks[1] = S16(-g_fake_ax[1]);
229+
sticks[2] = S16(g_fake_ax[3]); sticks[3] = S16(-g_fake_ax[4]);
230+
trig[0] = U8(fabsf(g_fake_ax[2])); trig[1] = U8(fabsf(g_fake_ax[5]));
231+
for (int i = 0; i < 10; i++) if (g_fake_btn[i]) *buttons |= xbit[i];
232+
switch (g_fake_pov){ /* d-pad: up 1, down 2, left 4, right 8 */
233+
case 0: *buttons |= 1; break; case 4500: *buttons |= 1|8; break;
234+
case 9000: *buttons |= 8; break; case 13500: *buttons |= 2|8; break;
235+
case 18000: *buttons |= 2; break; case 22500: *buttons |= 2|4; break;
236+
case 27000: *buttons |= 4; break; case 31500: *buttons |= 1|4; break;
237+
default: break; }
238+
return 1;
239+
}
240+
#undef S16
241+
#undef U8
242+
os_unfair_lock_lock(&g_xs_lock); XSnap s = g_xs; os_unfair_lock_unlock(&g_xs_lock);
243+
g_polls++;
244+
*buttons = s.buttons; trig[0] = s.trig[0]; trig[1] = s.trig[1];
245+
for (int i = 0; i < 4; i++) sticks[i] = s.sticks[i];
246+
return s.connected;
247+
}
248+
153249
void gamepad_report(void){
154250
if (g_pad_off) return;
155251
fprintf(stderr, "[pad] %s: %s, %llu state polls, %llu button-presses seen\n",
156-
g_fake ? "fake" : "GameController", g_ctl || g_fake ? g_name : "no controller",
252+
g_fake ? "fake" : "GameController", g_ctl || g_fake || g_seen ? g_name : "no controller",
157253
g_polls, g_pressed);
158254
}

0 commit comments

Comments
 (0)