-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathi76-remap.ahk
More file actions
609 lines (580 loc) · 25.6 KB
/
Copy pathi76-remap.ahk
File metadata and controls
609 lines (580 loc) · 25.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
; Interstate '76 - universal in-container input remapper (AutoHotkey v1.1).
; Runs INSIDE the Wine/Proton prefix on Mac/Deck - and unchanged on native
; Windows. Installed by setup-input-remapper.sh; auto-started by the Mac
; launcher stub when present. Docs: docs/INPUT-REMAPPER.md.
;
; WHY: the I76 engine knows exactly three mouse buttons (LeftBtn/RightBtn/
; MiddleBtn - verified in the exe). Buttons 4/5 DO exist at the Windows layer
; (winemac.drv maps physical button N>=4 to XBUTTON1/XBUTTON2), so we turn
; them into KEYS here and let input.map bind those keys as usual. The game
; never binds buttons 4/5 itself, so this remap is purely additive - no
; double-trigger even if hook suppression ever fails under Wine.
;
; EDITING RULES (human + agent):
; - Keep SendMode at its DEFAULT (Event). Do not add "SendMode Input":
; AHK uninstalls its own hooks during SendInput playback, and in this
; prefix only SendEvent-injected events reach the hooks (verified
; 2026-07-14 with the setup script's --test harness).
; - "A::B" remap syntax holds B down while A is held - what specials want.
; - Deliberately NO #IfWinActive scoping: under Wine this script only lives
; while the game session lives, and its hooks only see input while a Wine
; window has focus. If you run it globally on native Windows, wrap the
; remaps in: #IfWinActive ahk_exe i76.exe
;
; The key names on the RIGHT are what input.map binds (docs/input.map.reference):
; 6 / 7 / 8 = special1 / special2 / special3 (nitrous, bumpers... fire
; from whichever special slot the part is mounted in)
; = / - = shift_up / shift_down (gear shift)
#NoEnv
#NoTrayIcon
#SingleInstance Force
#Persistent
#InstallMouseHook
; NO MODAL DIALOGS, EVER. AHK's rate-limit warning is a modal MsgBox: inside
; the Wine session it steals foreground focus from the game -- and under
; DxWnd's HideDesktop backdrop it's INVISIBLE, so the game just goes
; keyboard-dead (measured 2026-07-14: a 700-notch injected scroll flood
; tripped the old limit of 500 at exactly ~500 activations/2s and hard-blocked
; everything; a real trackpad momentum scroll can burst the same way). With
; only discrete button hotkeys bound, a runaway is impossible -- so set the
; threshold unreachably high instead of "high enough".
#MaxHotkeysPerInterval 200000
; Load-time errors print to stdout (remapper silently absent) instead of
; popping the same kind of focus-stealing dialog.
#ErrorStdOut
; ---- NO mouse-MOTION injection (a right-stick->mouse-X bridge lived here for
; a few hours on 2026-07-18: field verdict "unusable"). Discrete KEY emission
; is fine - it's this whole script's job, and it's exactly how Steam Input
; drives the same features on the Deck.
; ---- right stick --> arrow keys (glance/track camera/menus), 2026-07-18.
; The engine's analog vocabulary can't name the stick's horizontal axis
; (winmm U) and analog camera deltas felt bad anyway; the arrow keys are
; field-proven great in and out of the car. So: deflect right stick =
; hold the matching arrow key, exactly like the Deck config's right stick.
; Stuck-key safety (the wheel-disaster rules): held-state table so every
; down has a matching up, release-on-center every 15ms tick, release-all
; when the pad vanishes and on script exit.
gRSGHeld := {}
SetTimer, RStickGlance, 15
OnExit, RSGExit
; ---- WHEEL layer (2026-08-01, Thrustmaster T300RS). A DirectInput wheel is
; INVISIBLE to XInput, so XIPoll below never sees it - but AHK reads it fine
; through winmm, headless, no GUI needed (verified with a positive control after
; an earlier probe bug wrongly said otherwise - see docs/WHEEL-T300.md).
;
; Detected by capability, not name: a wheel has NO U axis (JoyInfo "ZRPD") where
; an Xbox pad does, and reports 11+ buttons. Both must hold, so a pad never lands
; here and the Mac/Deck path is untouched.
;
; input.map keeps ONLY the analog sinks (steer/throttle) for a wheel. EVERY
; button and the hat are emitted here as the engine's stock keys - binding them
; natively as well would double-fire.
;
; BASE HOLD Button6 = SHIFT LAYER
; 1 L1 L-paddle hardpoint 2 1 hardpoint 1
; 2 R1 R-paddle fire selected weapon 2 front target
; 3 cycle weapon 3 combat view
; 4 handbrake 4 reverse
; 5 nitrous (special1) 5 special 2
; 6 >>> SHIFT <<< 6 --
; 7 SE rear gun (hp3) 7 dropper (hp4)
; 8 target nearest 8 untarget
; 9 R2 LINK FIRE (weapon_link) 9 look at target
; 10 L2 next target 10 radar range
; 11 L3 ignition 11 radar camera
; 12 R3 handbrake 12 binoculars
; 13 PS horn 13 poetry
; hat U/R/D/L lights/map/ignition/notepad hat U/D = gear up/down
; KEEP THIS TABLE IN SYNC WITH gWheelBase/gWheelAlt BELOW. A stale comment in
; this file (gears as =/- when input.map binds Period/Comma) already cost a
; silent no-op once.
;
; BUTTON NUMBERS ARE FROM THRUSTMASTER'S MANUAL ("MAPPING FOR PC" page), which
; confirms every number we measured by press-order. PC <- PlayStation label:
; 1=L1 (left paddle) 2=R1 (right paddle) 9=R2 10=L2
; 3,4,5,6 = the face cluster (triangle/square/circle/cross)
; 7=SHARE/CREATE ("SE") 8=OPTIONS 11=L3 12=R3 13=PS
; weapon_link - firing linked groups as ONE event, the engine-native way to fire
; several hardpoints WITHOUT the I7_SFRCE.DLL crash (docs/WHEEL-T300.md) - used to
; live on R2 (9). It moved to the Fighterstick's pinky button 2026-08-08, so the
; rule it protects still holds; only its location changed. NEVER replace it with
; two hardpoint keys on one button.
gWheelShiftBtn := 6
; REBALANCED 2026-08-08, once the CH Fighterstick took over weapons, targeting and
; the gearbox (docs/FIGHTERSTICK.md). The wheel is spun with the left hand, so its
; buttons become the things you reach for WITHOUT looking - lights, horn, ignition -
; rather than combat controls needing precision:
;
; 1 L1 left paddle hardpoint2 -> LIGHTS old-school headlight stalk, left hand
; 9 R2 weapon_link -> HORN weapon_link moved to the stick's pinky
; 10 L2 next target -> HORN targeting is the stick's castle hat now
; 11 L3 ignition -> LIGHTS second lights position, thumb reach
; 12 R3 handbrake -> IGNITION handbrake is the stick pulled back now
; 13 PS horn (unchanged)
;
; Three horn buttons is deliberate: it is Interstate '76, and the horn wants to be
; hittable without looking from anywhere on the rim.
gWheelBase := {1: "h", 2: "Enter", 3: "Tab", 4: "Space", 5: "6"
, 7: "3", 8: "t", 9: "g", 10: "g", 11: "h", 12: "i", 13: "g"}
gWheelAlt := {1: "LButton", 2: "q", 3: "v", 4: "x", 5: "7"
, 7: "4", 8: "u", 9: "e", 10: "r", 11: "k", 12: "b", 13: "p"}
; NEVER fire two hardpoints from one button. Doing so (left paddle = "LButton|2",
; SE = "3|4") CRASHED the game 2026-08-01 inside I7_SFRCE.DLL - the Nitro Pack
; force-feedback module - at the weapon-effect path (it loads force\*.frc per
; Hardpoint/WpnId and plays it through DirectInput). Two simultaneous weapon
; effects is a case that 1997 code never sees. Use the engine's own weapon_link
; (F, on shift+2) to fire groups together instead.
; held while the button is held (fire, handbrake); everything else taps once.
; WHICH BUTTONS HOLD THEIR KEY DOWN vs tap it once.
;
; Button 12 was REMOVED 2026-08-08: it was the handbrake, which must be held; it is
; now ignition, which must be TAPPED. Left in place it would have held `i` down for
; as long as R3 was pressed - a starter motor you cannot release.
;
; Buttons 9, 10 and 13 were ADDED 2026-08-08. They are the horn, and the horn only
; beeped once however long you leaned on it - because a button absent from this
; table taps its key on the press edge and never sends the key-up until the next
; press. A horn is the most obviously hold-shaped control on the wheel and it was
; the one control the table did not know about. Now it sounds for exactly as long
; as the button is down.
;
; Rule of thumb: anything the player would EXPECT to hold - fire, handbrake, horn -
; belongs here. Toggles (lights, ignition, weapon cycle) must NOT.
gWheelHoldSet := {2: 1, 4: 1, 9: 1, 10: 1, 13: 1}
; REPEAT-tap while held: a held hardpoint digit only fires ONCE in-engine, so
; re-tap it every gWheelRepeatTicks polls (~15ms each) for sustained fire.
; REPEAT-FIRE DISABLED 2026-08-01. Tapping a hardpoint every ~120ms made
; I7_SFRCE.DLL (the FFB module) load+play a force\*.frc that fast, and it
; FAULTED AGAIN at 20:00 with this enabled - after the dual-fire binding was
; already removed. Both crashes share one factor: many weapon FFB effects in
; quick succession. Direct hardpoint keys are ONE SHOT PER PRESS by design;
; sustained fire is what weapon_fire (right paddle, a true hold) is for.
; Set gWheelRepeatTicks and re-add entries here only if FFB proves robust.
gWheelRepeat := {}
gWheelRepeatTicks := 20
gWheelRepCount := {}
gWheelHat := {0: "h", 9000: "m", 18000: "i", 27000: "n"} ; U R D L
gWheelHatAlt := {0: ".", 18000: ","} ; shift: gear up/down. THIS input.map binds
; shift_up/shift_down to Period/Comma, NOT the =/- in the header note above -
; verified against the live file 2026-08-01. Check before trusting that comment.
gWheelPrev := {}
gWheelHeld := {}
gWheelPovPrev := -1
SetTimer, WheelPoll, 15
; ---- XInput layer (2026-07-18): things winmm can NOT deliver.
; winmm merges both triggers into ONE shared axis (they cancel when pressed
; together), so trigger-as-two-buttons is impossible at the joystick layer.
; XInput exposes LT/RT as independent 0-255 values - and Wine/Proton implement
; xinput*.dll, so this works identically on Mac (Wine), Deck (Proton, Steam
; Input off) and native Windows. Polled here:
; v3 layout (2026-07-18). Machine-readable: tools/pad-diagram.py renders
; the @pad lines below plus input.map into docs/pad-diagram.html - keep
; them in sync with the XIPoll code they describe.
; @pad RT: fire - current weapon / cockpit handgun (hold)
; @pad RT(looking back): fires the REAR gun (hp3) while right stick is held back
; @pad LT: hardpoint 2 (hold)
; @pad LB+X: radar zoom toggle (R)
; @pad A(tap): OK/select in menus - click+Enter; in-sim a single shot
; @pad A(hold 400ms): NITROUS while held
; @pad B(tap): cycle weapon (C)
; @pad X: cycle targets (Y)
; @pad Y: toggle chase cam <-> cockpit (F3/F1)
; @pad LB(tap): front target (Q)
; @pad Select: pause menu / skip cutscene (Esc)
; @pad Start: map (M)
; @pad Dpad-Up: headlights (H)
; @pad Dpad-Down: ignition (I)
; @pad Dpad-Left: notepad (N)
; @pad Dpad-Right: map (M)
; @pad RStick: look / glance - cockpit, external cam, menus (arrow keys)
; @pad R3: look at target (E)
; @pad R3(looking back): drop mines (hardpoint 4)
; @pad LB+RT: hardpoint 1
; @pad LB+LT: hardpoint 5 + hardpoint 2 (backup - few cars have a 5th)
; @pad LB+A: hardpoint 3 - rear gun only
; @pad LB+B: hardpoint 4 - dropper
; @pad LB+Y: cycle camera views (F1 F2 F3 F7 F8 F9 F10 F1 - both dash modes)
; @pad L3: nitrous / special 1 (hold); with LEFT stick pulled back = toggle reverse
; @pad LB+Dpad-Up: binoculars (B)
; @pad LB+Dpad-Down: horn (G)
; @pad LB+Dpad-Left: gear down (-)
; @pad LB+Dpad-Right: gear up (=)
gXIDll := ""
Loop, Parse, % "xinput1_4.dll,xinput1_3.dll,xinput9_1_0.dll", `,
{
if DllCall("LoadLibrary", "Str", A_LoopField, "Ptr") {
gXIDll := A_LoopField
break
}
}
gXIPad := 0, gXIPrevBtns := 0, gRTHeld := false, gLTHeld := false
gLBPrev := false, gLBUsed := false, gLBt0 := 0
gAPrev := false, gAt0 := 0, gANitro := false, gBPrev := false, gSelPrev := false
gYPrev := false, gCamIdx := 0, gYExt := false
gTL := 0, gTR := 0, gTTicks := 0, gGrowl := 0
gNitroPrev := false, gRBPrev := false, gGearUPrev := false, gGearDPrev := false, gMinePrev := false, gIgnPrev := false, gLookBack := false, gL3Nitro := false, gL3Prev := false, gXIVibLast := -1
if (gXIDll != "")
SetTimer, XIPoll, 15
; ---- mouse button 4 ("back") --> 6 = special 1: the user's NITROUS.
; Field-corrected 2026-07-18 (second pass): nitrous is in special slot 1,
; i.e. THIS button - the old "button 5 = nitrous" note (f04d668) was wrong
; for this loadout (special2 is empty; the forward button did nothing).
XButton1::6
; ---- mouse button 5 ("forward") --> 3 = weapon key 3 (input.map: Three ->
; hardpoint3_fire). NOTE 2026-07-18: on the user's car the hardpoint3_fire
; ACTION fires what the HUD numbers as weapon 4 - mount-slot vs HUD numbering
; differ. If the wrong gun fires, shift this key (2/3/4), one-line change.
XButton2::3
; ---- mouse wheel: DO NOT REMAP. Removed 2026-07-14 after a field regression
; ("mouse activity kills WASD"). Two reasons, the first fatal:
; 1. Wheel "keys" have NO release event, and an AHK remap (WheelUp::=) holds
; its destination key until the source releases -- which never comes. One
; notch (incl. trackpad two-finger scroll / momentum scrolling) can leave
; '=' logically STUCK DOWN, i.e. shift_up held forever -> transmission
; pegged, throttle feels dead, "WASD broken" until the session restarts.
; 2. Even clean per-notch keystrokes spam the gear-shift actions, dropping
; the transmission out of automatic mid-drive.
; If a wheel binding is ever wanted, use explicit hotkeys that send a full
; press+release of a HARMLESS key (never gears/steering/throttle):
; WheelUp::SendEvent {F6}
; and field-test with trackpad momentum scrolling before shipping.
; ---- right-stick glance machinery (see SetTimer near the top).
; AHK v1 axes read 0-100, 50 = center. JoyR = right stick VERTICAL (up ~0),
; JoyU = right stick HORIZONTAL (right ~100) - decoded 2026-07-18. If a
; direction is backwards in the field, flip the +1/-1 in the RStickGlance
; calls. Hysteresis: press past 25-from-center, release inside 15-from-center.
RStickGlance:
; A device with NO U axis (any wheel; many sticks) returns 0.0 here, NOT "" -
; and 0.0 sails past the old `u = ""` guard straight into the hysteresis below,
; where (0-50)*-1 = +50 > 12 = LEFT ARROW HELD DOWN FOREVER. Field-diagnosed
; 2026-08-01 on a Thrustmaster T300: the game hung at "PLEASE STAND BY" with a
; jammed glance-left. Gate on the capability string instead of the value:
; JoyInfo lists the axes that EXIST (wheel = "ZRPD", Xbox pad includes "U").
if (!InStr(GetKeyState("JoyInfo"), "U")) {
RSGSet("Right", false), RSGSet("Left", false), RSGSet("Up", false), RSGSet("Down", false)
return
}
u := GetKeyState("JoyU")
v := GetKeyState("JoyR")
if (u = "" || v = "") {
RSGSet("Right", false), RSGSet("Left", false), RSGSet("Up", false), RSGSet("Down", false)
return
}
RSGHys("Right", u, 1), RSGHys("Left", u, -1), RSGHys("Down", v, 1), RSGHys("Up", v, -1)
return
RSGHys(key, val, dir) {
d := (val - 50) * dir
if (d > 12)
RSGSet(key, true)
else if (d < 8)
RSGSet(key, false)
}
RSGSet(key, want) {
global gRSGHeld
if (want && !gRSGHeld[key]) {
gRSGHeld[key] := true
SendEvent, {%key% down}
} else if (!want && gRSGHeld[key]) {
gRSGHeld[key] := false
SendEvent, {%key% up}
}
}
; ---- wheel poll (see the map near the top).
WheelPoll:
wInfo := GetKeyState("JoyInfo")
wBtns := GetKeyState("JoyButtons")
; No device, a gamepad (has U = right stick), NO R axis, or too few buttons ->
; release and bail.
;
; THE R-AXIS TEST WAS ADDED 2026-08-08 AND IS NOT COSMETIC. The old test - "no U
; and 11+ buttons" - also matches the CH Fighterstick, measured:
;
; joystick1 T300RS wheel info=ZRPD 13 buttons <- intended
; joystick2 CH Fighterstick info=ZPD 19 buttons <- ALSO matched
;
; These reads are UNPREFIXED, so they take whichever joystick enumerates first.
; That happens to be the wheel today, which is the only reason this has not bitten:
; with the wheel switched off or enumerating second, this layer would have driven
; WHEEL bindings from the Fighterstick while i76-ch-fighterstick.ahk was reading the
; same device - two layers, one stick, every press doubled.
;
; The wheel reports an R axis and the Fighterstick does not, so requiring R
; separates them on measured capability rather than on enumeration luck.
if (wInfo = "" || InStr(wInfo, "U") || !InStr(wInfo, "R") || wBtns < 11) {
WheelReleaseAll()
return
}
shift := GetKeyState("Joy" . gWheelShiftBtn)
map := shift ? gWheelAlt : gWheelBase
; Layer changed -> drop anything held on the old layer, so a key can't stick.
if (shift != gWheelShiftPrev) {
WheelReleaseAll()
gWheelShiftPrev := shift
}
Loop 13 {
i := A_Index
if (i = gWheelShiftBtn)
continue
st := GetKeyState("Joy" . i)
keys := map[i]
if (keys = "") {
gWheelPrev[i] := st
continue
}
if (gWheelHoldSet[i]) {
if (st != gWheelHeld[i]) {
Loop, Parse, keys, |
RSGSet(A_LoopField, st)
gWheelHeld[i] := st
}
} else if (gWheelRepeat[i]) {
if (st) {
if (!gWheelPrev[i] || gWheelRepCount[i] >= gWheelRepeatTicks) {
Loop, Parse, keys, |
SendEvent, {%A_LoopField%}
gWheelRepCount[i] := 0
} else
gWheelRepCount[i] += 1
} else
gWheelRepCount[i] := 0
} else if (st && !gWheelPrev[i]) {
Loop, Parse, keys, |
SendEvent, {%A_LoopField%}
}
gWheelPrev[i] := st
}
; hat: tap on entering a direction
pov := GetKeyState("JoyPOV")
if (pov != gWheelPovPrev) {
hmap := shift ? gWheelHatAlt : gWheelHat
k := hmap[pov]
if (k != "")
SendEvent, {%k%}
gWheelPovPrev := pov
}
return
WheelReleaseAll() {
global gWheelHeld
for i, st in gWheelHeld
if (st)
RSGSet2(i)
gWheelHeld := {}
}
; release every key any hold-slot could have pressed
RSGSet2(i) {
global gWheelBase, gWheelAlt
for , src in [gWheelBase[i], gWheelAlt[i]]
if (src != "")
Loop, Parse, src, |
RSGSet(A_LoopField, false)
}
RSGExit:
VarSetCapacity(xiVib0, 4, 0)
if (gXIDll != "")
DllCall(gXIDll . "\XInputSetState", "UInt", gXIPad, "Ptr", &xiVib0)
RSGSet("Right", false), RSGSet("Left", false), RSGSet("Up", false), RSGSet("Down", false)
RSGSet("1", false), RSGSet("2", false), RSGSet("3", false), RSGSet("4", false), RSGSet("5", false), RSGSet("6", false)
RSGSet("h", false), RSGSet("i", false), RSGSet("n", false), RSGSet("m", false), RSGSet("LButton", false)
RSGSet("y", false), RSGSet("u", false), RSGSet("v", false), RSGSet("x", false), RSGSet("-", false), RSGSet("=", false), RSGSet("b", false), RSGSet("g", false), RSGSet("e", false), RSGSet("r", false)
ExitApp
; ---- XInput poll (see init near the top). State struct: buttons WORD @4,
; LT BYTE @6, RT BYTE @7. A=0x1000, Back=0x0020. Trigger hysteresis 40/25.
XIPoll:
VarSetCapacity(xiState, 16, 0)
if (DllCall(gXIDll . "\XInputGetState", "UInt", gXIPad, "Ptr", &xiState, "UInt") != 0) {
found := false
Loop, 4 {
idx := A_Index - 1
if (DllCall(gXIDll . "\XInputGetState", "UInt", idx, "Ptr", &xiState, "UInt") = 0) {
gXIPad := idx, found := true
break
}
}
if (!found) {
RSGSet("1", false), RSGSet("2", false)
gXIPrevBtns := 0
return
}
}
rt := NumGet(xiState, 7, "UChar")
lt := NumGet(xiState, 6, "UChar")
btns := NumGet(xiState, 4, "UShort")
if (rt > 40)
gRTHeld := true
else if (rt < 25)
gRTHeld := false
if (lt > 40)
gLTHeld := true
else if (lt < 25)
gLTHeld := false
aHeld := (btns & 0x1000) != 0
bHeld := (btns & 0x2000) != 0
xHeld := (btns & 0x4000) != 0
yHeld := (btns & 0x8000) != 0
lbHeld := (btns & 0x0100) != 0
selHeld := (btns & 0x0020) != 0
stHeld := (btns & 0x0010) != 0
dU := (btns & 0x0001) != 0
dD := (btns & 0x0002) != 0
dL := (btns & 0x0004) != 0
dR := (btns & 0x0008) != 0
if (lbHeld && !gLBPrev)
gLBUsed := false, gLBt0 := A_TickCount
; A: shifted = rear gun only. Base: tap = click+Enter on release (menu
; OK/select; in-sim just a single shot); held >=400ms = nitrous.
if (lbHeld) {
gANitro := false, gAt0 := 0
RSGSet("3", (lbHeld && aHeld) || (gLookBack && gRTHeld))
} else {
RSGSet("3", gLookBack && gRTHeld)
if (aHeld && !gAPrev)
gAt0 := A_TickCount, gANitro := false
if (aHeld && !gANitro && gAt0 && A_TickCount - gAt0 >= 400)
gANitro := true
if (!aHeld && gAPrev) {
if (!gANitro && gAt0) {
Click
SendEvent, {Enter}
}
gANitro := false, gAt0 := 0
}
}
gAPrev := aHeld
; L3: nitrous while held - unless the LEFT stick is pulled back at the
; press, then it's a reverse toggle instead (low-speed 3-point-turn helper)
l3Held := (btns & 0x0040) != 0
if (l3Held && !gL3Prev) {
ly := NumGet(xiState, 10, "Short")
if (ly < -16384) {
SendEvent, x
RumblePulse(25000, 0, 2) ; tactile confirm: reverse toggled
} else
gL3Nitro := true
}
if (!l3Held)
gL3Nitro := false
gL3Prev := l3Held
RSGSet("6", gANitro || gL3Nitro)
; look-back fire: while the right stick is held back (rear view), RT
; routes to the rear gun instead of the front guns
ry := NumGet(xiState, 14, "Short")
if (ry < -16384)
gLookBack := true
else if (ry > -13000)
gLookBack := false
; fire + triggers, base vs layer vs look-back
RSGSet("LButton", !lbHeld && !gLookBack && gRTHeld)
RSGSet("1", lbHeld && gRTHeld)
RSGSet("2", gLTHeld) ; hp2 always - shifted too (hp5 backup rule)
RSGSet("5", lbHeld && gLTHeld)
; R3: look at target; while looking back, drop mines (hardpoint 4).
; "4" has ONE writer: OR of LB+B (dropper) and lookback-R3 (mines).
r3Held := (btns & 0x0080) != 0
RSGSet("e", r3Held && !gLookBack)
; B: base tap = cycle weapon (C); shifted = dropper (hardpoint 4)
RSGSet("4", (lbHeld && bHeld) || (r3Held && gLookBack))
if (!lbHeld && bHeld && !gBPrev)
SendEvent, c
gBPrev := bHeld
; X: cycle targets (Y key); shifted = radar zoom toggle (R)
RSGSet("y", !lbHeld && xHeld)
RSGSet("r", lbHeld && xHeld)
; Y: base = chase <-> cockpit toggle (F3/F1); shifted = cycle camera views
if (!lbHeld && yHeld && !gYPrev) {
gYExt := !gYExt
yKey := gYExt ? "F3" : "F1"
SendEvent, {%yKey%}
gCamIdx := gYExt ? 2 : 0 ; keep the LB+Y cycle in step
}
if (lbHeld && yHeld && !gYPrev) {
gCamIdx := Mod(gCamIdx + 1, 8)
camKey := StrSplit("F1,F2,F3,F7,F8,F9,F10,F1", ",")[gCamIdx + 1]
SendEvent, {%camKey%}
}
gYPrev := yHeld
; D-pad: base = utilities (held-mirrored keys, work inside map/notepad
; screens too); shifted = driving (reverse / gear down / gear up)
RSGSet("h", !lbHeld && dU)
RSGSet("i", !lbHeld && dD)
RSGSet("n", !lbHeld && dL)
RSGSet("m", stHeld || (!lbHeld && dR)) ; Start also = map
RSGSet("b", lbHeld && dU) ; binoculars
RSGSet("g", lbHeld && dD) ; horn (hold to honk)
RSGSet("-", lbHeld && dL)
RSGSet("=", lbHeld && dR)
if (lbHeld && (aHeld || bHeld || xHeld || yHeld || gRTHeld || gLTHeld || dU || dD || dL || dR))
gLBUsed := true
if (!lbHeld && gLBPrev && !gLBUsed && (A_TickCount - gLBt0 < 300)) {
SendEvent, q
RumblePulse(0, 20000, 1) ; tactile confirm: front-target snap
}
gLBPrev := lbHeld
; Select: Esc (pause / skip cutscene)
if (selHeld && !gSelPrev)
SendEvent, {Esc}
gSelPrev := selHeld
; ---- rumble mixer (2026-07-18, field-proven path). Design per the game-feel
; canon: hierarchy + restraint (continuous states LOW so transients read on
; top), distinct signature per event, left motor = heavy/low-freq, right =
; light/high-freq buzz. Two layers: continuous (nitrous/weapons/engine growl)
; + transient pulses (RumblePulse, last-event-wins). SetState only on change.
lyR := NumGet(xiState, 10, "Short")
nact := (gANitro || gL3Nitro)
if (nact && !gNitroPrev)
RumblePulse(65000, 30000, 2) ; nitrous ENGAGE kick
gNitroPrev := nact
rbHeld := (btns & 0x0200) != 0
if (rbHeld && !gRBPrev)
RumblePulse(30000, 0, 2) ; handbrake thud
gRBPrev := rbHeld
gearU := lbHeld && dR, gearD := lbHeld && dL
if ((gearU && !gGearUPrev) || (gearD && !gGearDPrev))
RumblePulse(18000, 52000, 2) ; gear-shift click (was under spin-up time)
gGearUPrev := gearU, gGearDPrev := gearD
mn := (lbHeld && bHeld) || (r3Held && gLookBack)
if (mn && !gMinePrev)
RumblePulse(55000, 15000, 4) ; mine/dropper thud (beefed up)
gMinePrev := mn
ign := !lbHeld && dD
if (ign && !gIgnPrev)
RumblePulse(16000, 0, 20) ; ignition: ~300ms starter crank
gIgnPrev := ign
cl := 0, cr := 0
gGrowl := Mod(gGrowl + 1, 60000) ; free-running texture clock
if (nact)
cl := 45000, cr := 20000
if (gRSGHeld["LButton"] || gRSGHeld["1"] || gRSGHeld["3"])
cr := cr > 15000 ? cr : 15000 ; RT/A fire buzz (was 18k; -~15%)
if (gRSGHeld["2"] || gRSGHeld["5"]) {
bz := Mod(gGrowl, 2) ? 14000 : 9000 ; LT fire: lighter, faster flutter
cr := cr > bz ? cr : bz
}
if (!nact && lyR > 12000) {
; engine growl scales with stick: further forward = stronger AND faster
; texture (field-tuned 2026-07-18: was 30% too strong, half the frequency)
fthr := (lyR - 12000) / 20767.0 ; 0.0 .. 1.0 deflection past start
per := 5 - Round(fthr * 3) ; texture half-period 5 -> 2 ticks
amp := 3500 + Round(fthr * 5000) ; 3.5k idle-edge -> 8.5k pinned
gv := Mod(gGrowl, per * 2) >= per ? amp : amp // 2
cl := cl > gv ? cl : gv
}
if (gTTicks > 0) {
gTTicks -= 1
cl := cl > gTL ? cl : gTL
cr := cr > gTR ? cr : gTR
}
cl := cl > 65535 ? 65535 : cl
cr := cr > 65535 ? 65535 : cr
vibPair := (cl << 16) | cr
if (vibPair != gXIVibLast) {
VarSetCapacity(xiVib, 4, 0)
NumPut(cl, xiVib, 0, "UShort"), NumPut(cr, xiVib, 2, "UShort")
DllCall(gXIDll . "\XInputSetState", "UInt", gXIPad, "Ptr", &xiVib)
gXIVibLast := vibPair
}
gXIPrevBtns := btns
return
RumblePulse(l, r, ticks) {
global gTL, gTR, gTTicks
gTL := l, gTR := r, gTTicks := ticks
}