Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ jobs:
echo "Manifests OK"
- name: Config keys
run: |
for key in DEADZONE GAMMA STEER_SMOOTH SPEED_SCALE_START SPEED_SCALE_END SPEED_SCALE_MIN COUNTERSTEER_GAIN COUNTERSTEER_DAMP SLIP_LIMIT_START SLIP_LIMIT_RANGE SLIP_LIMIT_MIN GAS_DEADZONE GAS_GAMMA BRAKE_DEADZONE BRAKE_GAMMA HAPTICS_ENABLED HAPTICS_SLIP_START HAPTICS_SLIP_MAX HAPTICS_STRENGTH DEBUG_MODE; do
for key in DEADZONE GAMMA STEER_SMOOTH STEER_SMOOTH_MIN SPEED_SCALE_START SPEED_SCALE_END SPEED_SCALE_MIN YAW_GAIN YAW_DAMP SLIP_DELTA_THRESH US_REDUCTION OS_BOOST SLIP_LIMIT_START SLIP_LIMIT_RANGE SLIP_LIMIT_MIN GAS_DEADZONE GAS_GAMMA BRAKE_DEADZONE BRAKE_GAMMA TC_ENABLED HAPTICS_ENABLED HAPTICS_SLIP_START HAPTICS_SLIP_MAX HAPTICS_STRENGTH DEBUG_MODE; do
grep -q "$key" MyGamepadFX/config.lua || { echo "FAIL: config.lua missing $key"; exit 1; }
done
echo "All 20 config keys present"
echo "All 25 config keys present"

comment:
name: PR Comment
Expand Down
91 changes: 47 additions & 44 deletions MyGamepadFX/assist.lua
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
-- assist.lua — pipeline assembly and CSP entry points
-- Pipeline (execution order):
-- raw input → calibrate → deadzone/gamma → speed scale → slip limit (driver only) → + self-steer → smooth → clamp → output
-- raw input → calibrate → deadzone/gamma → speed scale
-- → slip classify → understeer clamp → slip limit
-- + yaw damp (oversteer boost, load transfer)
-- → adaptive smooth → clamp → output

local CFG = require('config')
local lib = require('lib')
Expand All @@ -10,10 +13,8 @@ local steerOut = 0.0
local frameCount = 0
local firstFrameDiagnostics = false

-- Live config: the MyGamepadFX Config app writes this file; we poll it every 0.5s.
-- When absent (no app open yet), CFG defaults from config.lua are used unchanged.
local LIVE_CFG_PATH = ac.getFolder(ac.FolderID.ScriptOrigin) .. '/live_cfg.ini'
local liveCfgTimer = 0.5 -- start first check after 0.5s to avoid startup noise
local liveCfgTimer = 0.5

