diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..206a20d --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,112 @@ +name: Quality + +on: + pull_request: + branches: [master] + push: + branches: [master] + +permissions: + pull-requests: write + +jobs: + syntax: + name: Lua Syntax + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Lua 5.2 + run: | + sudo apt-get update -q + sudo apt-get install -y lua5.2 + - name: Check syntax + run: | + find . -name "*.lua" -not -path "./.github/*" | while read f; do + echo "Checking $f" + luac5.2 -p "$f" || exit 1 + done + echo "All Lua files pass syntax check" + + lint: + name: Luacheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install luacheck + run: | + sudo apt-get update -q + sudo apt-get install -y luarocks + sudo luarocks install luacheck + - name: Run luacheck + run: luacheck . + + validate: + name: Structure + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Required files present + run: | + for f in MyGamepadFX/manifest.ini MyGamepadFX/config.lua MyGamepadFX/lib.lua MyGamepadFX/assist.lua MyGamepadFX/debug.lua MyGamepadFX_Config/manifest.ini MyGamepadFX_Config/app.lua; do + test -f "$f" || { echo "FAIL: missing $f"; exit 1; } + done + echo "All required files present" + - name: Manifest keys + run: | + grep -q "^NAME" MyGamepadFX/manifest.ini || { echo "FAIL: MyGamepadFX/manifest.ini missing NAME"; exit 1; } + grep -q "^VERSION" MyGamepadFX/manifest.ini || { echo "FAIL: MyGamepadFX/manifest.ini missing VERSION"; exit 1; } + grep -q "^NAME" MyGamepadFX_Config/manifest.ini || { echo "FAIL: MyGamepadFX_Config/manifest.ini missing NAME"; exit 1; } + grep -q "^FUNCTION_MAIN" MyGamepadFX_Config/manifest.ini || { echo "FAIL: MyGamepadFX_Config/manifest.ini missing FUNCTION_MAIN"; exit 1; } + 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 + grep -q "$key" MyGamepadFX/config.lua || { echo "FAIL: config.lua missing $key"; exit 1; } + done + echo "All 20 config keys present" + + comment: + name: PR Comment + needs: [syntax, lint, validate] + if: always() && github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + script: | + const results = { + 'Lua Syntax': '${{ needs.syntax.result }}', + 'Luacheck': '${{ needs.lint.result }}', + 'Structure': '${{ needs.validate.result }}', + } + const icon = r => ({ success: '✅', failure: '❌', skipped: '⏭️' }[r] || '⏳') + const allGood = Object.values(results).every(r => r === 'success') + const rows = Object.entries(results).map(([k, v]) => `| ${k} | ${icon(v)} ${v} |`).join('\n') + const body = [ + `## Quality Check ${allGood ? '✅ Passed' : '❌ Issues found'}`, + '', + '| Check | Result |', + '|---|---|', + rows, + ].join('\n') + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }) + const existing = comments.find(c => c.body.startsWith('## Quality Check')) + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }) + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }) + } diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..989f81d --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,17 @@ +-- CSP (Custom Shaders Patch) LuaJIT 5.2 environment +std = "lua52" +max_line_length = false + +globals = { + "ac", -- CSP API namespace + "ui", -- CSP UI rendering (app scripts only) + "script", -- CSP entry point table (script.update, script.reset) + "windowMain", -- CSP app window entry point (called by runtime, not defined by us) + "vec2", "vec3", "vec4", "rgbm", -- CSP math/color types +} + +ignore = { + "212", -- unused argument: dt is required by CSP entry point signatures + "141", -- setting undefined field: script.update/reset are CSP-defined entry points + "143", -- accessing undefined field: math.clamp is a CSP extension to math +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..07f0496 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Hard Rule — Skill Execution Policy + +Skills are lazy-loaded. Nothing runs, reads, or costs tokens unless its trigger condition is explicitly matched by the current task. + +- Never pre-load a skill speculatively +- Never keep a skill active between tasks +- Read a SKILL.md once per invocation, then release it +- If no trigger matches, no skill loads +- Routing is stateless and costs nothing — only the matched skill's read costs tokens + +This applies to every skill, every subagent, every session. No exceptions. + +## Hard Rule — Response Behavior + +No AI patterns. Ever. + +No formulaic transitions. No symmetrical structure. No generic phrasing. No predictable rhythm. Vary sentence length naturally. + +If multiple choice → output only the correct letter. +If short answer → output only the direct answer. +No explanation unless explicitly asked. +No conclusions. No summaries. No filler. + +--- + +## What This Is + +A [Custom Shaders Patch](https://acstuff.ru/patch/) Gamepad FX Lua script for Assetto Corsa. Runs in **LuaJIT** (5.2 compatibility mode) via CSP — no standard Lua toolchain, no package manager, no build step. + +## Deployment & Testing + +There is no build system. "Deploying" means copying the folder: + +``` +MyGamepadFX/ → assettocorsa/extension/lua/joypad-assist/MyGamepadFX/ +``` + +Testing requires launching Assetto Corsa with the script active. Activate in **Content Manager → Settings → Custom Shaders Patch → Gamepad FX → MyGamepadFX**. + +AC Controls must have Speed Sensitivity, Steering Gamma, Steering Filter, and Steering Deadzone all at 0%, Steering Speed at 100% — the script owns all of these. + +## Architecture + +Four files, strict separation of concerns: + +| File | Role | +|---|---| +| `manifest.ini` | CSP plugin declaration (name, version) | +| `config.lua` | All tunable constants, nothing else — returns a `CFG` table | +| `lib.lua` | Pure math helpers (`applyDeadzone`, `applyGamma`, `lerp`, `expSmooth`, `speedScale`) — no `ac.*` calls, no constants | +| `assist.lua` | Pipeline assembly and the two CSP entry points | +| `debug.lua` | Optional live telemetry overlay via `ac.debug()` — disabled by default | + +**Pipeline in `assist.lua` (execution order):** + +``` +raw input → applyDeadzone → applyGamma → speedScale + → slip limit clamp (driver input only) + → + selfSteer (not subject to slip clamp) + → expSmooth (combined signal) + → clamp → ac.setSteer / ac.setGas / ac.setBrake +``` + +## CSP Entry Points + +```lua +function script.update(dt) -- called every physics frame; dt = seconds +function script.reset() -- called on session reset; must clear all persistent state +``` + +`ac.getCar(0)` returns nil on the first frame — always nil-guard before reading car properties. `dt <= 0` guard also required. + +## Debug Overlay + +Uncomment in `assist.lua` to enable: + +```lua +local dbg = require('debug') +``` + +And uncomment the `dbg.draw({...})` call block. Shows all pipeline intermediate values via `ac.debug()`. Never ship with this enabled. + +## Key CSP API + +```lua +ac.getGamepad(0) -- gamepad.axes[1] = left stick X (steering) +ac.getCar(0) -- car.speedKmh, car.wheelsSlip[0..3], car.steer +ac.setSteer(v) -- [-1, 1] +ac.setGas(v) -- [0, 1] +ac.setBrake(v) -- [0, 1] +math.clamp(v, min, max) -- CSP extension, not vanilla LuaJIT +``` + +## Tuning + +Edit `config.lua` only — `assist.lua` contains no raw constants. Tuning order: deadzone → gamma → speed scale → countersteer gain → damp → slip limit → smoothing last. + +`COUNTERSTEER_DAMP` must stay ≥ `COUNTERSTEER_GAIN × 0.6` or the car develops tank-slappers. + +Frame-rate independence: smoothing uses `expSmooth` with `(1 - smooth)^(dt * 60)` — never use a bare lerp constant directly against `dt`. diff --git a/INSTALLATION.md b/INSTALLATION.md new file mode 100644 index 0000000..be96e4d --- /dev/null +++ b/INSTALLATION.md @@ -0,0 +1,104 @@ +# Installation Guide — AC Gamepad FX + +--- + +## Prerequisites + +- **Assetto Corsa** (any version) +- **Custom Shaders Patch (CSP)** v0.2.0 or later — [download at acstuff.ru/patch](https://acstuff.ru/patch/) +- **Content Manager** (recommended) — [download at assettocorsa.club/content-manager.html](https://assettocorsa.club/content-manager.html) + +--- + +## Step 1: Install the Script + +Copy the `MyGamepadFX/` folder into your Assetto Corsa installation: + +``` +assettocorsa/extension/lua/joypad-assist/MyGamepadFX/ +``` + +The folder must contain these four files: + +``` +MyGamepadFX/ +├── manifest.ini +├── config.lua +├── lib.lua +├── assist.lua +└── debug.lua +``` + +If the `joypad-assist/` folder doesn't exist, create it. + +--- + +## Step 2: Configure AC Controls (REQUIRED) + +**This step must be completed before driving. Skipping it causes broken steering feel that the script cannot detect or correct.** + +Navigate to: **Content Manager → Settings (gear icon, top right) → Assetto Corsa → Controls tab** + +Set each value exactly as listed: + +| Setting | Required Value | Notes | +|---|---|---| +| Input Method | Gamepad | Must be Gamepad, not Keyboard/Wheel | +| Speed Sensitivity | 0% | Script handles this | +| Steering Speed | 100% | Set to maximum | +| Steering Gamma | 100% | Critical — script applies its own gamma | +| Steering Filter | 0% | Script applies its own smoothing | +| Steering Deadzone | 0% | Script applies its own deadzone | + +[Screenshot: CM Controls tab — Input Method dropdown set to "Gamepad"] + +[Screenshot: CM Controls tab — Speed Sensitivity slider at 0%] + +[Screenshot: CM Controls tab — Steering Gamma slider at 100%] + +[Screenshot: CM Controls tab — Steering Filter and Deadzone both at 0%] + +**Why these values?** The script takes over all steering correction. If AC applies gamma or deadzone and the script applies them again, they stack — the result is unusable double-processing that cannot be fixed from inside the script. + +--- + +## Step 3: Activate in CSP + +In Content Manager: **Settings → Custom Shaders Patch → Gamepad FX** + +1. Check that **"Active"** is enabled +2. Select **"MyGamepadFX"** from the plugin dropdown + +[Screenshot: CM CSP settings showing Gamepad FX section with MyGamepadFX selected] + +--- + +## Step 4: First Drive + +1. Launch a session (Monaco pitlane is ideal — open space, low traffic) +2. Open the CM log: **View → Show Log** (or press `Ctrl+L`) +3. Confirm you see this line at startup: + ``` + [MyGamepadFX] Axis map — axes[0]=... axes[1]=... axes[3]=... axes[4]=... + ``` +4. Check the axis values: with the controller at rest, all four should be near `0.0` or `0.00` +5. Move the left stick — `axes[1]` should change +6. Press the right trigger — `axes[3]` should change +7. Drive around and confirm steering, throttle, and brake respond correctly + +If you see `[MyGamepadFX] ERROR:` in the log, see [Troubleshooting.md](Troubleshooting.md). + +--- + +## Troubleshooting + +| Symptom | First check | +|---|---| +| Steering twitchy or spikey | Steering Gamma not at 100% — redo Step 2 | +| Unresponsive center / large dead zone | Steering Deadzone not at 0% — redo Step 2 | +| Steering feels delayed/laggy | Steering Filter not at 0% — redo Step 2 | +| No `[MyGamepadFX]` lines in log | CSP not active or wrong plugin — redo Step 3 | +| Car drifts without input | Raise `DEADZONE` in config.lua | +| Car oscillates (tank-slapper) | Raise `COUNTERSTEER_DAMP` in config.lua | + +Full troubleshooting guide: [Troubleshooting.md](Troubleshooting.md) diff --git a/INTEGRATION.md b/INTEGRATION.md new file mode 100644 index 0000000..6c0ba5f --- /dev/null +++ b/INTEGRATION.md @@ -0,0 +1,180 @@ +# INTEGRATION.md — AC-Gamepad-FX v1.0 Integration Guide + +Step-by-step guide for applying the v1.0 changes. Written for a solo developer working alone, no CI pipeline, no automated build step — changes go straight from editor to the game folder. + +--- + +## Files Modified in v1.0 + +| File | What Changed | +|---|---| +| `MyGamepadFX/config.lua` | 9 new parameters added: GAS_DEADZONE, GAS_GAMMA, BRAKE_DEADZONE, BRAKE_GAMMA, HAPTICS_ENABLED, HAPTICS_SLIP_START, HAPTICS_SLIP_MAX, HAPTICS_STRENGTH, DEBUG_MODE | +| `MyGamepadFX/lib.lua` | Added `logOnce(key, message)` function and module-level `_logged` table | +| `MyGamepadFX/debug.lua` | Expanded telemetry with `carSteer` and `dt_ms` fields | +| `MyGamepadFX/assist.lua` | Full pipeline rewrite: first-frame diagnostics, error logging, frame drop warning, throttle/brake curves, haptic trigger rumble, corrected pipeline order | + +--- + +## Suggested Commit Order + +Apply and commit files in this order. The goal is to keep the working tree in a functional state for as long as possible — each commit is usable on its own until assist.lua lands. + +**1. config.lua** + +Add the 9 new parameters to the config table. This is a pure data change. Until assist.lua is updated, the new keys are never read, so there is no behavior change and no risk of breakage. + +``` +feat: add v1.0 config parameters (throttle/brake curves, haptics, debug mode) +``` + +**2. lib.lua** + +Add `logOnce`. This is purely additive — no existing function is modified. The new function is not called by anything until assist.lua is updated. + +``` +feat: add logOnce helper to lib.lua +``` + +**3. debug.lua** + +Expand the telemetry overlay with `carSteer` and `dt_ms`. The debug overlay is opt-in (requires uncommenting in assist.lua and setting DEBUG_MODE = true), so this change has no runtime effect until explicitly enabled. + +``` +feat: expand debug overlay with carSteer and dt_ms fields +``` + +**4. assist.lua** + +This is the behavior-changing commit. The pipeline now processes throttle/brake through deadzone/gamma, adds self-steer, slip limit, smoothing, haptics, error logging, and diagnostics. Commit this last to minimize the window between "old config" and "new pipeline." + +``` +feat: v1.0 pipeline rewrite (diagnostics, throttle/brake curves, haptics, error logging) +``` + +**5. Docs (one commit)** + +README.md, INSTALLATION.md, Troubleshooting.md, TESTING.md, VALIDATION.md, INTEGRATION.md — documentation only, no code. Can be one commit or split by file type. + +``` +docs: v1.0 documentation (testing, validation, integration, troubleshooting) +``` + +--- + +## How to Apply + +### Step 1 — Back Up + +Before touching any files, copy the current `MyGamepadFX/` folder to a safe location. + +``` +MyGamepadFX-backup-v0x\ +``` + +If anything goes wrong after the update, restore from this backup to return to the last known working state. + +### Step 2 — Locate the Target Folder + +The script folder lives inside the AC installation: + +``` +[AC install]\extension\lua\joypad-assist\MyGamepadFX\ +``` + +Common paths: +- Steam: `C:\Program Files (x86)\Steam\steamapps\common\assettocorsa\extension\lua\joypad-assist\MyGamepadFX\` +- Non-Steam: wherever AC is installed, same subfolder structure. + +### Step 3 — Copy New Files + +Copy the updated files from the repository to the target folder. If prompted to overwrite, confirm for all four files: + +- `config.lua` +- `lib.lua` +- `debug.lua` +- `assist.lua` + +Do not copy `TESTING.md`, `VALIDATION.md`, or `INTEGRATION.md` to the AC folder — they are dev docs only. + +### Step 4 — Launch AC via Content Manager + +Do not launch AC directly. Always launch through Content Manager so that CSP hooks load correctly. + +### Step 5 — Open the CM Log + +Before loading a race, open the log pane: **View → Show Log** (bottom toolbar in Content Manager). This streams log output in real time. You can also view `Documents\Assetto Corsa\logs\log.txt` after the session if you miss live output. + +### Step 6 — Load a Race + +Load any session — practice, quickrace, or hotlap. A simple venue with open space makes observation easier. Monaco pitlane recommended for low-speed steering feel testing. + +### Step 7 — Check for Startup Diagnostic + +Within the first second of the session loading, the log should contain: + +``` +[MyGamepadFX] === Startup Diagnostic === +[MyGamepadFX] Axes: [0]=0.00 [1]=0.00 [3]=0.00 [4]=0.00 +[MyGamepadFX] Expected: axes[1]=steer, axes[3]=throttle, axes[4]=brake +``` + +If this does not appear, CSP is not running the script. Check: **Settings → Custom Shaders Patch → Gamepad FX → Active = enabled, Script = MyGamepadFX**. + +### Step 8 — Drive for 2–3 Minutes + +Drive in a clear area. Test steering, throttle, brake responsiveness. Induce a little oversteer to verify self-steer is active. No specific lap required — this is a feel check, not a timed test. + +### Step 9 — Check Log for Errors + +After driving, scan the log for any lines containing `ERROR` or `Warning`: + +- `[MyGamepadFX] ERROR:` — something is wrong (nil car, nil gamepad). Follow the in-message instructions. +- `[MyGamepadFX] Warning: frame time` — frame drop logged. Occasional is fine; consistent means CPU or physics load issue (not a script bug). + +No error or warning lines = clean integration. + +--- + +## First-Drive Validation Checklist + +Run through these after completing Steps 4–9 above. Each item confirms a specific v1.0 feature is working. + +- [ ] Startup Diagnostic appears in CM log within first second of session load +- [ ] Axis values at rest are near 0.00 for all four axes (±0.05 acceptable for analog drift) +- [ ] Moving left stick changes steering angle proportionally — no dead gaps beyond the deadzone +- [ ] Deadzone feels clean: stick at center produces zero output, no drift +- [ ] Speed scaling is noticeable: full stick lock at rest is visibly more than full stick lock at 100+ km/h +- [ ] Throttle responds to right trigger; no response to trigger touches lighter than ~2% travel +- [ ] Brake responds to left trigger; same sub-2% deadzone behavior +- [ ] No `[MyGamepadFX] ERROR` lines in log +- [ ] No `[MyGamepadFX] Warning` lines in log (or only one frame-drop warning at most during load) + +--- + +## Troubleshooting + +### Startup Diagnostic does not appear + +1. Confirm CSP is installed and Gamepad FX is set to Active + MyGamepadFX in CM settings. +2. Confirm `MyGamepadFX/manifest.ini` is present in the script folder. +3. Check the log for a Lua error (CSP will log script parse errors). A common cause is a syntax error introduced during file copy. + +### Script stops working after update but worked before + +Most likely cause: config.lua was not updated. If assist.lua references `CFG.GAS_DEADZONE` but the old config.lua does not have that key, Lua will error and the script will stop. Verify that all 9 new parameters are present in config.lua. + +### Steering feels over-smooth or under-smooth compared to before + +assist.lua now applies smoothing to the combined signal (driver input + self-steer), whereas the previous version may have applied it differently. Adjust `STEER_SMOOTH` in config.lua. Start from the default and move in 0.05 steps. + +### Throttle/brake feel different + +v1.0 now runs throttle and brake through deadzone and gamma curves. With default values this is a very small change, but if you had a custom prior version that passed them raw, the feel difference may be noticeable. Adjust `GAS_GAMMA`, `GAS_DEADZONE`, `BRAKE_GAMMA`, `BRAKE_DEADZONE` in config.lua to match your preference. Setting GAS_GAMMA = 1.0 and GAS_DEADZONE = 0.0 reproduces the old raw behavior. + +### Axis assignments wrong (wrong axis controlling wrong input) + +Check the startup diagnostic log to see actual axis values. Move each input one at a time and identify which axis index changes. Update the axis read lines in assist.lua (`gamepad.axes[N]`) to match your controller's actual mapping. + +--- + +*Integration guide for AC-Gamepad-FX v1.0. Apply changes in the order listed, verify the first-drive checklist, then run the full manual test plan in TESTING.md.* diff --git a/MyGamepadFX/assist.lua b/MyGamepadFX/assist.lua index de5d2c7..6f644a4 100644 --- a/MyGamepadFX/assist.lua +++ b/MyGamepadFX/assist.lua @@ -6,13 +6,82 @@ local CFG = require('config') local lib = require('lib') -- local dbg = require('debug') -- uncomment for tuning overlay; never ship enabled -local steerOut = 0.0 +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 function applyLiveCfg() + local ok, ini = pcall(ac.INIConfig.load, LIVE_CFG_PATH) + if not ok or not ini then return end + 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.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.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.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) + CFG.HAPTICS_STRENGTH = ini:get('PARAMS', 'HAPTICS_STRENGTH', CFG.HAPTICS_STRENGTH) + CFG.DEBUG_MODE = ini:get('PARAMS', 'DEBUG_MODE', CFG.DEBUG_MODE) +end function script.update(dt) - local car = ac.getCar(0) + 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 + applyLiveCfg() + end + + 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.") + end + steerOut = 0.0 + return + end + local gamepad = ac.getGamepad(0) - if not car or not gamepad then steerOut = 0.0; return end - if dt <= 0 then return end + if not gamepad then + lib.logOnce("gamepad_nil", "[MyGamepadFX] ERROR: No gamepad detected. Check CM → Settings → Assetto Corsa → Controls → Input Method = Gamepad.") + steerOut = 0.0 + return + end + + if dt <= 0 or dt > 0.1 then return end + + if dt > 0.05 then + 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") + firstFrameDiagnostics = true + end -- 1. Raw input → deadzone → gamma → speed scale local raw = gamepad.axes[1] or 0.0 -- left stick X (steering) @@ -20,7 +89,7 @@ function script.update(dt) 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 -- average of FL + FR + 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 @@ -36,9 +105,9 @@ function script.update(dt) -- 4. Smooth combined signal (driver + self-steer together — avoids correction lag) steerOut = lib.expSmooth(steerOut, combined, CFG.STEER_SMOOTH, dt) - -- if dbg then dbg.draw({ + -- if dbg and CFG.DEBUG_MODE then dbg.draw({ -- raw = raw, - -- afterScale = steer, -- value after deadzone + gamma + speed scale, before slip clamp + -- afterScale = steer, -- steerLimit = steerLimit, -- driverInput = driverInput, -- selfSteer = selfSteer, @@ -46,14 +115,28 @@ function script.update(dt) -- steerOut = steerOut, -- avgFrontSlip = avgFrontSlip, -- speedKmh = car.speedKmh, + -- carSteer = car.steer, + -- dt = dt, -- }) end + -- 4.5. Haptic feedback (trigger rumble on front slip) + if CFG.HAPTICS_ENABLED then + local slipIntensity = math.clamp( + (avgFrontSlip - 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 - ac.setSteer(math.clamp(steerOut, -1.0, 1.0)) - ac.setGas( math.clamp(gamepad.axes[3] or 0.0, 0.0, 1.0)) -- right trigger - ac.setBrake(math.clamp(gamepad.axes[4] or 0.0, 0.0, 1.0)) -- left trigger + ac.setSteer(math.clamp(steerOut, -1.0, 1.0)) + ac.setGas( math.clamp(lib.applyGamma(lib.applyDeadzone(gamepad.axes[3] or 0.0, CFG.GAS_DEADZONE), CFG.GAS_GAMMA), 0.0, 1.0)) + ac.setBrake(math.clamp(lib.applyGamma(lib.applyDeadzone(gamepad.axes[4] or 0.0, CFG.BRAKE_DEADZONE), CFG.BRAKE_GAMMA), 0.0, 1.0)) end function script.reset() - steerOut = 0.0 + steerOut = 0.0 + frameCount = 0 + liveCfgTimer = 0.5 + -- firstFrameDiagnostics intentionally not reset — log axes once per game start, not per session end diff --git a/MyGamepadFX/config.lua b/MyGamepadFX/config.lua index df943d4..05baaca 100644 --- a/MyGamepadFX/config.lua +++ b/MyGamepadFX/config.lua @@ -20,6 +20,21 @@ local CFG = { SLIP_LIMIT_START = 0.15, -- front slip level where reduction begins SLIP_LIMIT_RANGE = 0.25, -- slip range over which full reduction is applied SLIP_LIMIT_MIN = 0.70, -- minimum driver authority at peak slip + + -- Throttle & brake curves + GAS_DEADZONE = 0.01, -- removes trigger drift at rest + GAS_GAMMA = 1.1, -- slight curve for smooth power delivery + BRAKE_DEADZONE = 0.01, -- avoids light brake from trigger drift + BRAKE_GAMMA = 1.0, -- linear brake (no curve) + + -- 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] + + -- Debug / tuning mode + DEBUG_MODE = false, -- set true to enable live telemetry overlay } return CFG diff --git a/MyGamepadFX/debug.lua b/MyGamepadFX/debug.lua index e007ef4..dc92f0f 100644 --- a/MyGamepadFX/debug.lua +++ b/MyGamepadFX/debug.lua @@ -7,16 +7,19 @@ local D = {} -- Call once per frame from script.update, passing the pipeline's internal values. function D.draw(values) -- values = { raw, afterScale, driverInput, steerLimit, - -- avgFrontSlip, selfSteer, combined, steerOut, speedKmh } - ac.debug("1 raw_input", values.raw) - ac.debug("2 after_scale", values.afterScale) - ac.debug("3 steer_limit", values.steerLimit) - ac.debug("4 driver_input", values.driverInput) - ac.debug("5 self_steer", values.selfSteer) - ac.debug("6 combined", values.combined) - ac.debug("7 steer_out", values.steerOut) - ac.debug("8 front_slip", values.avgFrontSlip) - ac.debug("9 speed_kmh", values.speedKmh) + -- avgFrontSlip, selfSteer, combined, steerOut, speedKmh, + -- carSteer, dt } + ac.debug("1 raw_input", string.format("%.3f", values.raw)) + ac.debug("2 after_scale", string.format("%.3f", values.afterScale)) + ac.debug("3 steer_limit", string.format("%.3f", values.steerLimit)) + ac.debug("4 driver_input", string.format("%.3f", values.driverInput)) + ac.debug("5 self_steer", string.format("%.3f", values.selfSteer)) + ac.debug("6 combined", string.format("%.3f", values.combined)) + ac.debug("7 steer_out", string.format("%.3f", values.steerOut)) + ac.debug("8 front_slip", string.format("%.3f", values.avgFrontSlip)) + ac.debug("9 speed_kmh", string.format("%.1f", values.speedKmh)) + ac.debug("A_car_steer", string.format("%.3f", values.carSteer)) + ac.debug("B_dt_ms", string.format("%.2f", values.dt * 1000)) end return D diff --git a/MyGamepadFX/lib.lua b/MyGamepadFX/lib.lua index a5dfb38..8c9a019 100644 --- a/MyGamepadFX/lib.lua +++ b/MyGamepadFX/lib.lua @@ -2,6 +2,15 @@ -- Note: math.clamp is a CSP extension, not available in vanilla LuaJIT. local M = {} +local _logged = {} + +function M.logOnce(key, message) + if not _logged[key] then + ac.log(message) + _logged[key] = true + end +end + -- Deadzone with rescale: removes drift and maps the remaining range to [0,1] -- with no discontinuity at the deadzone edge. function M.applyDeadzone(v, dz) diff --git a/MyGamepadFX_Config/app.lua b/MyGamepadFX_Config/app.lua new file mode 100644 index 0000000..93ff38c --- /dev/null +++ b/MyGamepadFX_Config/app.lua @@ -0,0 +1,170 @@ +-- app.lua — MyGamepadFX Config UI +-- Writes live_cfg.ini into the joypad-assist script folder. +-- assist.lua polls and applies it within 0.5 seconds while driving. +-- +-- Path used by this app: +-- ac.getFolder(ac.FolderID.ExtLua) .. '/joypad-assist/MyGamepadFX/live_cfg.ini' +-- Path used by assist.lua: +-- ac.getFolder(ac.FolderID.ScriptOrigin) .. '/live_cfg.ini' +-- Both resolve to the same file on disk. + +local LIVE_CFG_PATH = ac.getFolder(ac.FolderID.ExtLua) .. '/joypad-assist/MyGamepadFX/live_cfg.ini' + +-- Defaults mirror config.lua — used when no saved file exists yet +local cfg = { + DEADZONE = 0.08, + GAMMA = 1.6, + STEER_SMOOTH = 0.12, + SPEED_SCALE_START = 60, + SPEED_SCALE_END = 180, + SPEED_SCALE_MIN = 0.35, + COUNTERSTEER_GAIN = 0.45, + COUNTERSTEER_DAMP = 0.30, + SLIP_LIMIT_START = 0.15, + SLIP_LIMIT_RANGE = 0.25, + SLIP_LIMIT_MIN = 0.70, + GAS_DEADZONE = 0.01, + GAS_GAMMA = 1.1, + BRAKE_DEADZONE = 0.01, + BRAKE_GAMMA = 1.0, + HAPTICS_ENABLED = false, + HAPTICS_SLIP_START = 0.3, + HAPTICS_SLIP_MAX = 1.0, + HAPTICS_STRENGTH = 0.8, + DEBUG_MODE = false, +} + +local dirty = false +local saveTimer = 0 + +local function saveLiveCfg() + local ok, err = pcall(function() + local ini = ac.INIConfig.load(LIVE_CFG_PATH) + ini:set('PARAMS', 'DEADZONE', cfg.DEADZONE) + ini:set('PARAMS', 'GAMMA', cfg.GAMMA) + ini:set('PARAMS', 'STEER_SMOOTH', cfg.STEER_SMOOTH) + ini:set('PARAMS', 'SPEED_SCALE_START', cfg.SPEED_SCALE_START) + ini:set('PARAMS', 'SPEED_SCALE_END', cfg.SPEED_SCALE_END) + ini:set('PARAMS', 'SPEED_SCALE_MIN', cfg.SPEED_SCALE_MIN) + ini:set('PARAMS', 'COUNTERSTEER_GAIN', cfg.COUNTERSTEER_GAIN) + ini:set('PARAMS', 'COUNTERSTEER_DAMP', cfg.COUNTERSTEER_DAMP) + ini:set('PARAMS', 'SLIP_LIMIT_START', cfg.SLIP_LIMIT_START) + ini:set('PARAMS', 'SLIP_LIMIT_RANGE', cfg.SLIP_LIMIT_RANGE) + ini:set('PARAMS', 'SLIP_LIMIT_MIN', cfg.SLIP_LIMIT_MIN) + ini:set('PARAMS', 'GAS_DEADZONE', cfg.GAS_DEADZONE) + ini:set('PARAMS', 'GAS_GAMMA', cfg.GAS_GAMMA) + ini:set('PARAMS', 'BRAKE_DEADZONE', cfg.BRAKE_DEADZONE) + ini:set('PARAMS', 'BRAKE_GAMMA', cfg.BRAKE_GAMMA) + ini:set('PARAMS', 'HAPTICS_ENABLED', cfg.HAPTICS_ENABLED) + ini:set('PARAMS', 'HAPTICS_SLIP_START', cfg.HAPTICS_SLIP_START) + ini:set('PARAMS', 'HAPTICS_SLIP_MAX', cfg.HAPTICS_SLIP_MAX) + ini:set('PARAMS', 'HAPTICS_STRENGTH', cfg.HAPTICS_STRENGTH) + ini:set('PARAMS', 'DEBUG_MODE', cfg.DEBUG_MODE) + ini:save(LIVE_CFG_PATH) + end) + if not ok then + ac.log('[MyGamepadFXConfig] ERROR saving live_cfg.ini: ' .. tostring(err)) + end +end + +local function loadSaved() + local ok, ini = pcall(ac.INIConfig.load, LIVE_CFG_PATH) + if not ok or not ini then return end + 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.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.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.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) + cfg.HAPTICS_STRENGTH = ini:get('PARAMS', 'HAPTICS_STRENGTH', cfg.HAPTICS_STRENGTH) + cfg.DEBUG_MODE = ini:get('PARAMS', 'DEBUG_MODE', cfg.DEBUG_MODE) +end + +-- Restore last-saved values on app open +loadSaved() + +local function sliderRow(label, key, min, max, fmt) + local v = ui.slider('##' .. key, cfg[key], min, max, label .. ': ' .. fmt) + if v ~= cfg[key] then + cfg[key] = v + dirty = true + end +end + +function windowMain(dt) + -- Throttle disk writes — save at most 10 Hz regardless of how fast sliders move + if dirty and saveTimer <= 0 then + saveLiveCfg() + dirty = false + saveTimer = 0.1 + end + saveTimer = saveTimer - dt + + ui.text('Steering') + ui.separator() + sliderRow('Deadzone', 'DEADZONE', 0.0, 0.30, '%.2f') + sliderRow('Gamma', 'GAMMA', 1.0, 3.0, '%.2f') + sliderRow('Smooth', 'STEER_SMOOTH', 0.01, 0.50, '%.2f') + + ui.offsetCursorY(8) + ui.text('Speed Scaling') + ui.separator() + sliderRow('Start km/h', 'SPEED_SCALE_START', 0, 200, '%.0f') + sliderRow('End km/h', 'SPEED_SCALE_END', 0, 300, '%.0f') + sliderRow('Min scale', 'SPEED_SCALE_MIN', 0.1, 1.0, '%.2f') + + ui.offsetCursorY(8) + ui.text('Self-Steer') + ui.separator() + sliderRow('Gain', 'COUNTERSTEER_GAIN', 0.0, 1.0, '%.2f') + sliderRow('Damp', 'COUNTERSTEER_DAMP', 0.0, 1.0, '%.2f') + + ui.offsetCursorY(8) + ui.text('Slip Limit') + ui.separator() + sliderRow('Start slip', 'SLIP_LIMIT_START', 0.0, 0.5, '%.2f') + sliderRow('Range', 'SLIP_LIMIT_RANGE', 0.05, 0.5, '%.2f') + sliderRow('Min auth.', 'SLIP_LIMIT_MIN', 0.3, 1.0, '%.2f') + + ui.offsetCursorY(8) + ui.text('Throttle & Brake') + ui.separator() + sliderRow('Gas dz', 'GAS_DEADZONE', 0.0, 0.10, '%.3f') + sliderRow('Gas gamma', 'GAS_GAMMA', 0.5, 2.0, '%.2f') + sliderRow('Brake dz', 'BRAKE_DEADZONE', 0.0, 0.10, '%.3f') + sliderRow('Brake gamma', 'BRAKE_GAMMA', 0.5, 2.0, '%.2f') + + ui.offsetCursorY(8) + ui.text('Haptics') + ui.separator() + if ui.checkbox('Enabled##haptics', cfg.HAPTICS_ENABLED) then + cfg.HAPTICS_ENABLED = not cfg.HAPTICS_ENABLED + dirty = true + end + if cfg.HAPTICS_ENABLED then + sliderRow('Slip start', 'HAPTICS_SLIP_START', 0.0, 1.0, '%.2f') + sliderRow('Slip max', 'HAPTICS_SLIP_MAX', 0.0, 2.0, '%.2f') + sliderRow('Strength', 'HAPTICS_STRENGTH', 0.0, 1.0, '%.2f') + end + + ui.offsetCursorY(8) + if ui.checkbox('Debug overlay', cfg.DEBUG_MODE) then + cfg.DEBUG_MODE = not cfg.DEBUG_MODE + dirty = true + end + + ui.offsetCursorY(6) + ui.text('Changes apply within ~0.5s') +end diff --git a/MyGamepadFX_Config/manifest.ini b/MyGamepadFX_Config/manifest.ini new file mode 100644 index 0000000..66bcd90 --- /dev/null +++ b/MyGamepadFX_Config/manifest.ini @@ -0,0 +1,12 @@ +[ABOUT] +NAME = MyGamepadFX Config +AUTHOR = rolling-codes +VERSION = 1.0 + +[CORE] +LAZY = FULL + +[WINDOW_MAIN] +ID = main +FUNCTION_MAIN = windowMain +SIZE = 380, 680 diff --git a/README.md b/README.md index 896ef55..51fb54b 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,27 @@ A [Custom Shaders Patch](https://acstuff.ru/patch/) Gamepad FX Lua script for As --- +## ⚠️ MANDATORY: AC Controls Pre-Flight Checklist + +**This script will not work correctly unless AC's built-in controls are set to pass-through mode. Do this before driving.** + +In Content Manager: **Settings** (gear icon) → **Assetto Corsa** → **Controls** tab + +1. **Input Method** → `Gamepad` +2. **Speed Sensitivity** → `0%` +3. **Steering Speed** → `100%` +4. **Steering Gamma** → `100%` +5. **Steering Filter** → `0%` +6. **Steering Deadzone** → `0%` + +> **Why this matters:** This script owns all steering correction. If AC also applies gamma, deadzone, or filter, the effects stack — your gamma curve gets applied twice, your deadzone gets applied twice, and the result is unusable. There is no way for the script to detect or fix this automatically; you must set these manually. + +For step-by-step screenshots showing each setting in Content Manager, see [INSTALLATION.md](INSTALLATION.md). + +If steering feels broken after installing, the AC settings are the first thing to check. See [Troubleshooting.md](Troubleshooting.md). + +--- + ## What it does Five corrections applied in order every frame: @@ -37,15 +58,7 @@ assettocorsa/extension/lua/joypad-assist/MyGamepadFX/ Activate in **Content Manager → Settings → Custom Shaders Patch → Gamepad FX → MyGamepadFX**. -**Recommended AC Controls settings** (let the script handle these): - -| Setting | Value | -|---|---| -| Speed Sensitivity | 0% | -| Steering Speed | 100% | -| Steering Gamma | 100% | -| Steering Filter | 0% | -| Steering Deadzone | 0% | +Complete step-by-step installation with screenshots: [INSTALLATION.md](INSTALLATION.md). --- @@ -55,7 +68,7 @@ Activate in **Content Manager → Settings → Custom Shaders Patch → Gamepad MyGamepadFX/ ├── manifest.ini — CSP plugin declaration ├── config.lua — all tunable constants (edit this to tune feel) -├── lib.lua — math helpers: deadzone, gamma, lerp, expSmooth, speedScale +├── lib.lua — math helpers: deadzone, gamma, lerp, expSmooth, speedScale, logOnce ├── assist.lua — pipeline assembly and CSP entry points └── debug.lua — live telemetry overlay (disabled by default) ``` @@ -80,6 +93,13 @@ COUNTERSTEER_DAMP = 0.30 -- must stay >= COUNTERSTEER_GAIN * 0.6 to prevent os SLIP_LIMIT_START = 0.15 -- front slip level where limit kicks in SLIP_LIMIT_RANGE = 0.25 -- slip range for full reduction SLIP_LIMIT_MIN = 0.70 -- minimum authority at peak slip + +GAS_DEADZONE = 0.01 -- removes trigger drift at rest +GAS_GAMMA = 1.1 -- slight curve for smooth power delivery +BRAKE_DEADZONE = 0.01 -- avoids unintended light brake from trigger drift +BRAKE_GAMMA = 1.0 -- linear brake (no curve) + +HAPTICS_ENABLED = false -- set true if your controller supports trigger rumble ``` **Tuning order:** deadzone → gamma → speed scale → countersteer gain → damp → slip limit → smoothing last. @@ -90,13 +110,19 @@ If the car oscillates (tank-slapper), raise `COUNTERSTEER_DAMP` before touching ## Debug overlay -Enable the live telemetry overlay during tuning by uncommenting one line in `assist.lua`: +Enable the live telemetry overlay during tuning by uncommenting two lines in `assist.lua`: + +```lua +local dbg = require('debug') -- line 1: uncomment this +``` + +And inside `script.update`, uncomment the `dbg.draw({...})` block. Also set in `config.lua`: ```lua -local dbg = require('debug') -- uncomment this line +DEBUG_MODE = true ``` -Shows raw input, post-scale value, steer limit, driver input, self-steer contribution, combined signal, smoothed output, front slip, and speed — updated every frame via `ac.debug()`. +Shows raw input, post-scale value, steer limit, driver input, self-steer contribution, combined signal, smoothed output, front slip, speed, actual steer angle, and frame time — updated every frame via `ac.debug()`. **Never ship with this enabled.** @@ -106,3 +132,16 @@ Shows raw input, post-scale value, steer limit, driver input, self-steer contrib - Assetto Corsa with [Custom Shaders Patch](https://acstuff.ru/patch/) v0.2.0 or later - A gamepad (tested layout: left stick X = steering, right trigger = throttle, left trigger = brake) + +--- + +## Troubleshooting + +See [Troubleshooting.md](Troubleshooting.md) for a full Q&A covering common symptoms. + +**Quick reference:** +- Steering twitchy/spikey → AC Steering Gamma not at 100% +- Dead zone at center → AC Steering Deadzone not at 0% +- Laggy feel → AC Steering Filter not at 0% +- Car drifts at center → raise `DEADZONE` in config.lua +- Car oscillates → raise `COUNTERSTEER_DAMP` in config.lua diff --git a/RESEARCH_FINDINGS.md b/RESEARCH_FINDINGS.md new file mode 100644 index 0000000..483a69a --- /dev/null +++ b/RESEARCH_FINDINGS.md @@ -0,0 +1,53 @@ +# RESEARCH_FINDINGS.md — CSP Live Config App Research + +**Date:** June 2026 +**Decision:** Build v1.0 app — config UI only. AC pass-through settings cannot be automated. + +--- + +## Question: Can CSP Lua apps write to CONTROLS.INI? + +**Answer: NO.** + +Three independent lines of evidence from the CSP SDK (`acc-lua-sdk`): + +1. **Explicit SDK annotation.** `ac.INIConfig.controlsConfig()` in `ac_extras_ini.d.lua` carries the comment *"Returned file can't be saved."* The same read-only annotation applies to carData, carConfig, trackData, trackConfig, onlineExtras, raceConfig, and videoConfig — all protected system configs. + +2. **No public write API.** The `io` module only documents `io.scanDir`. There is no `io.save`, `io.write`, or `io.open` in the public Lua API. `ac.INIConfig:save()` internally calls `io.save` (a C++ function) — but only for configs loaded via `ac.INIConfig.load(filename)`, not for the protected config accessors. + +3. **`ac.storage` sandboxed.** The only reliable per-script persistence API saves to `Documents\Assetto Corsa\cfg\extension\state\lua\` — scoped per script type, inaccessible cross-script. + +## What the App CAN Do + +- Show all 20 MyGamepadFX parameters as live sliders and checkboxes +- Write `live_cfg.ini` to the joypad-assist script's own folder via `ac.INIConfig:save()` +- The joypad-assist script polls that file every 0.5s and overrides CFG values in memory +- Persist the last-used slider values across sessions (same file) + +## What the App CANNOT Do + +- Read or write `CONTROLS.INI` +- Auto-configure Speed Sensitivity, Steering Gamma, Steering Filter, Steering Deadzone +- Replace the manual pre-flight checklist in README.md and INSTALLATION.md + +## Communication Architecture + +``` +App (extension/apps/lua/MyGamepadFX_Config/) + → writes live_cfg.ini to extension/lua/joypad-assist/MyGamepadFX/ + via ac.getFolder(ac.FolderID.ExtLua) .. '/joypad-assist/MyGamepadFX/live_cfg.ini' + +Script (extension/lua/joypad-assist/MyGamepadFX/) + → polls live_cfg.ini every 0.5s + via ac.getFolder(ac.FolderID.ScriptOrigin) .. '/live_cfg.ini' + → overrides CFG values with live values when file present and parseable + → uses static config.lua defaults when file absent (first run, no app open) +``` + +Both paths resolve to the same file on disk. The script's folder is the canonical location — the app reaches into it via the known extension/lua/ root. + +## Build Decision + +**v1.0: Build the app.** All 20 CFG parameters exposed as live sliders. Changes apply within 0.5s while driving. No AC settings automation — that requires a CM plugin (v1.1 or later, different technology stack). + +The manual pre-flight checklist in README/INSTALLATION.md remains mandatory and unchanged. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..3ef853c --- /dev/null +++ b/TESTING.md @@ -0,0 +1,440 @@ +# TESTING.md — AC-Gamepad-FX v1.0 Manual Testing Plan + +Manual test plan for a single tester with one Xbox controller. All tests require launching Assetto Corsa via Content Manager. No automated test runner exists — this script runs inside the game engine every physics frame. + +--- + +## 1. Pre-Test Setup + +Complete all five settings before starting any test below. Wrong values here will produce false failures. + +**In Content Manager → Settings → Assetto Corsa → Controls:** +- [ ] Input Method = **Gamepad** +- [ ] Steering Gamma = **1.0** (pass-through; MyGamepadFX applies its own gamma) +- [ ] Steering Deadzone = **0** (pass-through; MyGamepadFX applies its own deadzone) +- [ ] Steering Filter = **0** (pass-through; MyGamepadFX applies its own smoothing) +- [ ] Speed Sensitivity = **0** (pass-through; MyGamepadFX applies its own speed scale) + +**In Content Manager → Settings → Custom Shaders Patch → Gamepad FX:** +- [ ] Active = **enabled** +- [ ] Selected script = **MyGamepadFX** + +**In MyGamepadFX/config.lua:** +- [ ] `DEBUG_MODE = false` (overlay off for functional tests; enable only in Section 6) +- [ ] `HAPTICS_ENABLED = false` (off for all tests except Section 5) + +**Recommended test venue:** Monaco pitlane or any open flat area. The pitlane at low speed lets you isolate steering feel without traction limit interference. For slip/self-steer tests, exit the pitlane onto the track. + +--- + +## 2. Functional Tests — Core Pipeline + +### 2.1 Steering Response + +**What to do:** Load a race. At rest in the pitlane, move the left stick slowly from center to full left, then full right. + +**Expected result:** The front wheels visually track the stick. Movement is smooth and proportional — no dead gap larger than the configured DEADZONE, no snap or jerk. + +- [ ] PASS / FAIL + +--- + +### 2.2 Deadzone at Center + +**What to do:** Hold the stick as close to dead center as you can. Watch the steering wheel in the cockpit view for 10 seconds. + +**Expected result:** Steering wheel does not drift or twitch. Output is exactly zero when stick is within the deadzone band. + +- [ ] PASS / FAIL + +--- + +### 2.3 Full Lock Left and Right + +**What to do:** Push left stick to full left, hold for 2 seconds. Then full right, hold for 2 seconds. + +**Expected result:** Steering reaches maximum lock in both directions. No clipping artifacts (wheel doesn't oscillate at limit). Returns cleanly to center when stick released. + +- [ ] PASS / FAIL + +--- + +### 2.4 Throttle Response + +**What to do:** In neutral or with clutch held, slowly press the right trigger from 0% to 100%. + +**Expected result:** Engine revs rise proportionally. No rev spike at the first mm of trigger travel (GAS_DEADZONE working). Full trigger produces full throttle. + +- [ ] PASS / FAIL + +--- + +### 2.5 Brake Response + +**What to do:** At low speed (30 km/h), slowly apply the left trigger. + +**Expected result:** Car decelerates smoothly. No brake jab at initial trigger contact (BRAKE_DEADZONE working). Full trigger produces full brake pressure. + +- [ ] PASS / FAIL + +--- + +### 2.6 Throttle/Brake Deadzone (< 2% trigger press) + +**What to do:** Very lightly brush the right trigger — just barely press it, less than what you'd estimate is 2% of full travel. Do the same with the left trigger. + +**Expected result:** No throttle or brake response. The car remains stationary (throttle) or decelerates at the same rate (brake). This confirms GAS_DEADZONE and BRAKE_DEADZONE are filtering micro-inputs. + +- [ ] PASS / FAIL + +--- + +### 2.7 Speed Scaling + +**What to do:** Come to a complete stop. Apply full left stick and note approximate steering angle. Accelerate to 150 km/h on a straight. Apply full left stick again and note steering angle. + +**Expected result:** At standstill, full stick produces near-full lock. At 150+ km/h, full stick produces noticeably less lock. The reduction should feel gradual, not a sudden cut. + +- [ ] PASS / FAIL +- Note approximate lock reduction at 150 km/h: ____________ + +--- + +### 2.8 Self-Steer / Countersteer Resistance + +**What to do:** At 80–100 km/h, enter a corner and deliberately over-rotate the car (e.g., lift mid-corner to induce oversteer). Do not countersteer manually — let the script work. + +**Expected result:** The car resists spinning further. Self-steer pulls the wheel toward recovery. It should NOT fully self-correct back to straight (CFG.COUNTERSTEER_GAIN is intentionally moderate) — the driver still needs to assist, but the snap is damped. + +- [ ] PASS / FAIL +- Note: this is subjective feel. Fail = car spins regardless, or wheel snaps violently in recovery direction. + +--- + +### 2.9 Slip Limit (Hard Cornering) + +**What to do:** Push hard into a medium-speed corner at the limit of front grip. Try to add more steering mid-corner. + +**Expected result:** Additional steering input beyond the slip limit feels progressively blocked — the wheel moves less than the stick commands. This is distinct from understeer (the physical car understeering vs. the script clamping input). + +- [ ] PASS / FAIL +- Note: to distinguish script clamp from car physics, enable DEBUG overlay (Section 6) and watch `steerLimit` value during the corner. + +--- + +### 2.10 Smoothing (No Single-Frame Spike) + +**What to do:** While driving at 80 km/h, rapidly flick the left stick from full left to full right and back. Do this 5–6 times quickly. + +**Expected result:** Steering follows the input without a single-frame snap. The transition should feel damped — it takes a fraction of a second to reach the new position. No jerk or oscillation. + +- [ ] PASS / FAIL + +--- + +## 3. Diagnostic Logging Tests + +**How to open the log:** In Content Manager, go to **View → Show Log** (bottom of the CM window). The log updates live. You can also check the AC log file at `Documents\Assetto Corsa\logs\log.txt` after the session. + +### 3.1 Startup Diagnostic Header + +**What to do:** Load any race session fresh (not resume). + +**Expected result:** Log contains: +``` +[MyGamepadFX] === Startup Diagnostic === +``` + +- [ ] PASS / FAIL + +--- + +### 3.2 Axis Values Logged + +**What to do:** Check the log immediately after the diagnostic header. + +**Expected result:** Log contains a line like: +``` +[MyGamepadFX] Axes: [0]=0.00 [1]=0.00 [3]=0.00 [4]=0.00 +``` +Values at rest should all be near `0.00` (may vary slightly by controller — small drift up to ±0.05 is normal for analog sticks). + +- [ ] PASS / FAIL +- Recorded axis values at rest: [0]=_____ [1]=_____ [3]=_____ [4]=_____ + +--- + +### 3.3 Expected Axis Assignment Line + +**What to do:** Check log for axis assignment hint. + +**Expected result:** Log contains: +``` +[MyGamepadFX] Expected: axes[1]=steer, axes[3]=throttle, axes[4]=brake +``` + +- [ ] PASS / FAIL + +--- + +### 3.4 Steering Axis Change Visible in Log + +**What to do:** Exit to menu. Edit config.lua to temporarily set `DEBUG_MODE = true`. Load the race again. Move left stick to full left before and after the diagnostic fires (on next game restart, not session reset). + +**Alternative (without restart):** Enable debug overlay (Section 6) and watch the `raw` field change as you move the stick. + +**Expected result:** axes[1] value changes from ~0.00 to ~-1.00 (left) when stick is moved. This confirms the steer axis assignment is correct. + +- [ ] PASS / FAIL + +--- + +### 3.5 Throttle/Brake Axes Change + +**What to do:** Same as 3.4 but press right trigger (axes[3]) and left trigger (axes[4]) while watching the debug overlay or restarting with diagnostic. + +**Expected result:** axes[3] rises from 0.00 toward 1.00 on throttle. axes[4] rises from 0.00 toward 1.00 on brake. + +- [ ] PASS / FAIL + +--- + +### 3.6 Diagnostic Does Not Repeat on Session Reset + +**What to do:** Without restarting the game, exit to menu and reload the session (Session Reset → Restart). + +**Expected result:** `[MyGamepadFX] === Startup Diagnostic ===` does NOT appear again in the log. The diagnostic fires once per game launch only. `script.reset()` clears frameCount and steerOut but intentionally does not reset `firstFrameDiagnostics`. + +- [ ] PASS / FAIL + +--- + +## 4. Error Condition Tests + +### 4.1 Gamepad Disconnected + +**What to do:** Unplug the Xbox controller USB before launching a race, then load a session. + +**Expected result:** Log contains exactly one line: +``` +[MyGamepadFX] ERROR: No gamepad detected. Check CM → Settings → Assetto Corsa → Controls → Input Method = Gamepad. +``` +The message appears once, not once per frame. + +- [ ] PASS / FAIL + +--- + +### 4.2 logOnce Prevents Spam + +**What to do:** Leave the controller unplugged and wait 10 seconds (many frames will have passed). + +**Expected result:** The error message above appears only once in the log, regardless of how many frames have elapsed. Searching the log for `[MyGamepadFX] ERROR: No gamepad` should return exactly one match. + +- [ ] PASS / FAIL + +--- + +### 4.3 Script Recovers After Reconnect + +**What to do:** While AC is running in a session with the gamepad unplugged (error message logged), plug the controller back in. + +**Expected result:** On the next frame after reconnect, `ac.getGamepad(0)` returns non-nil and the script resumes normal operation. Steering, throttle, and brake all respond correctly. No crash or freeze. + +- [ ] PASS / FAIL +- Note: AC may require you to re-enter the session or re-assign axes depending on OS controller detection timing. This is OS/AC behavior, not a script bug. + +--- + +## 5. Haptic Feedback Tests + +**Prerequisites:** A controller with trigger rumble motors (Xbox Elite Series 2 or similar). Standard Xbox One controllers have motor rumble but not per-trigger rumble — `ac.setTriggerRumble` behavior on non-trigger-rumble controllers is **undefined** (may be a no-op). Test results here are ASSUMPTION-flagged. + +**Before testing:** Set `HAPTICS_ENABLED = true` in config.lua, then restart AC. + +### 5.1 Trigger Rumble on Slip + +**What to do:** Drive at the limit of front traction (aggressive corner entry or deliberate lockup under braking). + +**Expected result:** Controller vibrates or trigger resists slightly when front tire slip is above HAPTICS_SLIP_START (0.3 by default). Intensity increases as slip increases. + +- [ ] PASS / FAIL / NOT TESTABLE (controller lacks trigger motors) + +--- + +### 5.2 Vibration Increases with Slip + +**What to do:** Gradually increase cornering speed from moderate to at-limit to beyond-limit. + +**Expected result:** Vibration/rumble intensity increases proportionally. At very high slip, intensity is near maximum (clamped to 1.0 * HAPTICS_STRENGTH = 0.8). + +- [ ] PASS / FAIL / NOT TESTABLE + +--- + +### 5.3 No Vibration on Straight + +**What to do:** Drive a straight at any speed with no wheelspin. + +**Expected result:** No haptic feedback. avgFrontSlip below HAPTICS_SLIP_START = 0.3 means slipIntensity clamps to 0.0. + +- [ ] PASS / FAIL / NOT TESTABLE + +--- + +### 5.4 Disable Haptics + +**What to do:** Set `HAPTICS_ENABLED = false` in config.lua. Restart AC. Repeat the slip scenario from 5.1. + +**Expected result:** No vibration at all, even at high slip. + +- [ ] PASS / FAIL + +--- + +## 6. Debug Mode Tests + +**Before testing:** In assist.lua, uncomment these two lines: +```lua +local dbg = require('debug') +``` +and (inside `script.update`, after `steerOut` is written): +```lua +-- dbg.draw({...}) +``` +Also set `DEBUG_MODE = true` in config.lua. Restart AC. + +### 6.1 Overlay Appears + +**What to do:** Load a race. + +**Expected result:** A telemetry overlay is visible in-game (top-left or configured corner). Numbers are updating each frame — not frozen. + +- [ ] PASS / FAIL + +--- + +### 6.2 All 11 Fields Present and Updating + +**What to do:** Drive around and observe the overlay. Move the stick, apply throttle/brake, generate slip. + +**Expected result:** All 11 fields update each frame: + +| Field | Changes When | +|---|---| +| `raw` | Stick moves | +| `afterScale` | Stick moves + speed changes | +| `steerLimit` | Front slip increases/decreases | +| `driverInput` | Stick moves | +| `selfSteer` | Slip or car.steer changes | +| `combined` | Any of the above | +| `steerOut` | Smoothed version of combined | +| `avgFrontSlip` | Cornering or braking | +| `speedKmh` | Acceleration/braking | +| `carSteer` | Actual wheel position | +| `dt_ms` | Every frame (should hover around 8–16ms at 60–120fps) | + +- [ ] PASS / FAIL +- Note any fields that appear frozen or missing: ____________ + +--- + +### 6.3 Overlay Disappears with DEBUG_MODE = false + +**What to do:** Set `DEBUG_MODE = false` in config.lua (or re-comment the dbg lines in assist.lua). Restart AC. + +**Expected result:** No overlay visible during the race. + +- [ ] PASS / FAIL + +--- + +## 7. Session Reset Tests + +### 7.1 No Jerk on Session Reload + +**What to do:** Drive normally at 80+ km/h. Exit to main menu (triggers `script.reset()`). Immediately reload the same session. + +**Expected result:** At session start, steering output is zero (steerOut was cleared to 0.0 by reset). No jerk or snap in the first second of the new session. + +- [ ] PASS / FAIL + +--- + +### 7.2 Diagnostics Do Not Re-Log on Reset + +**What to do:** Check the CM log after reloading the session (same game process, not a restart). + +**Expected result:** `[MyGamepadFX] === Startup Diagnostic ===` does not appear a second time. `firstFrameDiagnostics` is intentionally preserved across session resets. + +- [ ] PASS / FAIL + +--- + +### 7.3 No State Leak Across Sessions (Qualifying → Race) + +**What to do:** Start in qualifying. Drive for 2–3 minutes. Transition to race session without restarting AC. + +**Expected result:** Race session starts cleanly. steerOut is 0.0 at the start line. No persistent leftover steering bias. frameCount restarts at 0 (confirming reset ran). + +- [ ] PASS / FAIL + +--- + +## 8. Edge Cases + +### 8.1 Gamepad Briefly Unavailable at Load + +**What to do:** Launch AC with the controller plugged in. Just as the race loads (the loading screen is transitioning), briefly unplug and replug the controller. + +**Expected result:** Script does not crash. The nil-gamepad path runs for those frames, logging at most one error. Normal operation resumes once `ac.getGamepad(0)` returns non-nil again. + +- [ ] PASS / FAIL +- Note: outcome depends on OS timing of USB reconnect. A crash here would show as AC hanging — if that occurs, report it. + +--- + +### 8.2 Pause/Unpause Does Not Produce Jerk + +**What to do:** Drive at 100 km/h. Pause the game (ESC). Wait 5 seconds. Unpause. + +**Expected result:** No steering jerk on unpause. The `dt > 0.1` guard in assist.lua discards the first frame (which has an inflated dt from pause). Steering resumes smoothly from its pre-pause position. + +- [ ] PASS / FAIL + +--- + +### 8.3 Alt-Tab and Return + +**What to do:** While in a session, Alt-Tab to desktop. Wait 10 seconds. Return to the game. + +**Expected result:** No crash, freeze, or unexpected steering output on return. Similar to 8.2 — the dt spike on return is discarded. + +- [ ] PASS / FAIL + +--- + +### 8.4 Online Multiplayer (If Available) + +**What to do:** If a test server is accessible, join an online session. + +**Expected result:** Script behaves identically to single-player. Multiplayer physics loop runs the same CSP pipeline. + +- [ ] PASS / FAIL / SKIPPED (no server access) + +--- + +## 9. AC Settings Validation Table + +If behavior seems wrong, check these AC settings before attributing the issue to the script. + +| Setting | Wrong Value | Symptom | Correct Value | +|---|---|---|---| +| Steering Gamma | > 1.0 | Steering feels exponential on top of script gamma (double-curved) | 1.0 | +| Steering Deadzone | > 0 | Dead center zone feels larger than configured CFG.DEADZONE | 0 | +| Steering Filter | > 0 | Steering feels over-smoothed or laggy; smooth added twice | 0 | +| Speed Sensitivity | > 0 | Speed scaling effect is stronger than expected (AC and script both reduce at speed) | 0 | + +--- + +*Testing plan for AC-Gamepad-FX v1.0. All tests executable by one person with one Xbox controller. Tests marked NOT TESTABLE require hardware with per-trigger rumble motors.* diff --git a/Troubleshooting.md b/Troubleshooting.md new file mode 100644 index 0000000..49a23bb --- /dev/null +++ b/Troubleshooting.md @@ -0,0 +1,85 @@ +# Troubleshooting — AC Gamepad FX + +--- + +**Q: Steering feels twitchy or spikey — small inputs cause large jerky responses** + +AC's Steering Gamma is not at 100%. When AC applies its gamma curve and the script applies its own, you get gamma applied twice. The result is an overly aggressive exponential curve that makes center inputs feel violent. + +Fix: Content Manager → Settings → Assetto Corsa → Controls → **Steering Gamma = 100%** + +--- + +**Q: There's a large dead zone at center — the car barely responds to small stick inputs** + +AC's Steering Deadzone is not at 0%. Double deadzone means the stick has to travel further before anything happens, killing precision. + +Fix: Content Manager → Settings → Assetto Corsa → Controls → **Steering Deadzone = 0%** + +--- + +**Q: Steering feels delayed or laggy** + +AC's Steering Filter is not at 0%. The script applies its own frame-rate-independent smoothing (`STEER_SMOOTH`). Adding AC's filter on top creates compounded lag. + +Fix: Content Manager → Settings → Assetto Corsa → Controls → **Steering Filter = 0%** + +--- + +**Q: Steering authority changes erratically with speed in a way that feels wrong** + +AC's Speed Sensitivity is not at 0%. Double speed scaling produces non-linear, unexpected behavior through the speed range. + +Fix: Content Manager → Settings → Assetto Corsa → Controls → **Speed Sensitivity = 0%** + +--- + +**Q: Can the script auto-fix these AC settings for me?** + +No. CSP's Lua sandbox runs without filesystem access — the script cannot read or write `Documents\Assetto Corsa\cfg\controls.ini`. There is no API to query or modify AC's control settings from inside a Gamepad FX script. This is a deliberate CSP design decision, not a missing feature that will be added later. The settings must be configured manually. + +--- + +**Q: The script doesn't seem to be running at all — steering is completely unaffected** + +Check these in order: + +1. **CSP active?** Content Manager → Settings → Custom Shaders Patch — confirm "Custom Shaders Patch" is enabled and on v0.2.0 or later. +2. **Gamepad FX active?** Content Manager → Settings → Custom Shaders Patch → Gamepad FX — confirm "Active" is checked and "MyGamepadFX" is selected in the dropdown. +3. **Script loaded?** Open the CM log (View → Show Log). After loading a race, you should see `[MyGamepadFX] Axis map —` within the first few lines. If you see `[MyGamepadFX] ERROR:`, read what it says — the message includes where to look. +4. **Files in place?** Confirm the `MyGamepadFX/` folder is at `assettocorsa/extension/lua/joypad-assist/MyGamepadFX/` and contains `manifest.ini`, `config.lua`, `lib.lua`, `assist.lua`, `debug.lua`. + +--- + +**Q: Car drifts at center without touching the stick** + +Controller stick drift — the physical axis isn't returning to a true zero. Raise `DEADZONE` in `config.lua` until drift stops. Start at `0.10`, go to `0.12` if needed. Don't go above `0.15` or small corrections become imprecise. + +--- + +**Q: Car oscillates back and forth (tank-slapper effect)** + +`COUNTERSTEER_DAMP` is too low relative to `COUNTERSTEER_GAIN`. The self-steer system overcorrects and then overcorrects the overcorrection. + +Fix: raise `COUNTERSTEER_DAMP` in `config.lua`. Rule: DAMP must stay at or above `COUNTERSTEER_GAIN × 0.6`. If GAIN is `0.45`, DAMP must be at least `0.27`. Raise DAMP before lowering GAIN. + +--- + +**Q: Works fine on Xbox controller but feels wrong on PlayStation / generic USB gamepad** + +Axis assignments are not guaranteed across controller types. This script assumes: +- `axes[1]` = left stick X (steering) +- `axes[3]` = right trigger (throttle) +- `axes[4]` = left trigger (brake) + +On some controllers or with Steam Input enabled, these assignments may differ. Check the CM log after loading a race — look for the `[MyGamepadFX] Axis map —` line. It shows values for axes 0, 1, 3, and 4 at rest. Move each input and reload to see which axis changes. If `axes[1]` doesn't change when you move the left stick, the axis mapping on your controller is different from the default assumption. + +There is currently no in-config way to remap axes — you would need to change the axis index in `assist.lua` directly. + +--- + +**Q: I enabled haptics (`HAPTICS_ENABLED = true`) but the controller doesn't vibrate** + +Trigger rumble (`ac.setTriggerRumble`) requires CSP v0.2.0 or later and a controller that supports trigger motors. Standard Xbox One controllers do not have trigger motors; Xbox Series X|S controllers do. Generic USB controllers typically do not. If your controller doesn't support it, the call is a no-op — no error, no vibration. + +Also confirm the car is actually generating front wheel slip. Haptics only fire above `HAPTICS_SLIP_START` (default 0.3). In normal driving on a dry track, you may not reach this threshold. Push harder through corners or induce oversteer deliberately to test it. diff --git a/VALIDATION.md b/VALIDATION.md new file mode 100644 index 0000000..d6adde3 --- /dev/null +++ b/VALIDATION.md @@ -0,0 +1,147 @@ +# VALIDATION.md — AC-Gamepad-FX v1.0 Code Validation Report + +Static code review of the v1.0 Lua files. Covers API compatibility, entry point signatures, state management, math formula correctness, edge case guards, and breaking changes. + +**Verdict: PASS with warnings. No critical issues. Safe to ship.** + +--- + +## 1. API Compatibility (CSP v0.2.0+) + +| Call | Availability | Notes | +|---|---|---| +| `ac.getCar(0)` | All CSP versions | Returns nil for 1–2 frames on load — guarded | +| `ac.getGamepad(0)` | All CSP versions | Returns nil if no gamepad — guarded | +| `ac.setSteer(v)` | All CSP versions | Range [-1, 1], clamped before call | +| `ac.setGas(v)` | All CSP versions | Range [0, 1], clamped before call | +| `ac.setBrake(v)` | All CSP versions | Range [0, 1], clamped before call | +| `ac.setTriggerRumble(l, r)` | CSP v0.2.0+ | Behind `HAPTICS_ENABLED = false` by default | +| `ac.log(msg)` | All CSP versions | Diagnostics and errors | +| `ac.debug(label, val)` | All CSP versions | Debug overlay only | +| `math.clamp(v, lo, hi)` | CSP extension | Not in vanilla LuaJIT; used correctly | +| `string.format(fmt, ...)` | Standard Lua | Used throughout for log formatting | + +No calls that require CSP above v0.2.0 in the default-enabled code path. `ac.setTriggerRumble` (v0.2.0+) is behind `HAPTICS_ENABLED = false`. ✅ + +--- + +## 2. Entry Point Signatures + +```lua +function script.update(dt) -- ✅ correct CSP pattern +function script.reset() -- ✅ correct CSP pattern +``` + +`script.*` form only — no global-namespace aliases. ✅ + +--- + +## 3. State Management + +| Variable | Cleared by `script.reset()` | Intention | +|---|---|---| +| `steerOut` | ✅ Yes — set to 0.0 | Prevents jerk at session start | +| `frameCount` | ✅ Yes — set to 0 | Allows nil-car warning to re-fire if car never loads in new session | +| `firstFrameDiagnostics` | ❌ No — intentional | Axis log fires once per game launch, not per session | +| `lib._logged` | ❌ No — intentional | logOnce messages persist for the game lifetime; prevents log spam across sessions | + +`lib._logged` is module-level state. After a session reset, error messages from the previous session cannot re-fire even if the same condition recurs. If the user wants a fresh log, they must restart AC. **KNOWN/EXPECTED** ⚠️ + +--- + +## 4. Math Formula Verification + +### expSmooth +```lua +local alpha = 1.0 - (1.0 - smooth) ^ (dt * 60.0) +``` +Standard exponential decay normalized to 60 fps. At `dt = 1/60`, `alpha = smooth`. Identical feel at any frame rate. ✅ + +### speedScale +```lua +local t = math.clamp((speedKmh - START) / (END - START), 0, 1) +return lerp(1.0, SPEED_SCALE_MIN, t) +``` +At START → returns 1.0. At END → returns SPEED_SCALE_MIN. Clamped outside range. ✅ + +### selfSteer +```lua +selfSteer = -avgFrontSlip * GAIN - car.steer * DAMP +``` +Sign convention correct: positive slip → negative selfSteer (opposes outward slide). Damping opposes current angle. ✅ + +### slipFactor +```lua +slipFactor = clamp((avgFrontSlip - START) / RANGE, 0, 1) +steerLimit = lerp(1.0, SLIP_LIMIT_MIN, slipFactor) +``` +Below START → no restriction. At START + RANGE → full reduction. No out-of-range values. ✅ + +### Haptics +```lua +slipIntensity = clamp((slip - HAPTICS_SLIP_START) / (HAPTICS_SLIP_MAX - HAPTICS_SLIP_START), 0, 1) +ac.setTriggerRumble(0, slipIntensity * HAPTICS_STRENGTH) +``` +Final value in [0, 1] before passing to API. ✅ + +### Gas/brake curves +```lua +lib.applyGamma(lib.applyDeadzone(axes[N] or 0.0, DEADZONE), GAMMA) +``` +Deadzone on raw input first, gamma on scaled result. Correct order. ✅ + +--- + +## 5. Edge Case Guards + +| Condition | Guard | +|---|---| +| `car` nil on first 1–2 frames | frameCount grace period; silent for ≤ 2 frames, warns after | +| `gamepad` nil | logOnce error, return | +| `dt <= 0` | `if dt <= 0 or dt > 0.1 then return end` | +| `dt > 0.1` (pause, alt-tab) | same guard — discards inflated-dt frame | +| Nil axis values | `axes[N] or 0.0` fallbacks on every read | + +All guards verified. ✅ + +--- + +## 6. Warnings (Not Blockers) + +**W1 — logOnce keys persist across sessions.** After `script.reset()`, `lib._logged` is not cleared. Errors that fired once are silenced in subsequent sessions until AC restarts. Expected behavior — user has already seen the message. + +**W2 — `ac.setTriggerRumble` on non-supported controllers.** Behavior is undefined on controllers without per-trigger rumble motors (standard Xbox One, most generics). `HAPTICS_ENABLED = false` by default eliminates this risk unless the user opts in. If it throws on unsupported hardware, wrapping in `pcall` is the fix. **ASSUMPTION — needs real-world testing.** + +**W3 — Axis assignments assumed, not probed.** `axes[1] = steering` is standard Xbox layout. The startup diagnostic logs values but does not auto-detect wrong assignments. Documented in Troubleshooting.md. + +**W4 — car_nil can only log once regardless of session.** If car is nil on session restart (after `script.reset()`), the warning attempt is silenced by `lib._logged`. Safe, but the second occurrence is invisible in the log. + +--- + +## 7. Breaking Changes from Previous Version + +| Change | Impact | +|---|---| +| Throttle/brake now processed (was raw passthrough) | Slight feel change. Full trigger still = 100% — only < 1% inputs affected. | +| `config.lua` has 9 new required parameters | **Old config.lua will break.** Users who customized values must copy them into the new template. | +| `script.reset()` now resets `frameCount` | Additive — no external API impact. | +| `lib.lua` has `logOnce` and `_logged` | Additive — no existing signatures changed. | +| `debug.lua` draws 11 fields (was 9) | Additive — only affects debug overlay users. | + +**config.lua is the only file that requires user action on upgrade.** Tuned values from the old config must be manually transferred to the new template. + +--- + +## 8. Verdict + +| Category | Result | +|---|---| +| API compatibility | ✅ PASS | +| Entry point signatures | ✅ PASS | +| State management | ✅ PASS (W1, W4 known/expected) | +| Math formulas | ✅ PASS | +| Edge case guards | ✅ PASS | +| Breaking changes | ⚠️ config.lua requires user replacement on upgrade | +| Haptics compatibility | ⚠️ ASSUMPTION — needs hardware testing | + +**Overall: PASS. Safe to ship.**