Port layer only (zero src/ edits), per docs/dev/RUMBLE-PLAN.md: - port/src/input.c: inputRumbleSupported/inputRumble -> SDL_GameControllerRumble; detection via SDL_GameControllerHasRumble (SDL >= 2.0.18) else SDL_JoystickIsHaptic + known-type Windows fallback; zeroed on pad close. Single global gRumbleScale (dflt 0.5) registered as Input.RumbleScale. - port/src/libultra.c: the two M-87 blockers resolved - g_contStatus[i].status now sets CONT_CARD_ON for connected rumble-capable pads (so joyRumblePakInit arms) and osPfsInit returns PFS_ERR_DEVICE for them (so osMotorInit is attempted); osMotorInit/Start/Stop drive the haptics API. - port/src/optionsoverlay.c: one global 'Rumble strength' ROW_SLIDER (Input.RumbleScale, 0.05 detents, %.2f) in the INPUT section + kResetDefaults entry. The four per-player Input.PlayerN.RumbleScale keys were dropped (user decision 2026-09-28); stale keys in an existing ini log four 'unknown key' LOG_NOTE lines and self-clean on next configSave. - Real-pad haptics confirmed working by the user (2026-09-28). Build env: build-pc.sh self-heal guard revision - re-exec now writes a self-contained .ps1 (msys->native argv conversion mangles $-bearing -Command strings; file write is conversion-free), builds PATH explicitly, picks TMP from writable candidates (GetTempPath honours the broken inherited TMP), self-logs to build-pc/ge007-native-reexec-*.log, and treats a null LASTEXITCODE as 'did not execute' (PS 5.1 piped native commands error non-terminating). OPEN: the guard still fails inside build-pc.sh while the identical standalone .ps1 invocation works - AGENTS.md documents the workaround. docs: RUMBLE-PLAN.md (new, tracked), findings.md D401 entry + index rows, QOL-INVENTORY D224 -> DONE (D401), findings-index.csv regenerated.
12 KiB
Rumble Pak → real gamepad haptics (port plan)
Status: IMPLEMENTED — finding D401 (2026-09-27/28). Items 1+2+3 all
landed (port layer only; zero src/ edits), re-promoting the
DEPRIORITIZED finding D224 (M-87) to DONE. Real-pad haptics confirmed
working by the user (2026-09-28). Menu shape: one global Rumble strength slider (Input.RumbleScale, user decision 2026-09-28 — the
four per-player rows were dropped). Only the RumbleScale=0 silence check
remains unverified (trivial early-out path).
Scope: wire the N64 Rumble Pak path (already present in src/) through to
SDL_GameControllerRumble() on the PC. Port-layer only — zero src/game or
src/ logic edits, so rule 2 / ABI-exception do not apply.
Reference: the PD PC port (local checkout ../pd_port, port/src/input.c
port/src/libultra.c) implements the identical feature for the same Rare engine family. Copy its shape; the only friction is that GE'ssrc/joy.ccalls the classic libultra names (osMotorInit/Start/Stop,osPfsInit/ osPfsIsPlug) while PD's engine renamed them (osMotorProbe,__osMotorAccess,osPfsInitPak). We translate names, not logic.
Ground truth (current state)
Game side — already correct, untouched:
src/joy.cfull Rumble-Pak state machine:joyRumblePakInit(:178),joyRumblePakTick(:298),joyRumblePakStart/Stop. Call sites (verified 2026-09-27 for D401) live ingunfire.c:3284(start) /:3288(stop),bondview2.c:9722(start) /:9726(stop, incl. 2P second pad),lv.c:1688(stop at level start), plussched.c:272(stop on level teardown).src/motor.cis EXCLUDED from the PC build (N64 SI-DMA driver), replaced by no-op stubs. CMakeLists.txt:525 documents this.
Port side — all no-oped today, in port/src/libultra.c:
osPfsInit(:1413) →return PFS_ERR_NOPACK;osPfsIsPlug(:1415) →*pattern = 0; return 0;osMotorInit(:1417) →return -1;osMotorStart(:1419) →return -1;osMotorStop(:1420) →return -1;g_contStatus[i].status = 0;(:1102) —CONT_CARD_ONnever set.
Gamepad side — exists, no change needed:
port/src/input.cownsstatic SDL_GameController *pads[MAX_PADS](MAX_PADS = 4, aligned withMAXCONTROLLERS), open/close, hotplug,inputComputePad(). This is the exact objectSDL_GameControllerRumble()needs.
The two upstream blockers (D224 M-87 addendum — MUST land together)
joyRumblePakInit (src/joy.c:178) only drives the motor if BOTH pass:
g_ContStatus[index].status & CONT_CARD_ON→ today always 0, so init never even runs.osPfsInit(...)returnsPFS_ERR_ID_FATALorPFS_ERR_DEVICE→ today returnsPFS_ERR_NOPACK, soosMotorInitis never attempted.
If the SDL wiring is done without (1)+(2), the state machine parks at
RUMBLEPAKINITSTATE_NOT_READY forever and nothing rumbles despite "looking
wired." Both fixes are in the same file we already touch, so no extra cost.
Work items
1. port/src/input.c — detection + API
Add per-pad fields (GE uses flat statics, not PD's padsCfg[] array):
static int padRumbleOn[MAX_PADS]; // set where pads[i] is opened (line ~582)
static float padRumbleScale[MAX_PADS] = { 0.5f, 0.5f, 0.5f, 0.5f };
On pad open (in the SDL_GameControllerOpen path, line ~575-589), mirror PD:
#if SDL_VERSION_ATLEAST(2, 0, 18)
padRumbleOn[i] = SDL_GameControllerHasRumble(pads[i]);
#else
padRumbleOn[i] = SDL_JoystickIsHaptic(SDL_GameControllerGetJoystick(pads[i]));
if (!padRumbleOn[i]) {
const SDL_GameControllerType t = SDL_GameControllerGetType(pads[i]);
padRumbleOn[i] = t && (t != SDL_CONTROLLER_TYPE_VIRTUAL); // Win no-haptic fallback
}
#endif
On pad close, padRumbleOn[i] = 0;. GE has no inputCloseController
(PD-only) — the close sites are inputDestroy() and the close loop in
inputRescanPads(), both of which zero it (inputOpenPads re-detects on
open anyway).
New public fns (declare in port/include/input.h, define in input.c):
int inputRumbleSupported(int idx); // 0<=idx<MAX_PADS ? padRumbleOn[idx] : 0
void inputRumble(int idx, float strength, float time); // scaled SDL_GameControllerRumble
float inputRumbleGetScale(int idx);
void inputRumbleSetScale(int idx, float v);
inputRumble body (from PD, verbatim logic):
if (idx < 0 || idx >= MAX_PADS || !pads[idx]) return;
if (padRumbleScale[idx] <= 0.f) return;
if (padRumbleOn[idx]) {
strength *= padRumbleScale[idx];
if (strength <= 0.f) { strength = 0; time = 0; }
else { strength *= 65535.f; time *= 1000.f; }
SDL_GameControllerRumble(pads[idx], (Uint16)strength, (Uint16)strength, (Uint32)time);
}
In init, add the two PS4/5 hints (PD input.c:696-697) so BT pads report haptics:
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS4_RUMBLE, "1");
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS5_RUMBLE, "1");
Register per-pad config (in the existing configRegister* block, ~line 2457+):
char key[48];
for (int c = 0; c < MAX_PADS; ++c) {
sprintf(key, "Input.Player%d.RumbleScale", c + 1);
configRegisterFloat(key, &padRumbleScale[c], 0.f, 1.f);
}
Note: strFmt is NOT available in input.c (0 uses, no decl in
port/include) — use a local sprintf buffer as above. configRegisterFloat
is declared in port/include/config.h (already included by input.c:80); it's
used elsewhere (video.c) so the symbol is already in the link set.
2. port/src/libultra.c — rewire the shims
contSnapshotFromKeyboard (:1096): set the status bit for connected,
rumble-capable pads (need a per-channel capability check → call
inputRumbleSupported(i); it's declared in input.h so include it):
g_contStatus[i].type = connected ? CONT_TYPE_NORMAL : 0;
g_contStatus[i].status = (connected && inputRumbleSupported(i)) ? CONT_CARD_ON : 0;
Replace the 5 accessory stubs (:1413-1420) with real routing (PD libultra.c:235-351):
s32 osPfsInit(OSMesgQueue *q, OSPfs *pfs, int channel) {
(void)q;
// "pretend the Rumble Pak is present" when the real pad can rumble:
// returning PFS_ERR_DEVICE is what joyRumblePakInit (src/joy.c:186-190)
// needs to proceed to osMotorInit.
return inputRumbleSupported(channel) ? PFS_ERR_DEVICE : PFS_ERR_NOPACK;
}
s32 osPfsIsPlug(OSMesgQueue *q, u8 *pattern) {
(void)q;
if (pattern) {
*pattern = 0;
for (int i = 0; i < MAXCONTROLLERS; ++i)
if (inputRumbleSupported(i)) *pattern |= (u8)(1 << i);
}
return 0;
}
s32 osMotorInit(OSMesgQueue *mq, OSPfs *pfs, int channel) {
if (pfs && inputRumbleSupported(channel)) {
pfs->queue = mq;
pfs->channel = channel;
pfs->activebank = 0xff; // PD's osMotorProbe stores this too
// NOTE: PFS_MOTOR_INITIALIZED is NOT defined in GE's headers, and
// joy.c never reads pfs->status — it only checks (osMotorInit()==0)
// to set RUMBLEPAKINITSTATE_READY (src/joy.c:190). Skip the status store.
return 0;
}
(void)mq; (void)channel;
return PFS_ERR_NOPACK;
}
s32 osMotorStart(OSPfs *pfs) {
if (!pfs) return PFS_ERR_NOPACK;
// N64 motor has no duration; the game's joyRumblePakTimer60 (joy.c) turns it
// off. Issue one generous window, PD-style ("hope the timer stops it"):
inputRumble(pfs->channel, 1.0f, 5.0f);
return 0;
}
s32 osMotorStop(OSPfs *pfs) {
if (!pfs) return PFS_ERR_NOPACK;
inputRumble(pfs->channel, 0.0f, 0.0f); // zero-duration / zero-strength = stop
return 0;
}
JPN note (2026-09-27): _HW_VERSION_1 defines MAXCONTROLLERS as 6
while MAX_PADS is 4 — the MAXCONTROLLERS-bounded loops above are safe
because inputRumbleSupported() bounds-checks against MAX_PADS and reports
unsupported (silent) for i >= 4. osPfsIsPlug has no GE game-side call
sites — its rewiring is completeness/hygiene only; the load-bearing stubs
are osPfsInit/osMotor*.
Headers confirmed: OSPfs (include/PR/os.h:282) has queue + channel; the
error macros PFS_ERR_DEVICE (:642) and PFS_ERR_ID_FATAL (:641) and
PFS_ERR_NOPACK (:631) are all defined there. PFS_MOTOR_INITIALIZED is NOT
defined anywhere in GE's headers and joy.c never reads pfs->status, so it
is not needed. input.h is already included by libultra.c (line 50), so
inputRumbleSupported is directly callable there.
3. (DONE 2026-09-28) options-menu slider — single global
Landed in port/src/optionsoverlay.c (not PD's optionsmenu.c — GE's F10
overlay + front options screen share rows[], walked by frontoptions.c
via the optionsRow* accessors): ONE ROW_SLIDER row Rumble strength
on Input.RumbleScale (0.05 detents, generic CONFIG_OPT_FLOAT display
%.2f), in the INPUT section after the controller rows, plus a
kResetDefaults entry (0.5, the gRumbleScale C initializer) so INPUT's
"Reset to defaults" covers it.
Decision (user, 2026-09-28): the original four-per-player-row design
(Rumble (Player 1..4) on Input.Player%d.RumbleScale) was coded and
reviewed, then replaced with the single global row before its first
compile: the scale applies to every rumble-capable pad, and the four
Input.PlayerN.RumbleScale ini keys are dropped — stale keys in an
existing ini log four harmless unknown key LOG_NOTE lines at config
load and self-clean on the next configSave (which rewrites the whole
file with the registered keys). Per-player rumble tuning stays a G-class
stretch goal (QOL-INVENTORY) — and a per-pad override-on-top-of-global
design would be a no-op for everyone anyway, because configSave
rewrites all registered keys and the "user explicitly set this?" signal
is burned as soon as any key exists at the 0.5 default. The
row-disabled-when-unsupported state is deferred with the per-pad tuning
row (the overlay has no generic row-disabled mechanism); the row is inert
(no-op) on non-rumble setups.
Original plan (for reference): PD wires the per-pad scale into the
options menu (optionsmenu.c:533-540): inputRumbleGetScale(player),
inputRumbleSetScale(player, v/10), disabled when
!inputRumbleSupported(player). In GE this maps onto
port/src/optionsoverlay.c / the per-pad tuning row (QOL-INVENTORY.md).
Config file value alone is enough for the feature; the UI is the polish.
Verification ritual (AGENTS.md)
./build-pc.sh ntsc-finalcompiles clean (2026-09-27). NewinputRumble*defined once in input.c, declared in input.h; no new undefined/duplicate symbols (full/linkchecksweep pending at D401 close).CONT_CARD_ON+PFS_ERR_DEVICEland (the two-blocker addendum); the state machine's READY path is provable by code read — the runtime proof is folded into the real-pad test below.- By-hand, real pad: user confirmed the rumble works on a real
controller (2026-09-28) — fire a weapon (
gunfire.c:3284recoil), explosion rumble, 2P second pad (bondview2.c:9722/9726). Non-rumble pad and mouse/keyboard-only stay silent by construction (pads[idx]NULL /padRumbleOn0 ⇒inputRumbleno-op). Input.RumbleScale=0fully silences (unverified — the trivialgRumbleScale <= 0early-out ininputRumble); max value loud (confirmed implicitly by the working playtest at scale 0.5).
Finding log
Done (2026-09-27): D401 added to docs/dev/findings.md (§F index +
full entry) documenting the D224 implementation + the two-blocker
resolution; the QOL-INVENTORY.md M-87/D224 row re-promoted
DEPRIORITIZED → DONE (D401); docs/dev/findings-index.csv regenerated.
Effort
- Items 1+2: ~1–2 h coding (mostly a rename + copy from PD) + build/linkcheck.
- Hands-on pad testing: ~30 min–1 h (the only genuinely open-ended part — feels-right tuning).
- Item 3 (UI slider): ~1 h, landed 2026-09-28 as the single global
Rumble strengthrow.
Total for working core: half a day max, <2 h of pure code.