local function applyLiveCfg()
local ok, ini = pcall(ac.INIConfig.load, LIVE_CFG_PATH)
Expand All @@ -27,18 +28,25 @@ local function applyLiveCfg()
CFG.DEADZONE = ini:get('PARAMS', 'DEADZONE', CFG.DEADZONE)
CFG.GAMMA = ini:get('PARAMS', 'GAMMA', CFG.GAMMA)
CFG.STEER_SMOOTH = ini:get('PARAMS', 'STEER_SMOOTH', CFG.STEER_SMOOTH)
CFG.STEER_SMOOTH_MIN = ini:get('PARAMS', 'STEER_SMOOTH_MIN', CFG.STEER_SMOOTH_MIN)
CFG.SPEED_SCALE_START = ini:get('PARAMS', 'SPEED_SCALE_START', CFG.SPEED_SCALE_START)
CFG.SPEED_SCALE_END = ini:get('PARAMS', 'SPEED_SCALE_END', CFG.SPEED_SCALE_END)
CFG.SPEED_SCALE_MIN = ini:get('PARAMS', 'SPEED_SCALE_MIN', CFG.SPEED_SCALE_MIN)
CFG.COUNTERSTEER_GAIN = ini:get('PARAMS', 'COUNTERSTEER_GAIN', CFG.COUNTERSTEER_GAIN)
CFG.COUNTERSTEER_DAMP = ini:get('PARAMS', 'COUNTERSTEER_DAMP', CFG.COUNTERSTEER_DAMP)
CFG.YAW_GAIN = ini:get('PARAMS', 'YAW_GAIN', CFG.YAW_GAIN)
CFG.YAW_DAMP = ini:get('PARAMS', 'YAW_DAMP', CFG.YAW_DAMP)
CFG.SLIP_DELTA_THRESH = ini:get('PARAMS', 'SLIP_DELTA_THRESH', CFG.SLIP_DELTA_THRESH)
CFG.US_REDUCTION = ini:get('PARAMS', 'US_REDUCTION', CFG.US_REDUCTION)
CFG.OS_BOOST = ini:get('PARAMS', 'OS_BOOST', CFG.OS_BOOST)
CFG.SLIP_LIMIT_START = ini:get('PARAMS', 'SLIP_LIMIT_START', CFG.SLIP_LIMIT_START)
CFG.SLIP_LIMIT_RANGE = ini:get('PARAMS', 'SLIP_LIMIT_RANGE', CFG.SLIP_LIMIT_RANGE)
CFG.SLIP_LIMIT_MIN = ini:get('PARAMS', 'SLIP_LIMIT_MIN', CFG.SLIP_LIMIT_MIN)
CFG.GAS_DEADZONE = ini:get('PARAMS', 'GAS_DEADZONE', CFG.GAS_DEADZONE)
CFG.GAS_GAMMA = ini:get('PARAMS', 'GAS_GAMMA', CFG.GAS_GAMMA)
CFG.BRAKE_DEADZONE = ini:get('PARAMS', 'BRAKE_DEADZONE', CFG.BRAKE_DEADZONE)
CFG.BRAKE_GAMMA = ini:get('PARAMS', 'BRAKE_GAMMA', CFG.BRAKE_GAMMA)
CFG.TC_ENABLED = ini:get('PARAMS', 'TC_ENABLED', CFG.TC_ENABLED)
CFG.TC_SLIP_THRESHOLD = ini:get('PARAMS', 'TC_SLIP_THRESHOLD', CFG.TC_SLIP_THRESHOLD)
CFG.TC_MAX_REDUCTION = ini:get('PARAMS', 'TC_MAX_REDUCTION', CFG.TC_MAX_REDUCTION)
Comment on lines +31 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clamp live INI numeric values before assigning to CFG.

applyLiveCfg() trusts live_cfg.ini values. Invalid values (for example SLIP_DELTA_THRESH <= 0 or SLIP_LIMIT_RANGE <= 0) flow into divisions at Line 109 in this file and MyGamepadFX/lib.lua Line 84, producing unstable control math (Inf/NaN paths).

Proposed fix
 local function applyLiveCfg()
     local ok, ini = pcall(ac.INIConfig.load, LIVE_CFG_PATH)
     if not ok or not ini then return end
