From 10fff1483dc71026f003a03fa0b1293965c53d72 Mon Sep 17 00:00:00 2001 From: Mark <97573870+BuckleBean@users.noreply.github.com> Date: Mon, 21 Sep 2026 07:49:30 -0400 Subject: [PATCH 1/9] input: make the left stick click delay configurable v0.3.0 delays the left stick click by 150 ms so the two-stick weapon calibration chord doesn't also crouch. Layouts that move crouch off the left click pay that delay on every press for no benefit. [VR] LeftStickClickDelayMs sets it (default 150, the current behavior; 0 sends the click at once; clamped to 0-1000). The value is a setting, not mapper state, so it survives Mapper::Reset(), which runs on every calibration chord and focus loss. The companion logs the value it loaded. Co-Authored-By: Claude Opus 5 --- DeusExHRVR.ini.example | 5 +++++ src/ControllerInput.h | 1 + src/GamepadMapping.h | 10 +++++++--- src/NativeBridge.cpp | 3 +++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/DeusExHRVR.ini.example b/DeusExHRVR.ini.example index d68afb7..9c1f9ca 100644 --- a/DeusExHRVR.ini.example +++ b/DeusExHRVR.ini.example @@ -19,6 +19,11 @@ MotionControls=1 ImmersiveScope=0 ScopeMagnification=4 +; Left thumbstick click waits this long (ms) before a hold registers, so the +; two-stick weapon calibration chord doesn't also crouch. 0 sends it at once, +; for layouts that map the left click to something other than crouch. +LeftStickClickDelayMs=150 + ; Independent directions: Mouse, Headset, or Controller (right-hand aim). ; Mouse retains native mouse/gamepad look direction. Restart after editing. ; Interaction selects doors/items; it does not change weapon aiming. diff --git a/src/ControllerInput.h b/src/ControllerInput.h index 97e7525..f09dbaf 100644 --- a/src/ControllerInput.h +++ b/src/ControllerInput.h @@ -19,6 +19,7 @@ class ControllerInput { for(auto& a:action)a=XR_NULL_HANDLE; mapper.Reset(); } + void SetLeftClickDelayMs(uint64_t ms){mapper.SetLeftClickDelay(ms);} bool Init(XrInstance instance,XrSession session) { XrActionSetCreateInfo set{XR_TYPE_ACTION_SET_CREATE_INFO}; strcpy_s(set.actionSetName,"motion_controller");strcpy_s(set.localizedActionSetName,"VR gamepad and weapon"); diff --git a/src/GamepadMapping.h b/src/GamepadMapping.h index 9db1325..b2d4c6a 100644 --- a/src/GamepadMapping.h +++ b/src/GamepadMapping.h @@ -16,8 +16,12 @@ class Mapper { bool consumedX{},consumedY{},calibrationChord{},leftDown{}; uint64_t leftStart{},leftUntil{}; uint64_t menuStart{},rightStart{},backUntil{},startUntil{},scopeUntil{},lastTick{}; + uint64_t leftClickDelay=150; // setting, not state: survives Reset() public: - void Reset(){*this={};} + void Reset(){auto delay=leftClickDelay;*this={};leftClickDelay=delay;} + // [VR] LeftStickClickDelayMs: 0 sends the left click at once. Layouts that + // move crouch off the left click don't need the calibration-chord guard. + void SetLeftClickDelay(uint64_t ms){leftClickDelay=ms;} Transport::Gamepad Update(const Input& in,uint64_t now) { Transport::Gamepad out{}; if(!in.active || (lastTick && now.5f,XINPUT_GAMEPAD_RIGHT_SHOULDER); // A short grace period prevents crouch when the two clicks arrive apart. if(in.leftClick&&!leftDown)leftStart=now; - if(!in.leftClick&&leftDown&&now-leftStart<150)leftUntil=now+120; + if(!in.leftClick&&leftDown&&now-leftStart=150)||now=leftClickDelay)||now(GetPrivateProfileIntW(L"VR",L"LeftStickClickDelayMs",150,config)),0,1000); + controller.SetLeftClickDelayMs(static_cast(leftClickDelay)); + Log("Left stick click delay: %d ms (LeftStickClickDelayMs)",leftClickDelay); if(trackingChannel && DirectionConfig::MotionEnabled(config)) { bool ok=controller.Init(instance,session);Log("Motion controller aim and Xbox buttons: %s",ok?"ready":"unavailable; native input retained"); if(!ok)controller.Reset(); From 539b17b80df9010b804b075a40a8366d5a872739 Mon Sep 17 00:00:00 2001 From: Mark <97573870+BuckleBean@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:22:47 -0400 Subject: [PATCH 2/9] input: right stick up/down buttons with smooth turn RightStickUp and RightStickDown only worked with SnapTurn=1, because the code lived inside SnapTurn(). Players who dislike snap turn asked for jump and crouch on the stick too. With SnapTurn=0, setting either key now takes the right stick's vertical axis during gameplay and leaves the horizontal axis for smooth turning. It uses the same 30-degree cone as snap turn, and a push inside the cone also holds the turn so a jump or crouch doesn't drift the view. The gameplay check is the one snap turn uses, so menus, terminals, the scope and the weapon wheel keep the native stick. Nothing changes unless a key is set. Co-Authored-By: Claude Opus 5 --- DeusExHRVR.ini.example | 5 +++-- README.md | 2 +- src/GamepadBridge.cpp | 20 +++++++++++++++++++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/DeusExHRVR.ini.example b/DeusExHRVR.ini.example index 9c1f9ca..6a59178 100644 --- a/DeusExHRVR.ini.example +++ b/DeusExHRVR.ini.example @@ -95,8 +95,9 @@ SnapTurnMouseCounts=300 ; The game ignores mouse look while the left stick is deflected, so walking is ; released for at most this long around the injected move. SnapTurnPauseMs=60 -; With SnapTurn=1 the right stick's vertical axis is free: RightStickUp and -; RightStickDown under [Buttons] hold a target while it is pushed up or down. +; RightStickUp and RightStickDown under [Buttons] hold a target while the right +; stick is pushed up or down during gameplay. With SnapTurn=0, setting either one +; takes the stick's vertical axis for them; smooth turning keeps the horizontal. ; Optional button layout. [Buttons] applies to gameplay and scoped aiming, ; [ScreenButtons] to everything else: the title and pause menus, the in-game diff --git a/README.md b/README.md index 62c0120..78b30fb 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ Inputs are `RightA`, `RightB`, `LeftX`, `LeftY`, `LeftGrip`, `RightGrip`, `LeftS The turn is one injected relative mouse move, which avoids the stick's acceleration ramp. `SnapTurnMouseCounts` is the size of that move; the mod measures the resulting heading change after each snap and writes a corrected value back to the INI, so it self-calibrates within a few turns from any starting value. The game appears to ignore mouse look while the gamepad left stick is deflected, so walking is released for up to `SnapTurnPauseMs` (default 60) around the injected move and resumed as soon as the camera has visibly turned. -With `SnapTurn=1`, the right stick's vertical axis is free, so `RightStickUp` and `RightStickDown` under `[Buttons]` can hold a button while the stick is pushed within 30 degrees of vertical (for example `RightStickUp=A` to jump and `RightStickDown=LS` to crouch). +`RightStickUp` and `RightStickDown` under `[Buttons]` can hold a button while the right stick is pushed within 30 degrees of vertical during gameplay (for example `RightStickUp=A` to jump and `RightStickDown=LS` to crouch). This gives up vertical stick aiming, which you don't need when aiming with the controller (`ExperimentalMotionControls=1`). It works with snap turn or smooth turn. With `SnapTurn=0`, setting either key takes the stick's vertical axis for these buttons and leaves the horizontal axis for smooth turning; a push inside the 30-degree cone also holds the turn, so jumping or crouching doesn't drift the view. Menus, terminals and the scope keep the native right stick. ## Restore the original game diff --git a/src/GamepadBridge.cpp b/src/GamepadBridge.cpp index 9279a72..9f954b0 100644 --- a/src/GamepadBridge.cpp +++ b/src/GamepadBridge.cpp @@ -54,7 +54,7 @@ bool remapActive{}; // terminals, hacking, videos, game over, loading screens). uint32_t screenTarget[buttonCount]{};uint32_t screenLeftTrigger=TargetLT,screenRightTrigger=TargetRT; bool screenRemapActive{}; -uint32_t stickUpTarget{},stickDownTarget{}; // right stick up/down during gameplay (needs SnapTurn=1) +uint32_t stickUpTarget{},stickDownTarget{}; // right stick up/down during gameplay (snap or smooth turn) uint32_t ParseTarget(const wchar_t* key,uint32_t fallback,const wchar_t* section=L"Buttons") { wchar_t value[32]{};GetPrivateProfileStringW(section,key,L"",value,32,configPath); @@ -268,6 +268,23 @@ void SnapTurn(XINPUT_GAMEPAD& pad) { inject(); } +// Without snap turn the right stick normally reaches the game untouched. When +// RightStickUp or RightStickDown is set, gameplay keeps the horizontal axis for +// smooth turning and gives the vertical axis to those buttons, with the same +// 30-degree cone as snap turn. A push inside the cone also holds the turn, so a +// jump or crouch doesn't drift the view. +void StickButtons(XINPUT_GAMEPAD& pad) { + float yaw=0; + if(!EngineCamera::SnapTurnView(yaw))return; // menus/terminals/scope keep the native right stick + double rx=pad.sThumbRX/32767.,ry=pad.sThumbRY/32767.; + double magnitude=std::hypot(rx,ry); + pad.sThumbRY=0; + if(magnitude>=.6 && std::abs(ry)>=magnitude*.866) { + pad.sThumbRX=0; + auto target=ry>0?stickUpTarget:stickDownTarget; + if(target)Emit(pad,target,true,BYTE(255)); + } +} bool Read(XINPUT_GAMEPAD& pad) { if(!enabled || !channel)return false; Transport::Tracking sample{}; @@ -288,6 +305,7 @@ bool Read(XINPUT_GAMEPAD& pad) { ApplyButtons(pad); if(EngineCamera::ImmersiveScopeButton())pad.wButtons|=XINPUT_GAMEPAD_RIGHT_THUMB; if(snapEnabled)SnapTurn(pad); + else if(stickUpTarget || stickDownTarget)StickButtons(pad); } // Keep the device connected after first activation, but release every // button/axis on focus loss, sleeping controllers or a stalled companion. From 4477067809e62140d48176025b9b199e010568c8 Mon Sep 17 00:00:00 2001 From: Mark <97573870+BuckleBean@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:10:31 -0400 Subject: [PATCH 3/9] camera: hold the camera on the player's path while running Measured over two 46 s runs: the game sways the camera sideways relative to the player entity at the stride (667 ms) and step (333 ms) rates, about 1 cm peak to peak, while the entity itself travels straight. Every other axis of the run animation (heading, roll, pitch, height) was already removed before the headset; this one passed through because the mod never touched horizontal position. [VR] SideSwayHoldMs (default 0, off) places the VR camera at the entity origin plus the camera's offset averaged over that many milliseconds; 667 is one running stride. The offset barely changes while moving, so the average cancels the sway without lag. Simulated on the trace: stride sway +/-0.36-0.50 cm -> +/-0.05-0.06 cm, step sway +/-0.28-0.45 -> +/-0.01-0.09. Offsets past 15 cm (camera cuts, cover) pass through and the correction eases out at 50 cm/s instead of popping; loads, teleports and pauses reset it like the other filters. The Camera hooks log line reports the setting. Co-Authored-By: Claude Opus 5 --- DeusExHRVR.ini.example | 5 ++++ README.md | 2 ++ src/EngineCamera.cpp | 54 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/DeusExHRVR.ini.example b/DeusExHRVR.ini.example index 6a59178..1172021 100644 --- a/DeusExHRVR.ini.example +++ b/DeusExHRVR.ini.example @@ -71,6 +71,11 @@ StanceHold=0 ; transition speed. StanceHoldTrigger=60 +; Optional: keep the camera on the player's own path instead of following the +; run animation's side-to-side sway (about 1 cm, once per step). The value is +; the averaging window in milliseconds; 667 is one running stride. 0 disables. +SideSwayHoldMs=0 + ; Optional filtering of the walk animation's left-right heading swing, in ; milliseconds; 0 disables each stage. Two cascaded stages (walking, then a ; second rhythm for sprinting). Needs YawOnlyCamera=1 to take effect. diff --git a/README.md b/README.md index 78b30fb..0386fde 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,8 @@ Measured against the player entity over 9000 frames on the supported build: the Holding the height instead makes gait offsets and walking bob invisible, while a real stance change - crouched and standing differ by about 300 units - is followed at the speed of the game's own crouch/stand transition (about 1000 units/s, measured) until the camera settles. `StanceHoldTrigger` separates the two: gait offsets are 15-25 units and walking bob is about 2. Defaults to `0`. Ladders, cover, vaulting and elevators have not been tested with it yet. +`SideSwayHoldMs` under `[VR]` does the same for sideways motion. While running, the game sways the camera side to side relative to the player's own position, about 1 cm peak to peak, once per step and once per stride, while the body itself travels straight (measured over two 46-second runs; standing, the camera sits exactly on the body, and running leans it about 3 cm forward). With `SideSwayHoldMs=667` - one running stride - the VR camera stays on the player's path at the camera's average offset over the last 667 ms, which cancels the sway without lag because that offset barely changes while moving; in the measured runs it removed about 85-90% of the stride sway and nearly all of the step sway. Offsets larger than 15 cm (camera cuts, cover and similar) pass through untouched. Defaults to `0` (off). + Optional heading-swing filtering smooths the walk animation's left-right swing of the camera heading out of the VR view, without touching the game's own camera. It needs `YawOnlyCamera=1`. Each setting is a time window in milliseconds and defaults to `0` (off): ```ini diff --git a/src/EngineCamera.cpp b/src/EngineCamera.cpp index c062071..4469c25 100644 --- a/src/EngineCamera.cpp +++ b/src/EngineCamera.cpp @@ -233,6 +233,49 @@ struct StanceHold { return held; } } stanceHold; +// Hold the camera's horizontal position on the player's own path. +// +// Measured while running (two 46 s runs at 5.2 m/s): the game sways the camera +// sideways relative to the player entity at the stride (667 ms) and step +// (333 ms) rates, about 1 cm peak to peak, while the entity itself travels +// straight. Standing, the camera sits exactly on the entity origin; running +// leans it about 3 cm forward. So place the camera at the entity origin plus +// its offset averaged over one stride: that offset barely changes while +// moving, so the average removes the sway without lag. Running and sprinting +// offsets stay under 7 cm; anything past the limit (camera cuts, cover, +// takedowns) passes through, and the correction eases out rather than popping. +struct SwayHold { + int windowMs{}; // [VR] SideSwayHoldMs; 0 = off + static constexpr float limit=45.f; // game units (15 cm) + static constexpr float rate=150.f; // how fast the correction eases out, units/s + struct Sample {double t;float x,y;}; + std::array ring{};size_t head{},size{}; + double sumX{},sumY{},lastT{};float cx{},cy{},lastX{},lastY{};bool have{}; + void Clear(){size=0;sumX=sumY=0;} + void Reset(){Clear();cx=cy=0;have=false;} + const Sample& Oldest() const {return ring[(head+ring.size()-size)%ring.size()];} + void Apply(float& camX,float& camY,float entX,float entY,double now) { + float dx=camX-entX,dy=camY-entY; + bool jump=!have || now500 || std::hypot(dx-lastX,dy-lastY)>300; // load, teleport, pause + double dt=have?std::clamp((now-lastT)/1000.,0.,0.1):0.; + if(jump)Clear(); + float tx=0,ty=0; + bool inside=std::hypot(dx,dy)<=limit; + if(inside) { + if(size==ring.size()){sumX-=Oldest().x;sumY-=Oldest().y;size--;} + ring[head]={now,dx,dy};head=(head+1)%ring.size();size++;sumX+=dx;sumY+=dy; + while(size>1 && now-Oldest().t>windowMs){sumX-=Oldest().x;sumY-=Oldest().y;size--;} + tx=float(sumX/double(size))-dx;ty=float(sumY/double(size))-dy; + } else Clear(); + // Follow the target while holding; ease toward it when it would jump. + float step=float(rate*dt),ex=tx-cx,ey=ty-cy,d=std::hypot(ex,ey); + if(inside && d<=step*4){cx=tx;cy=ty;} + else if(d>step && d>0){cx+=ex/d*step;cy+=ey/d*step;} + else {cx=tx;cy=ty;} + camX+=cx;camY+=cy; + lastT=now;lastX=dx;lastY=dy;have=true; + } +} swayHold; // Heading (yaw) swing filter: two cascaded stages; snap turns and other jumps // (> 3 deg in one frame) are tracked as an offset so they pass through instantly. struct YawSwing { @@ -780,6 +823,14 @@ void __fastcall UpdateHook(void* self,void*) { renderBase.m[14]=entZ+stanceHold.Apply(eye,ms); else stanceHold.Reset(); // no player (menus, cutscenes): leave the camera alone } + if(swayHold.windowMs>0) { + auto entity=*reinterpret_cast(active+0xaa0); + float entX=entity?*reinterpret_cast(entity+0x20):0.f; + float entY=entity?*reinterpret_cast(entity+0x24):0.f; + if(entity && std::isfinite(entX) && std::isfinite(entY)) + swayHold.Apply(renderBase.m[12],renderBase.m[13],entX,entY,ms); + else swayHold.Reset(); + } if(yawOnlyCamera && (yawSwing.a.enabled||yawSwing.b.enabled)) { // renderBase is pure heading here: rebuild it from the filtered yaw. double yaw=std::atan2(renderBase.m[9],renderBase.m[8])*180/3.14159265358979; @@ -1178,6 +1229,7 @@ void Install() { yawSwing.a.enabled=yawSwing.a.windowMs[0]>0;yawSwing.b.enabled=yawSwing.b.windowMs[0]>0; stanceHold.enabled=GetPrivateProfileIntW(L"VR",L"StanceHold",0,config)!=0; readFloat(L"StanceHoldTrigger",L"60",5,400,stanceHold.trigger); + swayHold.windowMs=readInt(L"SideSwayHoldMs",0,2000); stanceHold.Reset(); motionControls=DirectionConfig::MotionEnabled(config); experimentalMotionControls=motionControls && GetPrivateProfileIntW(L"VR",L"ExperimentalMotionControls",0,config)!=0; @@ -1248,7 +1300,7 @@ void Install() { } } FILE* f{};if(!fopen_s(&f,"DeusExHRVR-camera.log","a")) { - fprintf(f,"Camera hooks base=%p enabled=%d unitsPerMetre=%g lockVerticalCamera=%d levelRecenter=%d levelMenu=%d yawOnlyCamera=%d stanceHold=%d/%g/%g yawMs=%d/%d yawLimit=%g F6=toggle F9=recenter\n",reinterpret_cast(base),enabled,worldScale,lockVerticalCamera,levelRecenter,levelMenu,yawOnlyCamera,stanceHold.enabled,stanceHold.trigger,stanceHold.rate,yawSwing.a.windowMs[0],yawSwing.b.windowMs[0],yawSwing.a.limit[0]);fclose(f); + fprintf(f,"Camera hooks base=%p enabled=%d unitsPerMetre=%g lockVerticalCamera=%d levelRecenter=%d levelMenu=%d yawOnlyCamera=%d stanceHold=%d/%g/%g yawMs=%d/%d yawLimit=%g sideSwayHold=%d F6=toggle F9=recenter\n",reinterpret_cast(base),enabled,worldScale,lockVerticalCamera,levelRecenter,levelMenu,yawOnlyCamera,stanceHold.enabled,stanceHold.trigger,stanceHold.rate,yawSwing.a.windowMs[0],yawSwing.b.windowMs[0],yawSwing.a.limit[0],swayHold.windowMs);fclose(f); } } } From 7aa4851c19a53d11809db3eadcfad0d04b46f964 Mon Sep 17 00:00:00 2001 From: Mark <97573870+BuckleBean@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:43:59 -0400 Subject: [PATCH 4/9] camera: cap the side-sway hold at 4 cm from the game camera The hold could lag a slow deliberate camera move (a gradual pull-back into cover) by up to the 15 cm pass-through limit before letting go. Clamp the correction to 12 units (4 cm) so the VR camera never trails the game's camera by more than that, whatever the transition. Simulated on the 2026-09-21 trace: running corrections have a 99th percentile of 3.4 cm, the cap binds on 0.23% of running frames, and the stride/step residuals are unchanged. Also measured: most of the correction is forward (RMS ~1.8 cm) - the game camera surges ahead of and behind the body each step, and the held output is as smooth as the body (forward-speed jitter +/-0.39 m/s vs the camera's +/-0.7). Co-Authored-By: Claude Opus 5 --- README.md | 2 +- src/EngineCamera.cpp | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0386fde..cc1c674 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ Measured against the player entity over 9000 frames on the supported build: the Holding the height instead makes gait offsets and walking bob invisible, while a real stance change - crouched and standing differ by about 300 units - is followed at the speed of the game's own crouch/stand transition (about 1000 units/s, measured) until the camera settles. `StanceHoldTrigger` separates the two: gait offsets are 15-25 units and walking bob is about 2. Defaults to `0`. Ladders, cover, vaulting and elevators have not been tested with it yet. -`SideSwayHoldMs` under `[VR]` does the same for sideways motion. While running, the game sways the camera side to side relative to the player's own position, about 1 cm peak to peak, once per step and once per stride, while the body itself travels straight (measured over two 46-second runs; standing, the camera sits exactly on the body, and running leans it about 3 cm forward). With `SideSwayHoldMs=667` - one running stride - the VR camera stays on the player's path at the camera's average offset over the last 667 ms, which cancels the sway without lag because that offset barely changes while moving; in the measured runs it removed about 85-90% of the stride sway and nearly all of the step sway. Offsets larger than 15 cm (camera cuts, cover and similar) pass through untouched. Defaults to `0` (off). +`SideSwayHoldMs` under `[VR]` does the same for sideways motion. While running, the game sways the camera side to side relative to the player's own position, about 1 cm peak to peak, once per step and once per stride, while the body itself travels straight (measured over two 46-second runs; standing, the camera sits exactly on the body, and running leans it about 3 cm forward). With `SideSwayHoldMs=667` - one running stride - the VR camera stays on the player's path at the camera's average offset over the last 667 ms, which cancels the sway without lag because that offset barely changes while moving; in the measured runs it removed about 85-90% of the stride sway and nearly all of the step sway. It also removes the camera's step-by-step forward-and-back surge relative to the body, since the body's own path is the smoother of the two. Offsets larger than 15 cm (camera cuts, cover and similar) pass through untouched, and the VR camera is never held more than 4 cm from the game's camera, so deliberate camera moves can't lag behind. Defaults to `0` (off). Optional heading-swing filtering smooths the walk animation's left-right swing of the camera heading out of the VR view, without touching the game's own camera. It needs `YawOnlyCamera=1`. Each setting is a time window in milliseconds and defaults to `0` (off): diff --git a/src/EngineCamera.cpp b/src/EngineCamera.cpp index 4469c25..e4f9d4b 100644 --- a/src/EngineCamera.cpp +++ b/src/EngineCamera.cpp @@ -248,6 +248,10 @@ struct SwayHold { int windowMs{}; // [VR] SideSwayHoldMs; 0 = off static constexpr float limit=45.f; // game units (15 cm) static constexpr float rate=150.f; // how fast the correction eases out, units/s + // Never more than 4 cm from the game's camera, so deliberate camera moves + // (cover, pull-backs) can't lag behind. Running corrections stay under it: + // 99th percentile 3.4 cm; the cap binds on 0.2% of running frames. + static constexpr float cap=12.f; struct Sample {double t;float x,y;}; std::array ring{};size_t head{},size{}; double sumX{},sumY{},lastT{};float cx{},cy{},lastX{},lastY{};bool have{}; @@ -272,6 +276,7 @@ struct SwayHold { if(inside && d<=step*4){cx=tx;cy=ty;} else if(d>step && d>0){cx+=ex/d*step;cy+=ey/d*step;} else {cx=tx;cy=ty;} + if(float m=std::hypot(cx,cy);m>cap){cx*=cap/m;cy*=cap/m;} camX+=cx;camY+=cy; lastT=now;lastX=dx;lastY=dy;have=true; } From 801adb78454d925821fa262b620056e50ac2e732 Mon Sep 17 00:00:00 2001 From: Mark <97573870+BuckleBean@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:47:29 -0400 Subject: [PATCH 5/9] camera: optional HRDCfix sleep fix (SleepFix) HRDCfix (github.com/imring/HRDCfix, MIT, by imring) fixes a stutter in the Director's Cut: a spin-wait at 0x54c800 loops on Sleep(1) while a busy flag is set. The game never calls timeBeginPeriod, so each Sleep(1) can last a whole ~15.6 ms timer tick. Changing the push to Sleep(0) makes it only yield. [VR] SleepFix (default 0) applies that one-byte patch from the VR mod instead of HRDCfix's DX11 ASI loader, which installs as d3d11.dll and would replace the mod's own proxy. The loop's bytes are compared first (except the four relocated import-address bytes), and Install() only runs on the supported executable. The Camera hooks log line reports sleepFix=1 when the patch was applied. Co-Authored-By: Claude Opus 5 --- DeusExHRVR.ini.example | 5 +++++ README.md | 2 ++ src/EngineCamera.cpp | 22 +++++++++++++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/DeusExHRVR.ini.example b/DeusExHRVR.ini.example index 1172021..87987a7 100644 --- a/DeusExHRVR.ini.example +++ b/DeusExHRVR.ini.example @@ -76,6 +76,11 @@ StanceHoldTrigger=60 ; the averaging window in milliseconds; 667 is one running stride. 0 disables. SideSwayHoldMs=0 +; Optional stutter fix from HRDCfix (github.com/imring/HRDCfix): a wait loop +; in the game sleeps a whole Windows timer tick (~15 ms) at a time; 1 makes it +; only yield instead. Needs no ASI loader when applied here. +SleepFix=0 + ; Optional filtering of the walk animation's left-right heading swing, in ; milliseconds; 0 disables each stage. Two cascaded stages (walking, then a ; second rhythm for sprinting). Needs YawOnlyCamera=1 to take effect. diff --git a/README.md b/README.md index cc1c674..12ff31b 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,8 @@ Holding the height instead makes gait offsets and walking bob invisible, while a `SideSwayHoldMs` under `[VR]` does the same for sideways motion. While running, the game sways the camera side to side relative to the player's own position, about 1 cm peak to peak, once per step and once per stride, while the body itself travels straight (measured over two 46-second runs; standing, the camera sits exactly on the body, and running leans it about 3 cm forward). With `SideSwayHoldMs=667` - one running stride - the VR camera stays on the player's path at the camera's average offset over the last 667 ms, which cancels the sway without lag because that offset barely changes while moving; in the measured runs it removed about 85-90% of the stride sway and nearly all of the step sway. It also removes the camera's step-by-step forward-and-back surge relative to the body, since the body's own path is the smoother of the two. Offsets larger than 15 cm (camera cuts, cover and similar) pass through untouched, and the VR camera is never held more than 4 cm from the game's camera, so deliberate camera moves can't lag behind. Defaults to `0` (off). +`SleepFix=1` under `[VR]` applies the stutter fix from [HRDCfix](https://github.com/imring/HRDCfix) by imring (MIT). One of the game's wait loops calls `Sleep(1)` while it waits; the game never raises the Windows timer resolution, so each call can last a whole timer tick (about 15.6 ms, nearly two frames at 120 Hz). The fix changes it to `Sleep(0)`, which only yields. Applying it from the VR mod avoids HRDCfix's DX11 ASI loader, which installs as `d3d11.dll` and would replace the mod's own proxy. The loop's bytes are checked before patching. Defaults to `0` (off). + Optional heading-swing filtering smooths the walk animation's left-right swing of the camera heading out of the VR view, without touching the game's own camera. It needs `YawOnlyCamera=1`. Each setting is a time window in milliseconds and defaults to `0` (off): ```ini diff --git a/src/EngineCamera.cpp b/src/EngineCamera.cpp index e4f9d4b..e0ff001 100644 --- a/src/EngineCamera.cpp +++ b/src/EngineCamera.cpp @@ -97,6 +97,7 @@ float worldScale=100.f; // Local patch: optional per-frame camera trace for head-bob analysis. // Buffered in memory (4 MB stdio buffer) so it rarely touches the disk. bool bobTrace=false;FILE* bobFile{};uint32_t bobLines{}; +bool sleepFix=false; // [VR] SleepFix applied (see Install) void BobTrace(double ms,const CameraMath::Matrix& g,const float out[3],const Transport::Tracking& t) { if(bobLines>=36000)return; if(!bobFile) { @@ -1222,6 +1223,25 @@ void Install() { levelMenu=GetPrivateProfileIntW(L"VR",L"LevelMenu",1,config)!=0; yawOnlyCamera=GetPrivateProfileIntW(L"VR",L"YawOnlyCamera",0,config)!=0; bobTrace=GetPrivateProfileIntW(L"VR",L"BobTrace",0,config)!=0; + // Optional stutter fix from HRDCfix (github.com/imring/HRDCfix, MIT). + // 0x54c800 spin-waits on Sleep(1) while a busy flag is set. The game never + // calls timeBeginPeriod, so each Sleep(1) can last a whole timer tick + // (~15.6 ms, nearly two frames at 120 Hz); Sleep(0) only yields. Bytes + // 13-16 hold Sleep's import address, relocated each launch, so they are + // not compared. An already-patched byte simply doesn't match. + if(GetPrivateProfileIntW(L"VR",L"SleepFix",0,config)!=0) { + static const unsigned char spin[]={0x56,0x8b,0xf1,0x8a,0x46,0x08,0x84,0xc0,0x74,0x13,0x57,0x8b,0x3d,0,0,0,0,0x6a,0x01}; + auto code=reinterpret_cast(VA(0x54c800)); + bool match=true; + for(size_t i=0;i16)&&code[i]!=spin[i]){match=false;break;} + DWORD old{}; + if(match&&VirtualProtect(code+0x12,1,PAGE_EXECUTE_READWRITE,&old)) { + code[0x12]=0x00; // push 1 -> push 0 + VirtualProtect(code+0x12,1,old,&old); + FlushInstructionCache(GetCurrentProcess(),code+0x12,1); + sleepFix=true; + } + } auto readInt=[&](const wchar_t* key,int fallback,int hi){return std::clamp(static_cast(GetPrivateProfileIntW(L"VR",key,fallback,config)),0,hi);}; auto readFloat=[&](const wchar_t* key,const wchar_t* fallback,float lo,float hi,float& target) { wchar_t text[32]{};GetPrivateProfileStringW(L"VR",key,fallback,text,32,config); @@ -1305,7 +1325,7 @@ void Install() { } } FILE* f{};if(!fopen_s(&f,"DeusExHRVR-camera.log","a")) { - fprintf(f,"Camera hooks base=%p enabled=%d unitsPerMetre=%g lockVerticalCamera=%d levelRecenter=%d levelMenu=%d yawOnlyCamera=%d stanceHold=%d/%g/%g yawMs=%d/%d yawLimit=%g sideSwayHold=%d F6=toggle F9=recenter\n",reinterpret_cast(base),enabled,worldScale,lockVerticalCamera,levelRecenter,levelMenu,yawOnlyCamera,stanceHold.enabled,stanceHold.trigger,stanceHold.rate,yawSwing.a.windowMs[0],yawSwing.b.windowMs[0],yawSwing.a.limit[0],swayHold.windowMs);fclose(f); + fprintf(f,"Camera hooks base=%p enabled=%d unitsPerMetre=%g lockVerticalCamera=%d levelRecenter=%d levelMenu=%d yawOnlyCamera=%d stanceHold=%d/%g/%g yawMs=%d/%d yawLimit=%g sideSwayHold=%d sleepFix=%d F6=toggle F9=recenter\n",reinterpret_cast(base),enabled,worldScale,lockVerticalCamera,levelRecenter,levelMenu,yawOnlyCamera,stanceHold.enabled,stanceHold.trigger,stanceHold.rate,yawSwing.a.windowMs[0],yawSwing.b.windowMs[0],yawSwing.a.limit[0],swayHold.windowMs,int(sleepFix));fclose(f); } } } From ba2f9e3f78e60423da44bcca771e3734e0413a5a Mon Sep 17 00:00:00 2001 From: Mark <97573870+BuckleBean@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:22:51 -0400 Subject: [PATCH 6/9] input: snap turn turns on the game's quickbar auto-hide Snap turn's injected mouse move brings up the game's keyboard-and-mouse quickbar. The game's own auto-hide setting (g_quickBarAutoHide, toggled with the tilde key) keeps it hidden, and players in VR can't reach tilde. With SnapTurn=1 the mod sets that byte to 1 each time gameplay starts; the setting's registration, name string and value pointer are checked against the supported build first. SnapTurnHideQuickBar=0 leaves it to the player. Co-Authored-By: Claude Opus 5 --- DeusExHRVR.ini.example | 4 ++++ README.md | 2 ++ src/EngineCamera.cpp | 21 +++++++++++++++++++++ src/EngineCamera.h | 3 +++ src/GamepadBridge.cpp | 18 ++++++++++++++++-- 5 files changed, 46 insertions(+), 2 deletions(-) diff --git a/DeusExHRVR.ini.example b/DeusExHRVR.ini.example index 87987a7..20fd7ff 100644 --- a/DeusExHRVR.ini.example +++ b/DeusExHRVR.ini.example @@ -105,6 +105,10 @@ SnapTurnMouseCounts=300 ; The game ignores mouse look while the left stick is deflected, so walking is ; released for at most this long around the injected move. SnapTurnPauseMs=60 +; Snap turn's mouse move brings up the PC quickbar. 1 turns on the game's own +; quickbar auto-hide (the tilde key) whenever gameplay starts; 0 leaves that +; setting alone. +SnapTurnHideQuickBar=1 ; RightStickUp and RightStickDown under [Buttons] hold a target while the right ; stick is pushed up or down during gameplay. With SnapTurn=0, setting either one ; takes the stick's vertical axis for them; smooth turning keeps the horizontal. diff --git a/README.md b/README.md index 12ff31b..820d2b8 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,8 @@ Inputs are `RightA`, `RightB`, `LeftX`, `LeftY`, `LeftGrip`, `RightGrip`, `LeftS The turn is one injected relative mouse move, which avoids the stick's acceleration ramp. `SnapTurnMouseCounts` is the size of that move; the mod measures the resulting heading change after each snap and writes a corrected value back to the INI, so it self-calibrates within a few turns from any starting value. The game appears to ignore mouse look while the gamepad left stick is deflected, so walking is released for up to `SnapTurnPauseMs` (default 60) around the injected move and resumed as soon as the camera has visibly turned. +The injected mouse move also brings up the game's keyboard-and-mouse quickbar (the PC item bar). The game's own quickbar auto-hide, the setting the tilde key toggles, keeps it hidden, so with `SnapTurn=1` the mod turns it on whenever gameplay starts. The setting is checked against the supported build before it is changed. `SnapTurnHideQuickBar=0` leaves it to you. + `RightStickUp` and `RightStickDown` under `[Buttons]` can hold a button while the right stick is pushed within 30 degrees of vertical during gameplay (for example `RightStickUp=A` to jump and `RightStickDown=LS` to crouch). This gives up vertical stick aiming, which you don't need when aiming with the controller (`ExperimentalMotionControls=1`). It works with snap turn or smooth turn. With `SnapTurn=0`, setting either key takes the stick's vertical axis for these buttons and leaves the horizontal axis for smooth turning; a push inside the 30-degree cone also holds the turn, so jumping or crouching doesn't drift the view. Menus, terminals and the scope keep the native right stick. ## Restore the original game diff --git a/src/EngineCamera.cpp b/src/EngineCamera.cpp index e0ff001..b8343b0 100644 --- a/src/EngineCamera.cpp +++ b/src/EngineCamera.cpp @@ -1444,6 +1444,27 @@ bool SnapTurnView(float& yaw) { yaw=std::atan2(current.originalWorld.m[9],current.originalWorld.m[8]); return std::isfinite(yaw); } +// Local patch (snap turn): the game's own quickbar auto-hide, g_quickBarAutoHide, +// the setting the tilde key toggles. The registration at 0xa68190 passes the +// value's address (0x01c79eb8) to the setting's constructor, which keeps it at +// +0x104 of the setting object (0x01c7bb60) and writes the default there; the +// game's own setter writes that one byte. All of it is checked before touching +// anything. Returns the value found (before setting it, if asked), or -1 when +// the layout doesn't match. +int QuickBarAutoHide(bool set) { + if(!base)return -1; + auto code=reinterpret_cast(VA(0xa68190)); + auto dword=[&](size_t at){uint32_t v;memcpy(&v,code+at,4);return uintptr_t(v);}; + static const unsigned char pushes[]={0x6a,0x01,0x6a,0x00,0x6a,0x00,0x68}; // push 1; push 0; push 0; push value + auto value=VA(0x01c79eb8),object=VA(0x01c7bb60),name=VA(0xaa4004); + if(memcmp(code,pushes,sizeof(pushes)) || dword(7)!=value || code[11]!=0x68 || dword(12)!=name || + code[16]!=0xb9 || dword(17)!=object || strcmp(reinterpret_cast(name),"g_quickBarAutoHide") || + *reinterpret_cast(object+0x104)!=value)return -1; + auto flag=reinterpret_cast(value); + int found=*flag; + if(set && !found)*flag=1; + return found; +} void SetShaderSwapDevice(ID3D11Device* device){ shaderSwap.SetDevice(device); diff --git a/src/EngineCamera.h b/src/EngineCamera.h index 8594b2d..afc5a0a 100644 --- a/src/EngineCamera.h +++ b/src/EngineCamera.h @@ -6,6 +6,9 @@ namespace EngineCamera { Transport::RenderInfo OnPresent(uint64_t frame, bool capture); void SetChannel(Transport::Header* header); bool SnapTurnView(float& yaw); +// Local patch: the game's quickbar auto-hide setting (tilde). Returns the value +// found, setting it to 1 first if asked, or -1 if the game's layout doesn't match. +int QuickBarAutoHide(bool set); bool ImmersiveScopeButton(); // Local patch: bit mask of active virtual-screen reasons (1 interaction, 2 menu, 4 video, 8 game over, 16 scope, // 32 in-game menu, 64 e-reader/news reader: buttons only, the display stays tracked; diff --git a/src/GamepadBridge.cpp b/src/GamepadBridge.cpp index 9f954b0..ae437c0 100644 --- a/src/GamepadBridge.cpp +++ b/src/GamepadBridge.cpp @@ -170,8 +170,10 @@ int snapDir{},snapMisses{}; uint64_t snapAt{},snapCooldown{},snapCount{},snapHoldUntil{}; int snapPauseMs=60; bool snapHoldAdaptive{};float snapHoldYaw{};uint64_t snapHoldStart{}; +bool snapHideQuickBar{},quickBarPending=true,quickBarFailed{},quickBarLogged{}; void LoadSnap() { snapEnabled=GetPrivateProfileIntW(L"VR",L"SnapTurn",0,configPath)!=0; + snapHideQuickBar=GetPrivateProfileIntW(L"VR",L"SnapTurnHideQuickBar",1,configPath)!=0; wchar_t text[32]{}; GetPrivateProfileStringW(L"VR",L"SnapTurnDegrees",L"30",text,32,configPath); float degrees=static_cast(_wtof(text)); @@ -183,7 +185,18 @@ void LoadSnap() { // deflected, so walking is released until the injected turn shows up. snapPauseMs=std::clamp(static_cast(GetPrivateProfileIntW(L"VR",L"SnapTurnPauseMs",60,configPath)),0,500); // SnapTurnPauseMs is the maximum; the pause ends as soon as the turn is seen. - InputLog("snapTurn enabled=%d degrees=%.1f mouseCounts=%.0f pauseMs=%d",snapEnabled,snapDegrees,snapCounts,snapPauseMs); + InputLog("snapTurn enabled=%d degrees=%.1f mouseCounts=%.0f pauseMs=%d hideQuickBar=%d",snapEnabled,snapDegrees,snapCounts,snapPauseMs,snapHideQuickBar); +} +// The injected mouse move brings up the game's PC quickbar. The game's own +// quickbar auto-hide (the tilde key) keeps it hidden, so snap turn turns it on each +// time gameplay starts, in case the game has reloaded its settings since. +// SnapTurnHideQuickBar=0 leaves the setting to the player. +void HideQuickBar() { + if(!snapHideQuickBar || quickBarFailed)return; + int found=EngineCamera::QuickBarAutoHide(true); + if(found<0){quickBarFailed=true;InputLog("quickBarAutoHide: setting not recognized, left alone");return;} + if(!quickBarLogged || !found)InputLog("quickBarAutoHide found=%d%s",found,found?"":", set to 1"); + quickBarLogged=true; } bool GameInForeground() { DWORD pid=0;GetWindowThreadProcessId(GetForegroundWindow(),&pid); @@ -220,7 +233,8 @@ void SnapTurn(XINPUT_GAMEPAD& pad) { InputLog("snap #%llu dir=%d: view left gameplay before measurement",snapCount,snapDir); } } - if(!view){snapArmed=false;snapHoldUntil=0;return;} // menus/terminals/scope keep the native right stick + if(!view){snapArmed=false;snapHoldUntil=0;quickBarPending=true;return;} // menus/terminals/scope keep the native right stick + if(quickBarPending){quickBarPending=false;HideQuickBar();} // During gameplay the right stick is snap-only (vertical look did nothing useful with LockVerticalCamera). double rx=pad.sThumbRX/32767.,ry=pad.sThumbRY/32767.; double magnitude=std::hypot(rx,ry); From cc2bf234bdbca7548a1a76b65f31517a9eaa3490 Mon Sep 17 00:00:00 2001 From: Mark <97573870+BuckleBean@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:22:51 -0400 Subject: [PATCH 7/9] README: give button remapping its own section Adds a "Remapping buttons" heading with a short step-by-step, moves the [Buttons]/[ScreenButtons] reference under it after the snap turn docs, lists RightStickUp/RightStickDown among the inputs, and notes that the left menu button isn't remappable. Co-Authored-By: Claude Opus 5 --- README.md | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 820d2b8..b485a4f 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,21 @@ The filter reports the time-average of the heading over the last window, extrapo `BobTrace=1` writes one line per frame to `DeusExHRVR-bob.csv` (camera position and heading, stick input, head pose; capped at 36000 lines) for measuring the walk animation on other hardware. The values above came from such a trace of walking and sprinting in the first hub. Tracing costs frame time - leave it at `0` for play. +`SnapTurn=1` under `[VR]` replaces the right stick's smooth camera turn during gameplay with a snap turn. A flick within 60 degrees of horizontal turns the view instantly by `SnapTurnDegrees` (default 30); the stick must return near center before the next flick. Menus, terminals and scoped aiming keep the native right stick. + +The turn is one injected relative mouse move, which avoids the stick's acceleration ramp. `SnapTurnMouseCounts` is the size of that move; the mod measures the resulting heading change after each snap and writes a corrected value back to the INI, so it self-calibrates within a few turns from any starting value. The game appears to ignore mouse look while the gamepad left stick is deflected, so walking is released for up to `SnapTurnPauseMs` (default 60) around the injected move and resumed as soon as the camera has visibly turned. + +The injected mouse move also brings up the game's keyboard-and-mouse quickbar (the PC item bar). The game's own quickbar auto-hide, the setting the tilde key toggles, keeps it hidden, so with `SnapTurn=1` the mod turns it on whenever gameplay starts. The setting is checked against the supported build before it is changed. `SnapTurnHideQuickBar=0` leaves it to you. + +### Remapping buttons + +To change what a motion-controller input sends: + +1. Open `DeusExHRVR.ini` beside `DXHRDC.exe` (copy `DeusExHRVR.ini.example` there first if you don't have one). +2. Add a `[Buttons]` section for gameplay, a `[ScreenButtons]` section for everything else, or both. +3. Under it, add one line per input you want to change, in the form `Input=Target`, for example `LeftGrip=Y`. +4. Save the file and restart the game. + Motion-controller buttons can be remapped in `DeusExHRVR.ini` without touching the game's own bindings. `[Buttons]` applies during gameplay and scoped aiming; `[ScreenButtons]` applies to everything else: the title and pause menus, the in-game menu (map, objectives, inventory), the e-reader and news reader, terminals, hacking, videos, game over and loading screens, where one-handed use and a different Select/Back pairing are often easier. Any input not listed keeps the stock layout above. The in-game menu and the readers stay in tracked VR (snap turn still works over a reader); only their buttons follow `[ScreenButtons]`. ```ini @@ -170,13 +185,7 @@ LeftTrigger=LT RightTrigger=RT ``` -Inputs are `RightA`, `RightB`, `LeftX`, `LeftY`, `LeftGrip`, `RightGrip`, `LeftStickClick`, `RightStickClick`, `LeftTrigger`, `RightTrigger`. Targets are `A`, `B`, `X`, `Y`, `LB`, `RB`, `LS`/`L3`, `RS`/`R3`, `LT`, `RT`, `Back`, `Start`, `DPadUp`, `DPadDown`, `DPadLeft`, `DPadRight`, or `None`. Triggers may be mapped to buttons and buttons to triggers. `HoldMs=250` makes that input send its target only after it has been held that long, so accidental taps send nothing; with a hold delay set, `Tap=A` sends a different target as a 120 ms pulse when the input is released early. Unrecognized names are logged and ignored. Restart after editing. - -`SnapTurn=1` under `[VR]` replaces the right stick's smooth camera turn during gameplay with a snap turn. A flick within 60 degrees of horizontal turns the view instantly by `SnapTurnDegrees` (default 30); the stick must return near center before the next flick. Menus, terminals and scoped aiming keep the native right stick. - -The turn is one injected relative mouse move, which avoids the stick's acceleration ramp. `SnapTurnMouseCounts` is the size of that move; the mod measures the resulting heading change after each snap and writes a corrected value back to the INI, so it self-calibrates within a few turns from any starting value. The game appears to ignore mouse look while the gamepad left stick is deflected, so walking is released for up to `SnapTurnPauseMs` (default 60) around the injected move and resumed as soon as the camera has visibly turned. - -The injected mouse move also brings up the game's keyboard-and-mouse quickbar (the PC item bar). The game's own quickbar auto-hide, the setting the tilde key toggles, keeps it hidden, so with `SnapTurn=1` the mod turns it on whenever gameplay starts. The setting is checked against the supported build before it is changed. `SnapTurnHideQuickBar=0` leaves it to you. +Inputs are `RightA`, `RightB`, `LeftX`, `LeftY`, `LeftGrip`, `RightGrip`, `LeftStickClick`, `RightStickClick`, `LeftTrigger`, `RightTrigger`, plus `RightStickUp` and `RightStickDown` in `[Buttons]` (below). Targets are `A`, `B`, `X`, `Y`, `LB`, `RB`, `LS`/`L3`, `RS`/`R3`, `LT`, `RT`, `Back`, `Start`, `DPadUp`, `DPadDown`, `DPadLeft`, `DPadRight`, or `None`. Triggers may be mapped to buttons and buttons to triggers. `HoldMs=250` makes that input send its target only after it has been held that long, so accidental taps send nothing; with a hold delay set, `Tap=A` sends a different target as a 120 ms pulse when the input is released early. Unrecognized names are logged and ignored. Restart after editing. The left menu button is not remappable: a tap sends Back (in-game menu) and a hold of 1.5 seconds sends Start (pause), as in the table above. `RightStickUp` and `RightStickDown` under `[Buttons]` can hold a button while the right stick is pushed within 30 degrees of vertical during gameplay (for example `RightStickUp=A` to jump and `RightStickDown=LS` to crouch). This gives up vertical stick aiming, which you don't need when aiming with the controller (`ExperimentalMotionControls=1`). It works with snap turn or smooth turn. With `SnapTurn=0`, setting either key takes the stick's vertical axis for these buttons and leaves the horizontal axis for smooth turning; a push inside the 30-degree cone also holds the turn, so jumping or crouching doesn't drift the view. Menus, terminals and the scope keep the native right stick. From 14872b650aaafcc8237b54972adf5c63f3b5d6e9 Mon Sep 17 00:00:00 2001 From: Mark <97573870+BuckleBean@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:36:35 -0400 Subject: [PATCH 8/9] input: QuickSave button target (F5) The game's quicksave is a fixed keyboard key (F5) with no gamepad binding, so [Buttons] couldn't reach it. New target QuickSave presses F5 by scan code while the target is held (e.g. LeftXHoldMs=500 with LeftXTap=QuickSave: tap quicksaves, hold still pauses). The key is only pressed while the game is the foreground window, and it is released when the target ends, input stops, focus is lost, or the game stops polling the controller. Each press and release is logged to DeusExHRVR-input.log. Co-Authored-By: Claude Opus 5 --- DeusExHRVR.ini.example | 3 ++- README.md | 2 +- src/GamepadBridge.cpp | 26 ++++++++++++++++++++++++-- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/DeusExHRVR.ini.example b/DeusExHRVR.ini.example index 20fd7ff..f259709 100644 --- a/DeusExHRVR.ini.example +++ b/DeusExHRVR.ini.example @@ -120,7 +120,8 @@ SnapTurnHideQuickBar=1 ; Omitted entries keep the stock layout. Inputs: RightA, RightB, LeftX, LeftY, ; LeftGrip, RightGrip, LeftStickClick, RightStickClick, LeftTrigger, ; RightTrigger. Targets: A B X Y LB RB LS RS LT RT Back Start DPadUp DPadDown -; DPadLeft DPadRight None. HoldMs=250 delays the target until the input +; DPadLeft DPadRight QuickSave None. QuickSave presses F5, the game's quicksave +; key, which has no gamepad binding. HoldMs=250 delays the target until the input ; has been held that long; with a delay set, Tap=A sends a short pulse ; when the input is released early. [Buttons] diff --git a/README.md b/README.md index b485a4f..5328705 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ LeftTrigger=LT RightTrigger=RT ``` -Inputs are `RightA`, `RightB`, `LeftX`, `LeftY`, `LeftGrip`, `RightGrip`, `LeftStickClick`, `RightStickClick`, `LeftTrigger`, `RightTrigger`, plus `RightStickUp` and `RightStickDown` in `[Buttons]` (below). Targets are `A`, `B`, `X`, `Y`, `LB`, `RB`, `LS`/`L3`, `RS`/`R3`, `LT`, `RT`, `Back`, `Start`, `DPadUp`, `DPadDown`, `DPadLeft`, `DPadRight`, or `None`. Triggers may be mapped to buttons and buttons to triggers. `HoldMs=250` makes that input send its target only after it has been held that long, so accidental taps send nothing; with a hold delay set, `Tap=A` sends a different target as a 120 ms pulse when the input is released early. Unrecognized names are logged and ignored. Restart after editing. The left menu button is not remappable: a tap sends Back (in-game menu) and a hold of 1.5 seconds sends Start (pause), as in the table above. +Inputs are `RightA`, `RightB`, `LeftX`, `LeftY`, `LeftGrip`, `RightGrip`, `LeftStickClick`, `RightStickClick`, `LeftTrigger`, `RightTrigger`, plus `RightStickUp` and `RightStickDown` in `[Buttons]` (below). Targets are `A`, `B`, `X`, `Y`, `LB`, `RB`, `LS`/`L3`, `RS`/`R3`, `LT`, `RT`, `Back`, `Start`, `DPadUp`, `DPadDown`, `DPadLeft`, `DPadRight`, `QuickSave`, or `None`. `QuickSave` presses F5, the game's quicksave key, which has no gamepad binding (for example `LeftXHoldMs=500` with `LeftXTap=QuickSave`). Triggers may be mapped to buttons and buttons to triggers. `HoldMs=250` makes that input send its target only after it has been held that long, so accidental taps send nothing; with a hold delay set, `Tap=A` sends a different target as a 120 ms pulse when the input is released early. Unrecognized names are logged and ignored. Restart after editing. The left menu button is not remappable: a tap sends Back (in-game menu) and a hold of 1.5 seconds sends Start (pause), as in the table above. `RightStickUp` and `RightStickDown` under `[Buttons]` can hold a button while the right stick is pushed within 30 degrees of vertical during gameplay (for example `RightStickUp=A` to jump and `RightStickDown=LS` to crouch). This gives up vertical stick aiming, which you don't need when aiming with the controller (`ExperimentalMotionControls=1`). It works with snap turn or smooth turn. With `SnapTurn=0`, setting either key takes the stick's vertical axis for these buttons and leaves the horizontal axis for smooth turning; a push inside the 30-degree cone also holds the turn, so jumping or crouching doesn't drift the view. Menus, terminals and the scope keep the native right stick. diff --git a/src/GamepadBridge.cpp b/src/GamepadBridge.cpp index ae437c0..cfbd210 100644 --- a/src/GamepadBridge.cpp +++ b/src/GamepadBridge.cpp @@ -32,6 +32,10 @@ void InputLog(const char* format,...) { // ---- Local patch: configurable button layout ([Buttons] in DeusExHRVR.ini) ---- // Targets: low 16 bits are XInput button bits; these flags select analog triggers. constexpr uint32_t TargetLT=1u<<16, TargetRT=1u<<17; +// QuickSave presses the game's fixed quicksave key, F5, which has no gamepad +// binding. The key is held while the target is, and only while the game is in front. +constexpr uint32_t TargetQuickSave=1u<<18; +bool quickSaveWanted{},quickSaveDown{};uint64_t quickSaveSyncAt{}; struct ButtonSource {const wchar_t* key;uint16_t bit;}; // "bit" is what the stock mapper emits for that physical control. constexpr ButtonSource buttonSources[]={ @@ -69,7 +73,7 @@ uint32_t ParseTarget(const wchar_t* key,uint32_t fallback,const wchar_t* section {L"Back",XINPUT_GAMEPAD_BACK},{L"Start",XINPUT_GAMEPAD_START}, {L"DPadUp",XINPUT_GAMEPAD_DPAD_UP},{L"DPadDown",XINPUT_GAMEPAD_DPAD_DOWN}, {L"DPadLeft",XINPUT_GAMEPAD_DPAD_LEFT},{L"DPadRight",XINPUT_GAMEPAD_DPAD_RIGHT}, - {L"None",0}, + {L"QuickSave",TargetQuickSave},{L"None",0}, }; for(const auto& n:names)if(!_wcsicmp(value,n.name))return n.target; InputLog("[%ls] %ls=%ls not recognized; keeping default",section,key,value); @@ -107,6 +111,7 @@ void LoadButtons() { } void Emit(XINPUT_GAMEPAD& out,uint32_t target,bool pressed,BYTE analog) { if(pressed && (target&0xffff))out.wButtons|=static_cast(target&0xffff); + if(pressed && (target&TargetQuickSave))quickSaveWanted=true; if(target&TargetLT)out.bLeftTrigger=std::max(out.bLeftTrigger,analog); if(target&TargetRT)out.bRightTrigger=std::max(out.bRightTrigger,analog); } @@ -202,6 +207,19 @@ bool GameInForeground() { DWORD pid=0;GetWindowThreadProcessId(GetForegroundWindow(),&pid); return pid==GetCurrentProcessId(); } +// Presses or releases F5 to match the QuickSave target (by scan code, which the +// game's keyboard input and window messages both see). +void SyncQuickSave() { + quickSaveSyncAt=GetTickCount64(); + bool want=quickSaveWanted && GameInForeground(); + quickSaveWanted=false; + if(want==quickSaveDown)return; + INPUT input{};input.type=INPUT_KEYBOARD;input.ki.wScan=0x3f; // F5 + input.ki.dwFlags=KEYEVENTF_SCANCODE|(want?0:KEYEVENTF_KEYUP); + bool sent=SendInput(1,&input,sizeof(input))==1; + if(sent || !want)quickSaveDown=want; + InputLog("quickSave F5 %s%s",want?"down":"up",sent?"":" (SendInput failed)"); +} void SnapTurn(XINPUT_GAMEPAD& pad) { auto now=GetTickCount64(); float yaw=0; @@ -330,7 +348,9 @@ DWORD WINAPI GetHook(DWORD index,XINPUT_STATE* state) { DWORD result=originalGet(index,state); if(index || !state)return result; std::lock_guard lock(guard);XINPUT_GAMEPAD pad{}; - if(!Read(pad))return result; + bool active=Read(pad); + SyncQuickSave(); // also releases F5 when input stops or the game loses focus + if(!active)return result; ++polls; if(result==ERROR_SUCCESS) { const auto& real=state->Gamepad; @@ -382,6 +402,8 @@ void SetChannel(Transport::Header* header) { } void OnPresent(bool capture) { std::lock_guard lock(guard);Install(); + // If the game stops polling the controller while F5 is held, release it here. + if(quickSaveDown && GetTickCount64()-quickSaveSyncAt>250){quickSaveWanted=false;SyncQuickSave();} if(capture) { InputLog("enabled=%d installed=%d connected=%d polls=%llu changes=%llu packet=%lu lastButtons=%04x sticks=%d,%d/%d,%d triggers=%u,%u", enabled,installed,connected,polls,changes,packet,previous.wButtons,previous.sThumbLX,previous.sThumbLY, From ccb6e66d57da9337a9199a591e0230049c267a6e Mon Sep 17 00:00:00 2001 From: Mark <97573870+BuckleBean@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:43:06 -0400 Subject: [PATCH 9/9] docs: name LeftStickClickDelayMs in the README, rewrap an INI comment Co-Authored-By: Claude Opus 5 --- DeusExHRVR.ini.example | 4 ++-- README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/DeusExHRVR.ini.example b/DeusExHRVR.ini.example index f259709..1b862b7 100644 --- a/DeusExHRVR.ini.example +++ b/DeusExHRVR.ini.example @@ -121,8 +121,8 @@ SnapTurnHideQuickBar=1 ; LeftGrip, RightGrip, LeftStickClick, RightStickClick, LeftTrigger, ; RightTrigger. Targets: A B X Y LB RB LS RS LT RT Back Start DPadUp DPadDown ; DPadLeft DPadRight QuickSave None. QuickSave presses F5, the game's quicksave -; key, which has no gamepad binding. HoldMs=250 delays the target until the input -; has been held that long; with a delay set, Tap=A sends a short pulse +; key, which has no gamepad binding. HoldMs=250 delays the target until +; the input has been held that long; with a delay set, Tap=A sends a short pulse ; when the input is released early. [Buttons] diff --git a/README.md b/README.md index 5328705..262f895 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Set `MotionControls=0` under `[VR]` to disable all motion-controller features, i To adjust a weapon's grip, use full VR with motion-controlled weapons enabled and keep the weapon out of scope mode. Press both thumbsticks and hold for one second, until the gun stops following your hand (a system tone also signals this). Keep holding and move/rotate your right controller into the grip position you want. Release either thumbstick to save, then release the other. Each weapon type keeps its own position and rotation offset in `DeusExHRVR-weapons.ini` beside the game executable, including across restarts. The gun, firing line, muzzle effects and immersive scope share that offset. Repeat to readjust; delete that file while the game is closed to reset all weapons. -The calibration gesture consumes controller movement, turning, firing and button actions until both sticks are released. Left-stick crouch has a 150 ms grace period to allow the two clicks to arrive together; a quick tap still crouches. Tracking loss, menus, recentering or weapon changes cancel an unfinished calibration. Install the matching game DLL and host together: this build uses transport version 5. +The calibration gesture consumes controller movement, turning, firing and button actions until both sticks are released. Left-stick crouch has a 150 ms grace period to allow the two clicks to arrive together; a quick tap still crouches. `LeftStickClickDelayMs` under `[VR]` sets that period; `0` sends the left click at once, for layouts that map it to something other than crouch. Tracking loss, menus, recentering or weapon changes cancel an unfinished calibration. Install the matching game DLL and host together: this build uses transport version 5. `ImmersiveScope=1` optionally keeps native scoped weapons in full VR. With motion-controlled weapons enabled, raise the right controller near your eye and point forward to enter the scope; lower it to exit. The magnified view follows controller aim, using the rifle's firing line as a shared optical viewpoint for both eyes. `ScopeMagnification=4` controls zoom (1-12). This alignment has been tested in-headset with the sniper rifle. The default remains the automatic virtual screen (`ImmersiveScope=0`). Restart after changing these settings.