+    local function getNum(key, fallback, minV, maxV)
+        local v = tonumber(ini:get('PARAMS', key, fallback)) or fallback
+        if minV and maxV then return math.clamp(v, minV, maxV) end
+        if minV then return math.max(v, minV) end
+        if maxV then return math.min(v, maxV) end
+        return v
+    end
@@
-    CFG.SLIP_DELTA_THRESH  = ini:get('PARAMS', 'SLIP_DELTA_THRESH',  CFG.SLIP_DELTA_THRESH)
+    CFG.SLIP_DELTA_THRESH  = getNum('SLIP_DELTA_THRESH', CFG.SLIP_DELTA_THRESH, 0.001, 1.0)
@@
-    CFG.SLIP_LIMIT_RANGE   = ini:get('PARAMS', 'SLIP_LIMIT_RANGE',   CFG.SLIP_LIMIT_RANGE)
+    CFG.SLIP_LIMIT_RANGE   = getNum('SLIP_LIMIT_RANGE', CFG.SLIP_LIMIT_RANGE, 0.001, 1.0)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@MyGamepadFX/assist.lua` around lines 31 - 49, In the applyLiveCfg() function,
the numeric values retrieved from ini:get() for parameters like
SLIP_DELTA_THRESH, SLIP_LIMIT_RANGE, and other divisor-dependent values are
assigned directly to CFG without validation, which can cause Inf/NaN results
when these values are used in division operations at line 109 and in
MyGamepadFX/lib.lua line 84. Add clamping logic after each ini:get() call to
ensure critical numeric parameters that are used as divisors (such as
SLIP_DELTA_THRESH and SLIP_LIMIT_RANGE) have minimum values greater than 0, and
ensure other numeric parameters are within acceptable ranges before assigning
them to their corresponding CFG properties.

CFG.HAPTICS_ENABLED = ini:get('PARAMS', 'HAPTICS_ENABLED', CFG.HAPTICS_ENABLED)
CFG.HAPTICS_SLIP_START = ini:get('PARAMS', 'HAPTICS_SLIP_START', CFG.HAPTICS_SLIP_START)
CFG.HAPTICS_SLIP_MAX = ini:get('PARAMS', 'HAPTICS_SLIP_MAX', CFG.HAPTICS_SLIP_MAX)
Expand All @@ -49,7 +57,6 @@ end
function script.update(dt)
frameCount = frameCount + 1

-- Poll live_cfg.ini written by the MyGamepadFX Config app (if open)
liveCfgTimer = liveCfgTimer - dt
if liveCfgTimer <= 0 then
liveCfgTimer = 0.5
Expand All @@ -59,15 +66,15 @@ function script.update(dt)
local car = ac.getCar(0)
if not car then
if frameCount > 2 then
lib.logOnce("car_nil", "[MyGamepadFX] ERROR: ac.getCar(0) returned nil after startup. Check CM Settings Custom Shaders Patch Gamepad FX confirm Active and MyGamepadFX selected.")
lib.logOnce("car_nil", "[MyGamepadFX] ERROR: ac.getCar(0) returned nil after startup. Check CM \xE2\x86\x92 Settings \xE2\x86\x92 Custom Shaders Patch \xE2\x86\x92 Gamepad FX \xE2\x86\x92 confirm Active and MyGamepadFX selected.")
end
steerOut = 0.0
return
end

local gamepad = ac.getGamepad(0)
if not gamepad then
lib.logOnce("gamepad_nil", "[MyGamepadFX] ERROR: No gamepad detected. Check CM Settings Assetto Corsa Controls Input Method = Gamepad.")
lib.logOnce("gamepad_nil", "[MyGamepadFX] ERROR: No gamepad detected. Check CM \xE2\x86\x92 Settings \xE2\x86\x92 Assetto Corsa \xE2\x86\x92 Controls \xE2\x86\x92 Input Method = Gamepad.")
steerOut = 0.0
return
end
Expand All @@ -78,68 +85,64 @@ function script.update(dt)
lib.logOnce("frame_drop", "[MyGamepadFX] Warning: frame time " .. string.format("%.0f", dt * 1000) .. "ms exceeds 50ms threshold. Smoothing may be affected.")
end

-- First-frame axis diagnostics (once per game start, not per session reset)
if not firstFrameDiagnostics then
ac.log("[MyGamepadFX] === Startup Diagnostic ===")
ac.log("[MyGamepadFX] Axes: [0]=" .. string.format("%.2f", gamepad.axes[0] or 0)
.. " [1]=" .. string.format("%.2f", gamepad.axes[1] or 0)
.. " [3]=" .. string.format("%.2f", gamepad.axes[3] or 0)
.. " [4]=" .. string.format("%.2f", gamepad.axes[4] or 0))
ac.log("[MyGamepadFX] Expected: axes[1]=steer, axes[3]=throttle, axes[4]=brake")
ac.log("[MyGamepadFX] yawRate available: " .. tostring(car.localAngularVelocity ~= nil))
firstFrameDiagnostics = true
end

-- 1. Raw input calibrate deadzone gamma speed scale
local raw = gamepad.axes[1] or 0.0 -- left stick X (steering)
-- 1. Raw input -> calibrate -> deadzone -> gamma -> speed scale
local raw = gamepad.axes[1] or 0.0
local calibrated = lib.normalizeAxis(raw, CFG.STEER_CENTER, CFG.STEER_RANGE)
local steer = lib.applyGamma(lib.applyDeadzone(calibrated, CFG.DEADZONE), CFG.GAMMA)
steer = steer * lib.speedScale(car.speedKmh, CFG)

-- 2. Slip limit — clamps driver input only; self-steer is added after
local avgFrontSlip = (car.wheelsSlip[0] + car.wheelsSlip[1]) * 0.5
local slipFactor = math.clamp(
(avgFrontSlip - CFG.SLIP_LIMIT_START) / CFG.SLIP_LIMIT_RANGE,
0.0, 1.0
)
-- 2. Slip classify: front vs rear
local slip = lib.stageSlipClassify(car, CFG)

-- 3. Slip limit — clamps driver input; tightened by understeer factor
local slipFactor = math.clamp((slip.front - CFG.SLIP_LIMIT_START) / CFG.SLIP_LIMIT_RANGE, 0.0, 1.0)
local steerLimit = lib.lerp(1.0, CFG.SLIP_LIMIT_MIN, slipFactor)
steerLimit = steerLimit * lib.lerp(1.0, 1.0 - CFG.US_REDUCTION, slip.understeer)
local driverInput = math.clamp(steer, -steerLimit, steerLimit)

-- 3. Self-steer: caster return-to-center + damping (not subject to slip limit)
local selfSteer = -avgFrontSlip * CFG.COUNTERSTEER_GAIN
- car.steer * CFG.COUNTERSTEER_DAMP
local combined = driverInput + selfSteer

-- 4. Smooth combined signal (driver + self-steer together — avoids correction lag)
steerOut = lib.expSmooth(steerOut, combined, CFG.STEER_SMOOTH, dt)

-- if dbg and CFG.DEBUG_MODE then dbg.draw({
-- raw = raw,
-- afterScale = steer,
-- steerLimit = steerLimit,
-- driverInput = driverInput,
-- selfSteer = selfSteer,
-- combined = combined,
-- steerOut = steerOut,
-- avgFrontSlip = avgFrontSlip,
-- speedKmh = car.speedKmh,
-- carSteer = car.steer,
-- dt = dt,
-- }) end

-- 4.5. Haptic feedback (trigger rumble on front slip)
-- 4. Yaw damping + oversteer boost (replaces self-steer)
local yaw = lib.yawRate(car)
local heavyBraking = (car.brake or 0) > 0.4 and car.speedKmh > 30
local effectiveDamp = heavyBraking and CFG.YAW_DAMP * 0.6 or CFG.YAW_DAMP
local yawCorrection = lib.stageYawDamp(yaw, car.steer, CFG, slip.oversteer, effectiveDamp)
local combined = driverInput + yawCorrection

-- 5. Adaptive smooth: faster at the limit, smooth when settled
local avgSlip = (slip.front + slip.rear) * 0.5
local stability = 1.0 - math.clamp((math.abs(yaw) * 0.5 + avgSlip) / 0.4, 0.0, 1.0)
steerOut = lib.stageAdaptiveSmooth(steerOut, combined, stability, CFG, dt)

-- 5.5. Haptic feedback
if CFG.HAPTICS_ENABLED then
local slipIntensity = math.clamp(
(avgFrontSlip - CFG.HAPTICS_SLIP_START) / (CFG.HAPTICS_SLIP_MAX - CFG.HAPTICS_SLIP_START),
(avgSlip - CFG.HAPTICS_SLIP_START) / (CFG.HAPTICS_SLIP_MAX - CFG.HAPTICS_SLIP_START),
0.0, 1.0
)
ac.setTriggerRumble(0, slipIntensity * CFG.HAPTICS_STRENGTH)
end

-- 5. Write to physics
-- 6. Write to physics
local rawGas = lib.normalizeTrigger(gamepad.axes[3] or 0.0, CFG.GAS_REST, CFG.GAS_MAX)
local rawBrake = lib.normalizeTrigger(gamepad.axes[4] or 0.0, CFG.BRAKE_REST, CFG.BRAKE_MAX)
local gasOut = math.clamp(lib.applyGamma(lib.applyDeadzone(rawGas, CFG.GAS_DEADZONE), CFG.GAS_GAMMA), 0.0, 1.0)
if CFG.TC_ENABLED then
local spin = lib.stageWheelspin(car)
local reduction = math.clamp((spin - CFG.TC_SLIP_THRESHOLD) / 0.2, 0.0, 1.0)
gasOut = gasOut * (1.0 - reduction * CFG.TC_MAX_REDUCTION)
end
ac.setSteer(math.clamp(steerOut, -1.0, 1.0))
ac.setGas( math.clamp(lib.applyGamma(lib.applyDeadzone(rawGas, CFG.GAS_DEADZONE), CFG.GAS_GAMMA), 0.0, 1.0))
ac.setGas(gasOut)
ac.setBrake(math.clamp(lib.applyGamma(lib.applyDeadzone(rawBrake, CFG.BRAKE_DEADZONE), CFG.BRAKE_GAMMA), 0.0, 1.0))
end

Expand Down
31 changes: 21 additions & 10 deletions MyGamepadFX/config.lua
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
-- config.lua — all tunable constants; no logic lives here
local CFG = {
-- Axis calibration (Hall Effect controllers)
-- Defaults produce no change from v1.0 behaviour.
-- Defaults produce no change from pre-calibration behaviour.
STEER_CENTER = 0.0, -- raw stick value at rest; subtracted before deadzone
STEER_RANGE = 1.0, -- max raw deflection from center (scale to ±1.0)
GAS_REST = 0.0, -- raw trigger value when fully released
Expand All @@ -14,16 +14,22 @@ local CFG = {
GAMMA = 1.6, -- steering curve exponent (>1 = more center precision)

-- Smoothing
STEER_SMOOTH = 0.12, -- blend weight at 60 fps (frame-rate independent)
STEER_SMOOTH = 0.12, -- stable-state blend weight at 60 fps
STEER_SMOOTH_MIN = 0.04, -- minimum blend weight at high instability (v2.0)

-- Speed scaling
SPEED_SCALE_START = 60, -- km/h where speed scaling begins
SPEED_SCALE_END = 180, -- km/h where scaling reaches minimum
SPEED_SCALE_MIN = 0.35, -- minimum steering multiplier at top speed

-- Self-steer
COUNTERSTEER_GAIN = 0.45, -- correction strength
COUNTERSTEER_DAMP = 0.30, -- oscillation damping; must stay >= (COUNTERSTEER_GAIN * 0.6)
-- Yaw damping (v2.0 — replaces COUNTERSTEER_GAIN/DAMP)
YAW_GAIN = 0.5, -- countersteer force per rad/s of yaw
YAW_DAMP = 0.3, -- steer angle damping when yawing

-- Slip discrimination (v2.0)
SLIP_DELTA_THRESH = 0.08, -- front-rear slip difference that fully classifies under/oversteer
US_REDUCTION = 0.25, -- max authority reduction when understeering (0 = disabled)
OS_BOOST = 0.3, -- max countersteer boost when oversteering (0 = disabled)

-- Slip limit
SLIP_LIMIT_START = 0.15, -- front slip level where reduction begins
Expand All @@ -36,14 +42,19 @@ local CFG = {
BRAKE_DEADZONE = 0.01, -- avoids light brake from trigger drift
BRAKE_GAMMA = 1.0, -- linear brake (no curve)

-- Traction control (v2.0, off by default)
TC_ENABLED = false, -- enable to reduce wheelspin automatically
TC_SLIP_THRESHOLD = 0.15, -- rear wheelspin ratio where TC activates
TC_MAX_REDUCTION = 0.5, -- maximum throttle reduction fraction

-- Haptic feedback (trigger rumble, CSP v0.2.0+)
HAPTICS_ENABLED = false, -- disabled by default; enable in config if controller supports it
HAPTICS_SLIP_START = 0.3, -- front slip level where rumble begins
HAPTICS_SLIP_MAX = 1.0, -- slip level for maximum rumble
HAPTICS_STRENGTH = 0.8, -- overall rumble intensity [0, 1]
HAPTICS_ENABLED = false,
HAPTICS_SLIP_START = 0.3,
HAPTICS_SLIP_MAX = 1.0,
HAPTICS_STRENGTH = 0.8,

-- Debug / tuning mode
DEBUG_MODE = false, -- set true to enable live telemetry overlay
DEBUG_MODE = false,
}

return CFG
47 changes: 46 additions & 1 deletion MyGamepadFX/lib.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
-- lib.lua — math primitives; no constants, no ac.* API calls
-- lib.lua — math primitives and pipeline stage functions; no constants, no ac.* API calls
-- Note: math.clamp is a CSP extension, not available in vanilla LuaJIT.
local M = {}

Expand Down Expand Up @@ -64,4 +64,49 @@ function M.speedScale(speedKmh, cfg)
return M.lerp(1.0, cfg.SPEED_SCALE_MIN, t)
end

-- Yaw rate from car object. Returns 0.0 if unavailable (pre-v2.0 CSP).
-- car.localAngularVelocity is body-frame rad/s; .y = yaw (positive = rotating left).
function M.yawRate(car)
if not car.localAngularVelocity then return 0.0 end
return car.localAngularVelocity.y
end

-- Classify front vs rear slip to determine oversteer/understeer state.
-- Returns { front, rear, understeer, oversteer } all in [0, 1].
function M.stageSlipClassify(car, cfg)
local front = (car.wheelsSlip[0] + car.wheelsSlip[1]) * 0.5
local rear = (car.wheelsSlip[2] + car.wheelsSlip[3]) * 0.5
local delta = front - rear -- positive = understeer, negative = oversteer
local thresh = cfg.SLIP_DELTA_THRESH
return {
front = front,
rear = rear,
understeer = math.clamp( delta / thresh, 0.0, 1.0),
oversteer = math.clamp(-delta / thresh, 0.0, 1.0),
}
end

-- Yaw damping: opposes car rotation and steer angle oscillation.
-- overFactor scales yaw correction up when oversteering; effectiveDamp handles load transfer.
function M.stageYawDamp(yaw, steerAngle, cfg, overFactor, effectiveDamp)
local yawPart = -yaw * cfg.YAW_GAIN * M.lerp(1.0, 1.0 + cfg.OS_BOOST, overFactor)
local dampPart = -steerAngle * effectiveDamp
return yawPart + dampPart
end

-- Adaptive smooth: faster at the limit, stable when settled.
-- stability in [0, 1] where 1 = fully stable.
function M.stageAdaptiveSmooth(current, target, stability, cfg, dt)
local smooth = M.lerp(cfg.STEER_SMOOTH_MIN, cfg.STEER_SMOOTH, stability)
return M.expSmooth(current, target, smooth, dt)
end

-- Rear wheelspin ratio. Returns value in [0, 1] where 0 = no spin, 1 = full spin.
function M.stageWheelspin(car)
local expected = car.speedKmh / 3.6 / 0.3 -- ~0.3m typical tyre radius
if expected < 1.0 then return 0.0 end
local rearAvg = (car.wheelAngularSpeed[2] + car.wheelAngularSpeed[3]) * 0.5
return math.clamp(rearAvg / expected - 1.0, 0.0, 1.0)
end

return M
2 changes: 1 addition & 1 deletion MyGamepadFX/manifest.ini
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[ABOUT]
NAME = MyGamepadFX
VERSION = 1.0
VERSION = 2.0
Loading
Loading