Compare commits

...

10 Commits

Author SHA1 Message Date
81bb610459 Add test file
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:48:19 +01:00
58df608550 Fix penetration/ricochet bugs: division by zero, edge cases, incidence angle
P0 - Division by zero fixes:
- Clamp PhysMaterial->Density to min 0.001 before division
- Clamp MuzzleVelocity averages to min 1.0 in all divisions
- Clamp PhysMaterial->Restitution to [0, 1]

P1 - Edge case guards:
- Stop bullet immediately when velocity is near-zero (prevents NaN)
- Handle near-zero cross product at very shallow grazing angles
- Handle zero-length bounceAngle in ricochet calculation

P2 - Improvements:
- Fix typo: BlockTIme -> BlockTime
- Add incidence angle factor to penetration depth: grazing shots
  penetrate less (5% at ~5deg) while head-on shots get full depth

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 19:29:45 +01:00
ba6b35b3d9 binaries + python 2026-03-18 19:08:24 +01:00
1a5b107b1f Remove obsolete features: calibration, IMU shock sim, quadratic regression, HUD, aim stabilization
Remove ~920 lines of dead code:
- Calibration system (replaced by Python analyze_shots.py)
- IMU shock simulation (no longer needed for testing)
- Debug HUD overlay (values are in CSV logs instead)
- Debug line thickness property (fixed to 0)
- Quadratic regression anti-recoil mode (linear regression sufficient)
- AdaptiveMinSpeed property (optimized to 0, not useful)
- AimStabilization dead zone (smoothing done in Blueprint instead)

Remaining anti-recoil modes: Buffer, LinearExtrapolation,
WeightedLinearRegression, KalmanFilter, AdaptiveExtrapolation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 18:47:12 +01:00
cd097e4e55 Optimize adaptive extrapolation defaults from real-world test data
- Update defaults from test-driven optimization:
  BufferTime=200ms, DiscardTime=30ms, Sensitivity=3.0,
  DeadZone=0.95, MinSpeed=0.0, Damping=5.0
- Add ShotFired column to CSV recording for contamination analysis
- Rewrite Python optimizer with 6-parameter search (sensitivity,
  dead zone, min speed, damping, buffer time, discard time)
- Fix velocity weighting order bug in Python simulation
- Add dead zone, min speed threshold, and damping to Python sim
- Add shot contamination analysis (analyze_shots.py) to measure
  exact IMU perturbation duration per shot
- Support multi-file optimization with mean/worst_case strategies
- Add jitter and overshoot scoring metrics

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 18:33:14 +01:00
4e9c33778c binaries 2026-03-17 20:00:12 +01:00
48737b60c9 Tune adaptive extrapolation defaults, add AdaptiveMinSpeed property, fix debug visuals
- Add AdaptiveMinSpeed UPROPERTY (default 30 cm/s) to avoid false deceleration at low speeds
- Update default values: BufferTime=300ms, DiscardTime=40ms, Sensitivity=1.5, Damping=8.0
- Replace debug spheres with points to not obstruct aiming view
- Add detailed debug logs with [LOW]/[DZ]/[DEC] tags for dead zone diagnosis
- Convert buffer/discard time units to milliseconds
- Set AdaptiveExtrapolation as default AntiRecoil mode
- Fix DLL copy error handling in DinkeyPlugin and ViveVBS build scripts
- Add AimStabilization dead zone with smooth transition (no hard jumps)
- Add AimSmoothingSpeed property for temporal aim smoothing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 19:50:39 +01:00
83188b1fa1 no message 2026-03-16 18:27:00 +01:00
af723c944b Rework adaptive extrapolation: deceleration detection + dead zone + debug HUD
Replace variance-based confidence (caused constant lag) with targeted
deceleration detection: compares recent speed (last 25% of safe window)
to average speed. During steady movement ratio≈1 → zero lag.
Only reduces extrapolation when actual braking is detected.

- AdaptiveSensitivity: now a power exponent (0.1-5.0, default 1.0)
- AdaptiveDeadZone: new parameter (default 0.8) to ignore normal
  speed fluctuations and only react to real deceleration
- DebugAntiRecoilHUD: real-time display of ratio, confidence, speeds, errors
- EndPlay: auto-close CSV file when stopping play (no more locked files)
- Python script updated to match new deceleration-based algorithm

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 18:25:54 +01:00
fa257fb87b Add adaptive extrapolation mode, quadratic regression, CSV analysis tool
Anti-recoil prediction improvements:
- New ARM_AdaptiveExtrapolation mode: velocity variance-based confidence
  with separate pos/aim tracking and low-speed protection
- New ARM_WeightedLinearRegression mode: preserves original simple linear fit
- ARM_WeightedRegression upgraded to quadratic (y=a+bt+ct²) with
  linear/quadratic blend and velocity-reversal clamping
- ExtrapolationDamping parameter (all modes): exp decay on extrapolated velocity
- CSV recording (RecordPredictionCSV) for offline parameter tuning
- Python analysis tool (Tools/analyze_antirecoil.py) to find optimal
  AdaptiveSensitivity from recorded data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:21:50 +01:00
21 changed files with 1624 additions and 152 deletions

View File

@@ -4,7 +4,30 @@
"Bash(find /c/ASTERION/GIT/PS_Ballistics/Unreal -name *.bat -o -name Generate*.sh -o -name *Generate*)", "Bash(find /c/ASTERION/GIT/PS_Ballistics/Unreal -name *.bat -o -name Generate*.sh -o -name *Generate*)",
"Bash(\"C:\\\\Program Files\\\\Epic Games\\\\UE_5.5\\\\Engine\\\\Build\\\\BatchFiles\\\\Build.bat\" PS_BallisticsEditor Win64 Development -Project=\"C:\\\\ASTERION\\\\GIT\\\\PS_Ballistics\\\\Unreal\\\\PS_Ballistics.uproject\" -WaitMutex -FromMsBuild)", "Bash(\"C:\\\\Program Files\\\\Epic Games\\\\UE_5.5\\\\Engine\\\\Build\\\\BatchFiles\\\\Build.bat\" PS_BallisticsEditor Win64 Development -Project=\"C:\\\\ASTERION\\\\GIT\\\\PS_Ballistics\\\\Unreal\\\\PS_Ballistics.uproject\" -WaitMutex -FromMsBuild)",
"Bash(\"C:\\\\Program Files\\\\Epic Games\\\\UE_5.5\\\\Engine\\\\Build\\\\BatchFiles\\\\Build.bat\" PS_BallisticsEditor Win64 Development -Project=\"C:\\\\ASTERION\\\\GIT\\\\PS_Ballistics\\\\Unreal\\\\PS_Ballistics.uproject\" -WaitMutex -FromMsBuild -NoLiveCoding)", "Bash(\"C:\\\\Program Files\\\\Epic Games\\\\UE_5.5\\\\Engine\\\\Build\\\\BatchFiles\\\\Build.bat\" PS_BallisticsEditor Win64 Development -Project=\"C:\\\\ASTERION\\\\GIT\\\\PS_Ballistics\\\\Unreal\\\\PS_Ballistics.uproject\" -WaitMutex -FromMsBuild -NoLiveCoding)",
"Bash(\"C:\\\\Program Files\\\\Epic Games\\\\UE_5.5\\\\Engine\\\\Build\\\\BatchFiles\\\\Build.bat\" PS_BallisticsEditor Win64 Development -Project=\"C:\\\\ASTERION\\\\GIT\\\\PS_Ballistics\\\\Unreal\\\\PS_Ballistics.uproject\" -NoLiveCoding)" "Bash(\"C:\\\\Program Files\\\\Epic Games\\\\UE_5.5\\\\Engine\\\\Build\\\\BatchFiles\\\\Build.bat\" PS_BallisticsEditor Win64 Development -Project=\"C:\\\\ASTERION\\\\GIT\\\\PS_Ballistics\\\\Unreal\\\\PS_Ballistics.uproject\" -NoLiveCoding)",
"Bash(grep -l \"Shoot\\\\|ClientAim\\\\|ShootRep\" \"E:\\\\ASTERION\\\\GIT\\\\PS_Ballistics\\\\Unreal\\\\Plugins\\\\PS_Ballistics\\\\Source\\\\EasyBallistics\\\\Private\"/*.cpp)",
"Bash(xargs grep:*)",
"Bash(ls Source/EasyBallistics/Private/*.cpp Source/EasyBallistics/Public/*.h)",
"Bash(powershell.exe -Command \"& ''''C:\\\\Program Files\\\\Epic Games\\\\UE_5.5\\\\Engine\\\\Build\\\\BatchFiles\\\\RunUAT.bat'''' BuildEditor -project=''''E:\\\\ASTERION\\\\GIT\\\\PS_Ballistics\\\\Unreal\\\\PS_Ballistics.uproject'''' -notools -noP4 2>&1\")",
"Bash(python \"E:\\\\ASTERION\\\\GIT\\\\PS_Ballistics\\\\Tools\\\\analyze_antirecoil.py\" \"E:\\\\ASTERION\\\\SVN\\\\DEV\\\\PROSERVE_UE_5_5\\\\Saved\\\\Logs\\\\AntiRecoil_20260316_150326.csv\")",
"Bash(python \"Tools\\\\analyze_antirecoil.py\" \"E:\\\\ASTERION\\\\SVN\\\\DEV\\\\PROSERVE_UE_5_5\\\\Saved\\\\Logs\\\\AntiRecoil_20260316_150326.csv\")",
"Bash(python \"Tools\\\\analyze_antirecoil.py\" \"E:\\\\ASTERION\\\\SVN\\\\DEV\\\\PROSERVE_UE_5_5\\\\Saved\\\\Logs\\\\AntiRecoil_20260316_153607.csv\")",
"Bash(python \"Tools\\\\analyze_antirecoil.py\" \"E:\\\\ASTERION\\\\SVN\\\\DEV\\\\PROSERVE_UE_5_5\\\\Saved\\\\Logs\\\\AntiRecoil_20260316_160323.csv\")",
"Bash(python \"Tools\\\\analyze_antirecoil.py\" \"E:\\\\ASTERION\\\\SVN\\\\DEV\\\\PROSERVE_UE_5_5\\\\Saved\\\\Logs\\\\AntiRecoil_20260316_164341.csv\")",
"Bash(python \"Tools\\\\analyze_antirecoil.py\" \"E:\\\\ASTERION\\\\SVN\\\\DEV\\\\PROSERVE_UE_5_5\\\\Saved\\\\Logs\\\\AntiRecoil_20260316_170543.csv\")",
"Bash(find C:ASTERIONSVNDEVPROSERVE_UE_5_5Plugins -type f \\\\\\(-name *.cpp -o -name *.h \\\\\\))",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(find E:/ASTERION/SVN/DEV/PROSERVE_UE_5_5/Saved -name AntiRecoil* -type f)",
"Bash(python analyze_antirecoil.py \"C:/ASTERION/SVN/DEV/PROSERVE_UE_5_5/Saved/Logs/AntiRecoil_20260318_140946.csv\" --grid)",
"Bash(python analyze_antirecoil.py \"C:/ASTERION/SVN/DEV/PROSERVE_UE_5_5/Saved/Logs/AntiRecoil_20260318_140946.csv\" \"C:/ASTERION/SVN/DEV/PROSERVE_UE_5_5/Saved/Logs/AntiRecoil_20260318_141329.csv\")",
"Bash(python analyze_antirecoil.py \"C:/ASTERION/SVN/DEV/PROSERVE_UE_5_5/Saved/Logs/AntiRecoil_20260318_140946.csv\" \"C:/ASTERION/SVN/DEV/PROSERVE_UE_5_5/Saved/Logs/AntiRecoil_20260318_141329.csv\" --grid)",
"Bash(python analyze_antirecoil.py \"C:/ASTERION/SVN/DEV/PROSERVE_UE_5_5/Saved/Logs/AntiRecoil_20260318_140946.csv\" \"C:/ASTERION/SVN/DEV/PROSERVE_UE_5_5/Saved/Logs/AntiRecoil_20260318_141329.csv\" --grid --strategy worst_case)",
"Bash(python analyze_antirecoil.py \"C:/ASTERION/SVN/DEV/PROSERVE_UE_5_5/Saved/Logs/AntiRecoil_20260318_162726.csv\" \"C:/ASTERION/SVN/DEV/PROSERVE_UE_5_5/Saved/Logs/AntiRecoil_20260318_162234.csv\" --grid --strategy worst_case)",
"Bash(python analyze_shots.py \"C:/ASTERION/SVN/DEV/PROSERVE_UE_5_5/Saved/Logs/AntiRecoil_20260318_162726.csv\")",
"Bash(python analyze_shots.py \"C:/ASTERION/SVN/DEV/PROSERVE_UE_5_5/Saved/Logs/AntiRecoil_20260318_163533.csv\")",
"Bash(python analyze_shots.py \"C:/ASTERION/SVN/DEV/PROSERVE_UE_5_5/Saved/Logs/AntiRecoil_20260318_181404.csv\")",
"Bash(python -c \":*)"
] ]
} }
} }

Binary file not shown.

Binary file not shown.

779
Tools/analyze_antirecoil.py Normal file
View File

@@ -0,0 +1,779 @@
"""
Anti-Recoil Parameter Optimizer
================================
Reads CSV files recorded by the EBBarrel CSV recording feature and finds
optimal parameters for the Adaptive Extrapolation mode.
Usage:
python analyze_antirecoil.py <csv_file> [csv_file2 ...] [options]
Options:
--plot Generate comparison plots (requires matplotlib)
--grid Use grid search instead of differential evolution
--strategy <s> Multi-file aggregation: mean (default), worst_case
--max-iter <n> Max optimizer iterations (default: 200)
The script:
1. Loads per-frame data (real position/aim vs predicted position/aim)
2. Simulates adaptive extrapolation offline (matching C++ exactly)
3. Optimizes all 4 parameters: Sensitivity, DeadZone, MinSpeed, Damping
4. Reports recommended parameters with per-file breakdown
"""
import csv
import sys
import math
import os
import argparse
from dataclasses import dataclass
from typing import List, Tuple, Optional
@dataclass
class Frame:
timestamp: float
real_pos: Tuple[float, float, float]
real_aim: Tuple[float, float, float]
pred_pos: Tuple[float, float, float]
pred_aim: Tuple[float, float, float]
safe_count: int
buffer_count: int
extrap_time: float
shot_fired: bool = False
@dataclass
class AdaptiveParams:
sensitivity: float = 3.0
dead_zone: float = 0.95
min_speed: float = 0.0
damping: float = 5.0
buffer_time_ms: float = 200.0
discard_time_ms: float = 30.0
@dataclass
class ScoreResult:
pos_mean: float
pos_p95: float
aim_mean: float
aim_p95: float
jitter: float
overshoot: float
score: float
def load_csv(path: str) -> List[Frame]:
frames = []
with open(path, 'r') as f:
reader = csv.DictReader(f)
has_shot_col = False
for row in reader:
if not has_shot_col and 'ShotFired' in row:
has_shot_col = True
frames.append(Frame(
timestamp=float(row['Timestamp']),
real_pos=(float(row['RealPosX']), float(row['RealPosY']), float(row['RealPosZ'])),
real_aim=(float(row['RealAimX']), float(row['RealAimY']), float(row['RealAimZ'])),
pred_pos=(float(row['PredPosX']), float(row['PredPosY']), float(row['PredPosZ'])),
pred_aim=(float(row['PredAimX']), float(row['PredAimY']), float(row['PredAimZ'])),
safe_count=int(row['SafeCount']),
buffer_count=int(row['BufferCount']),
extrap_time=float(row['ExtrapolationTime']),
shot_fired=int(row.get('ShotFired', 0)) == 1,
))
return frames
# --- Vector math helpers ---
def vec_dist(a, b):
return math.sqrt(sum((ai - bi) ** 2 for ai, bi in zip(a, b)))
def vec_sub(a, b):
return tuple(ai - bi for ai, bi in zip(a, b))
def vec_add(a, b):
return tuple(ai + bi for ai, bi in zip(a, b))
def vec_scale(a, s):
return tuple(ai * s for ai in a)
def vec_len(a):
return math.sqrt(sum(ai * ai for ai in a))
def vec_normalize(a):
l = vec_len(a)
if l < 1e-10:
return (0, 0, 0)
return tuple(ai / l for ai in a)
def angle_between(a, b):
"""Angle in degrees between two direction vectors."""
dot = sum(ai * bi for ai, bi in zip(a, b))
dot = max(-1.0, min(1.0, dot))
return math.degrees(math.acos(dot))
# --- Prediction error from recorded data ---
def compute_prediction_error(frames: List[Frame]) -> dict:
"""Compute error between predicted and actual (real) positions/aims."""
pos_errors = []
aim_errors = []
for f in frames:
pos_err = vec_dist(f.pred_pos, f.real_pos)
pos_errors.append(pos_err)
aim_a = vec_normalize(f.pred_aim)
aim_b = vec_normalize(f.real_aim)
if vec_len(aim_a) > 0.5 and vec_len(aim_b) > 0.5:
aim_err = angle_between(aim_a, aim_b)
aim_errors.append(aim_err)
if not pos_errors:
return {'pos_mean': 0, 'pos_p95': 0, 'pos_max': 0, 'aim_mean': 0, 'aim_p95': 0, 'aim_max': 0}
pos_errors.sort()
aim_errors.sort()
p95_idx_pos = int(len(pos_errors) * 0.95)
p95_idx_aim = int(len(aim_errors) * 0.95) if aim_errors else 0
return {
'pos_mean': sum(pos_errors) / len(pos_errors),
'pos_p95': pos_errors[min(p95_idx_pos, len(pos_errors) - 1)],
'pos_max': pos_errors[-1],
'aim_mean': sum(aim_errors) / len(aim_errors) if aim_errors else 0,
'aim_p95': aim_errors[min(p95_idx_aim, len(aim_errors) - 1)] if aim_errors else 0,
'aim_max': aim_errors[-1] if aim_errors else 0,
}
# --- Shot contamination analysis ---
def analyze_shot_contamination(frames: List[Frame], analysis_window_ms: float = 200.0):
"""
Analyze how shots contaminate the tracking data.
For each shot, measure the velocity/acceleration spike and how long it takes
to return to baseline. This tells us the minimum discard_time needed.
Returns a dict with analysis results, or None if no shots found.
"""
shot_indices = [i for i, f in enumerate(frames) if f.shot_fired]
if not shot_indices:
return None
analysis_window_s = analysis_window_ms / 1000.0
# Compute per-frame speeds
speeds = [0.0]
for i in range(1, len(frames)):
dt = frames[i].timestamp - frames[i - 1].timestamp
if dt > 1e-6:
d = vec_dist(frames[i].real_pos, frames[i - 1].real_pos)
speeds.append(d / dt)
else:
speeds.append(speeds[-1] if speeds else 0.0)
# For each shot, measure the speed profile before and after
contamination_durations = []
speed_spikes = []
for si in shot_indices:
# Baseline speed: average speed in 100ms BEFORE the shot
baseline_speeds = []
for j in range(si - 1, -1, -1):
if frames[si].timestamp - frames[j].timestamp > 0.1:
break
baseline_speeds.append(speeds[j])
if not baseline_speeds:
continue
baseline_mean = sum(baseline_speeds) / len(baseline_speeds)
baseline_std = math.sqrt(sum((s - baseline_mean) ** 2 for s in baseline_speeds) / len(baseline_speeds)) if len(baseline_speeds) > 1 else baseline_mean * 0.1
# Threshold: speed is "contaminated" if it deviates by more than 3 sigma from baseline
threshold = baseline_mean + max(3.0 * baseline_std, 10.0) # at least 10 cm/s spike
# Find how long after the shot the speed stays above threshold
max_speed = 0.0
last_contaminated_time = 0.0
for j in range(si, len(frames)):
dt_from_shot = frames[j].timestamp - frames[si].timestamp
if dt_from_shot > analysis_window_s:
break
if speeds[j] > threshold:
last_contaminated_time = dt_from_shot
if speeds[j] > max_speed:
max_speed = speeds[j]
contamination_durations.append(last_contaminated_time * 1000.0) # in ms
speed_spikes.append(max_speed - baseline_mean)
if not contamination_durations:
return None
contamination_durations.sort()
return {
'num_shots': len(shot_indices),
'contamination_mean_ms': sum(contamination_durations) / len(contamination_durations),
'contamination_p95_ms': contamination_durations[int(len(contamination_durations) * 0.95)],
'contamination_max_ms': contamination_durations[-1],
'speed_spike_mean': sum(speed_spikes) / len(speed_spikes) if speed_spikes else 0,
'speed_spike_max': max(speed_spikes) if speed_spikes else 0,
'recommended_discard_ms': math.ceil(contamination_durations[int(len(contamination_durations) * 0.95)] / 5.0) * 5.0, # round up to 5ms
}
# --- Offline adaptive extrapolation simulation (matches C++ exactly) ---
def simulate_adaptive(frames: List[Frame], params: AdaptiveParams) -> Tuple[List[float], List[float]]:
"""
Simulate the adaptive extrapolation offline with given parameters.
Matches the C++ PredictAdaptiveExtrapolation algorithm exactly.
Optimized for speed: pre-extracts arrays, inlines math, avoids allocations.
"""
pos_errors = []
aim_errors = []
n_frames = len(frames)
if n_frames < 4:
return pos_errors, aim_errors
# Pre-extract into flat arrays for speed
ts = [f.timestamp for f in frames]
px = [f.real_pos[0] for f in frames]
py = [f.real_pos[1] for f in frames]
pz = [f.real_pos[2] for f in frames]
ax = [f.real_aim[0] for f in frames]
ay = [f.real_aim[1] for f in frames]
az = [f.real_aim[2] for f in frames]
buffer_s = params.buffer_time_ms / 1000.0
discard_s = params.discard_time_ms / 1000.0
sensitivity = params.sensitivity
dead_zone = params.dead_zone
min_speed = params.min_speed
damping = params.damping
SMALL = 1e-10
_sqrt = math.sqrt
_exp = math.exp
_acos = math.acos
_degrees = math.degrees
_pow = pow
for i in range(2, n_frames - 1):
ct = ts[i]
safe_cutoff = ct - discard_s
oldest_allowed = ct - buffer_s
# Collect safe sample indices (backward scan, then reverse)
safe = []
for j in range(i, -1, -1):
t = ts[j]
if t < oldest_allowed:
break
if t <= safe_cutoff:
safe.append(j)
safe.reverse()
ns = len(safe)
if ns < 2:
continue
# Build velocity pairs inline
vpx = []; vpy = []; vpz = []
vax = []; vay = []; vaz = []
for k in range(1, ns):
p, c = safe[k - 1], safe[k]
dt = ts[c] - ts[p]
if dt > 1e-6:
inv_dt = 1.0 / dt
vpx.append((px[c] - px[p]) * inv_dt)
vpy.append((py[c] - py[p]) * inv_dt)
vpz.append((pz[c] - pz[p]) * inv_dt)
vax.append((ax[c] - ax[p]) * inv_dt)
vay.append((ay[c] - ay[p]) * inv_dt)
vaz.append((az[c] - az[p]) * inv_dt)
nv = len(vpx)
if nv < 2:
continue
# Weighted average velocity (quadratic weights, oldest=index 0)
tw = 0.0
apx = apy = apz = 0.0
aax = aay = aaz = 0.0
for k in range(nv):
w = (k + 1) * (k + 1)
apx += vpx[k] * w; apy += vpy[k] * w; apz += vpz[k] * w
aax += vax[k] * w; aay += vay[k] * w; aaz += vaz[k] * w
tw += w
inv_tw = 1.0 / tw
apx *= inv_tw; apy *= inv_tw; apz *= inv_tw
aax *= inv_tw; aay *= inv_tw; aaz *= inv_tw
# Recent velocity (last 25%, unweighted)
rs = max(0, nv - max(1, nv // 4))
rc = nv - rs
rpx = rpy = rpz = 0.0
rax = ray = raz = 0.0
for k in range(rs, nv):
rpx += vpx[k]; rpy += vpy[k]; rpz += vpz[k]
rax += vax[k]; ray += vay[k]; raz += vaz[k]
inv_rc = 1.0 / rc
rpx *= inv_rc; rpy *= inv_rc; rpz *= inv_rc
rax *= inv_rc; ray *= inv_rc; raz *= inv_rc
avg_ps = _sqrt(apx*apx + apy*apy + apz*apz)
avg_as = _sqrt(aax*aax + aay*aay + aaz*aaz)
rec_ps = _sqrt(rpx*rpx + rpy*rpy + rpz*rpz)
rec_as = _sqrt(rax*rax + ray*ray + raz*raz)
# Position confidence
pc = 1.0
if avg_ps > min_speed:
ratio = rec_ps / avg_ps
if ratio > 1.0: ratio = 1.0
if ratio < dead_zone:
rm = ratio / dead_zone if dead_zone > SMALL else 0.0
if rm > 1.0: rm = 1.0
pc = _pow(rm, sensitivity)
# Aim confidence
ac = 1.0
if avg_as > min_speed:
ratio = rec_as / avg_as
if ratio > 1.0: ratio = 1.0
if ratio < dead_zone:
rm = ratio / dead_zone if dead_zone > SMALL else 0.0
if rm > 1.0: rm = 1.0
ac = _pow(rm, sensitivity)
# Extrapolation time
lsi = safe[-1]
edt = ct - ts[lsi]
if edt <= 0: edt = 0.011
# Damping
ds = _exp(-damping * edt) if damping > 0.0 else 1.0
# Predict
m = edt * pc * ds
ppx = px[lsi] + apx * m
ppy = py[lsi] + apy * m
ppz = pz[lsi] + apz * m
ma = edt * ac * ds
pax_r = ax[lsi] + aax * ma
pay_r = ay[lsi] + aay * ma
paz_r = az[lsi] + aaz * ma
pa_len = _sqrt(pax_r*pax_r + pay_r*pay_r + paz_r*paz_r)
# Position error
dx = ppx - px[i]; dy = ppy - py[i]; dz = ppz - pz[i]
pos_errors.append(_sqrt(dx*dx + dy*dy + dz*dz))
# Aim error
if pa_len > 0.5:
inv_pa = 1.0 / pa_len
pax_n = pax_r * inv_pa; pay_n = pay_r * inv_pa; paz_n = paz_r * inv_pa
ra_len = _sqrt(ax[i]*ax[i] + ay[i]*ay[i] + az[i]*az[i])
if ra_len > 0.5:
inv_ra = 1.0 / ra_len
dot = pax_n * ax[i] * inv_ra + pay_n * ay[i] * inv_ra + paz_n * az[i] * inv_ra
if dot > 1.0: dot = 1.0
if dot < -1.0: dot = -1.0
aim_errors.append(_degrees(_acos(dot)))
return pos_errors, aim_errors
# --- Scoring ---
def compute_score(pos_errors: List[float], aim_errors: List[float]) -> ScoreResult:
"""Compute a combined score from position and aim errors, including stability metrics."""
if not pos_errors:
return ScoreResult(0, 0, 0, 0, 0, 0, float('inf'))
pos_sorted = sorted(pos_errors)
aim_sorted = sorted(aim_errors) if aim_errors else [0]
pos_mean = sum(pos_errors) / len(pos_errors)
pos_p95 = pos_sorted[int(len(pos_sorted) * 0.95)]
aim_mean = sum(aim_errors) / len(aim_errors) if aim_errors else 0
aim_p95 = aim_sorted[int(len(aim_sorted) * 0.95)] if aim_errors else 0
# Jitter: standard deviation of frame-to-frame error change
jitter = 0.0
if len(pos_errors) > 1:
deltas = [abs(pos_errors[i] - pos_errors[i - 1]) for i in range(1, len(pos_errors))]
delta_mean = sum(deltas) / len(deltas)
jitter = math.sqrt(sum((d - delta_mean) ** 2 for d in deltas) / len(deltas))
# Overshoot: percentage of frames where error spikes above 2x mean
overshoot = 0.0
if pos_mean > 0:
overshoot_count = sum(1 for e in pos_errors if e > 2.0 * pos_mean)
overshoot = overshoot_count / len(pos_errors)
# Combined score
score = (pos_mean * 0.25 + pos_p95 * 0.15 +
aim_mean * 0.25 + aim_p95 * 0.15 +
jitter * 0.10 + overshoot * 0.10)
return ScoreResult(pos_mean, pos_p95, aim_mean, aim_p95, jitter, overshoot, score)
def aggregate_scores(per_file_scores: List[Tuple[str, ScoreResult]],
strategy: str = "mean") -> float:
"""Aggregate scores across multiple files."""
scores = [s.score for _, s in per_file_scores]
if not scores:
return float('inf')
if strategy == "worst_case":
return max(scores)
else: # mean
return sum(scores) / len(scores)
# --- Optimizer ---
def objective(x, all_frames, strategy):
"""Objective function for the optimizer."""
params = AdaptiveParams(
sensitivity=x[0],
dead_zone=x[1],
min_speed=x[2],
damping=x[3],
buffer_time_ms=x[4],
discard_time_ms=x[5]
)
per_file_scores = []
for name, frames in all_frames:
pos_errors, aim_errors = simulate_adaptive(frames, params)
score_result = compute_score(pos_errors, aim_errors)
per_file_scores.append((name, score_result))
return aggregate_scores(per_file_scores, strategy)
def optimize_differential_evolution(all_frames, strategy="mean", max_iter=200, min_discard_ms=10.0):
"""Find optimal parameters using scipy differential evolution."""
try:
from scipy.optimize import differential_evolution
except ImportError:
print("ERROR: scipy is required for optimization.")
print("Install with: pip install scipy")
sys.exit(1)
bounds = [
(0.1, 5.0), # sensitivity
(0.0, 0.95), # dead_zone
(0.0, 200.0), # min_speed
(0.0, 50.0), # damping
(100.0, 500.0), # buffer_time_ms
(max(10.0, min_discard_ms), 100.0), # discard_time_ms (floor from contamination analysis)
]
print(f"\nRunning differential evolution (maxiter={max_iter}, popsize=25, min_discard={min_discard_ms:.0f}ms)...")
print("This may take a few minutes...\n")
result = differential_evolution(
objective,
bounds,
args=(all_frames, strategy),
maxiter=max_iter,
seed=42,
tol=1e-4,
popsize=25,
disp=True,
workers=1
)
best_params = AdaptiveParams(
sensitivity=round(result.x[0], 2),
dead_zone=round(result.x[1], 3),
min_speed=round(result.x[2], 1),
damping=round(result.x[3], 1),
buffer_time_ms=round(result.x[4], 0),
discard_time_ms=round(result.x[5], 0)
)
return best_params, result.fun
def optimize_grid_search(all_frames, strategy="mean", min_discard_ms=10.0):
"""Find optimal parameters using grid search (slower but no scipy needed)."""
print(f"\nRunning grid search over 6 parameters (min_discard={min_discard_ms:.0f}ms)...")
sensitivities = [1.0, 2.0, 3.0, 4.0]
dead_zones = [0.7, 0.8, 0.9]
min_speeds = [0.0, 30.0]
dampings = [5.0, 10.0, 15.0]
buffer_times = [300.0, 400.0, 500.0, 600.0, 800.0]
discard_times = [d for d in [20.0, 40.0, 60.0, 100.0, 150.0, 200.0] if d >= min_discard_ms]
if not discard_times:
discard_times = [min_discard_ms]
total = (len(sensitivities) * len(dead_zones) * len(min_speeds) *
len(dampings) * len(buffer_times) * len(discard_times))
print(f"Total combinations: {total}")
best_score = float('inf')
best_params = AdaptiveParams()
count = 0
for sens in sensitivities:
for dz in dead_zones:
for ms in min_speeds:
for damp in dampings:
for bt in buffer_times:
for dt in discard_times:
count += 1
if count % 500 == 0:
print(f" Progress: {count}/{total} ({100 * count / total:.0f}%) best={best_score:.4f}")
params = AdaptiveParams(sens, dz, ms, damp, bt, dt)
per_file_scores = []
for name, frames in all_frames:
pos_errors, aim_errors = simulate_adaptive(frames, params)
score_result = compute_score(pos_errors, aim_errors)
per_file_scores.append((name, score_result))
score = aggregate_scores(per_file_scores, strategy)
if score < best_score:
best_score = score
best_params = params
return best_params, best_score
# --- Main ---
def print_file_stats(name: str, frames: List[Frame]):
"""Print basic stats for a CSV file."""
duration = frames[-1].timestamp - frames[0].timestamp
avg_fps = len(frames) / duration if duration > 0 else 0
avg_safe = sum(f.safe_count for f in frames) / len(frames)
avg_buffer = sum(f.buffer_count for f in frames) / len(frames)
avg_extrap = sum(f.extrap_time for f in frames) / len(frames) * 1000
num_shots = sum(1 for f in frames if f.shot_fired)
print(f" {os.path.basename(name)}: {len(frames)} frames, {avg_fps:.0f}fps, "
f"{duration:.1f}s, safe={avg_safe:.1f}, extrap={avg_extrap:.1f}ms, shots={num_shots}")
def print_score_detail(name: str, score: ScoreResult):
"""Print detailed score for a file."""
print(f" {os.path.basename(name):30s} Pos: mean={score.pos_mean:.3f}cm p95={score.pos_p95:.3f}cm | "
f"Aim: mean={score.aim_mean:.3f}deg p95={score.aim_p95:.3f}deg | "
f"jitter={score.jitter:.3f} overshoot={score.overshoot:.1%} | "
f"score={score.score:.4f}")
def main():
parser = argparse.ArgumentParser(
description="Anti-Recoil Parameter Optimizer - finds optimal AdaptiveExtrapolation parameters"
)
parser.add_argument("csv_files", nargs="+", help="One or more CSV recording files")
parser.add_argument("--plot", action="store_true", help="Generate comparison plots (requires matplotlib)")
parser.add_argument("--grid", action="store_true", help="Use grid search instead of differential evolution")
parser.add_argument("--strategy", choices=["mean", "worst_case"], default="mean",
help="Multi-file score aggregation strategy (default: mean)")
parser.add_argument("--max-iter", type=int, default=200, help="Max optimizer iterations (default: 200)")
args = parser.parse_args()
# Load all CSV files
all_frames = []
for csv_path in args.csv_files:
if not os.path.exists(csv_path):
print(f"Error: File not found: {csv_path}")
sys.exit(1)
frames = load_csv(csv_path)
if len(frames) < 50:
print(f"Warning: {csv_path} has only {len(frames)} frames (need at least 50 for good results)")
all_frames.append((csv_path, frames))
print(f"\nLoaded {len(all_frames)} file(s)")
print("=" * 70)
# Per-file stats
print("\n=== FILE STATISTICS ===")
for name, frames in all_frames:
print_file_stats(name, frames)
# Shot contamination analysis
has_shots = any(any(f.shot_fired for f in frames) for _, frames in all_frames)
if has_shots:
print("\n=== SHOT CONTAMINATION ANALYSIS ===")
max_recommended_discard = 0.0
for name, frames in all_frames:
result = analyze_shot_contamination(frames)
if result:
print(f" {os.path.basename(name)}:")
print(f" Shots detected: {result['num_shots']}")
print(f" Speed spike: mean={result['speed_spike_mean']:.1f} cm/s, max={result['speed_spike_max']:.1f} cm/s")
print(f" Contamination duration: mean={result['contamination_mean_ms']:.1f}ms, "
f"p95={result['contamination_p95_ms']:.1f}ms, max={result['contamination_max_ms']:.1f}ms")
print(f" Recommended discard_time: >= {result['recommended_discard_ms']:.0f}ms")
max_recommended_discard = max(max_recommended_discard, result['recommended_discard_ms'])
else:
print(f" {os.path.basename(name)}: no shots detected")
if max_recommended_discard > 0:
print(f"\n >>> MINIMUM SAFE DiscardTime across all files: {max_recommended_discard:.0f}ms <<<")
else:
print("\n (No ShotFired data in CSV - record with updated plugin to get contamination analysis)")
# Baseline: current default parameters
default_params = AdaptiveParams()
print(f"\n=== BASELINE (defaults: sens={default_params.sensitivity}, dz={default_params.dead_zone}, "
f"minspd={default_params.min_speed}, damp={default_params.damping}, "
f"buf={default_params.buffer_time_ms}ms, disc={default_params.discard_time_ms}ms) ===")
baseline_scores = []
for name, frames in all_frames:
pos_errors, aim_errors = simulate_adaptive(frames, default_params)
score = compute_score(pos_errors, aim_errors)
baseline_scores.append((name, score))
print_score_detail(name, score)
baseline_agg = aggregate_scores(baseline_scores, args.strategy)
print(f"\n Aggregate score ({args.strategy}): {baseline_agg:.4f}")
# Also show recorded prediction error (as-is from the engine)
print(f"\n=== RECORDED PREDICTION ERROR (as captured in-engine) ===")
for name, frames in all_frames:
err = compute_prediction_error(frames)
print(f" {os.path.basename(name):30s} Pos: mean={err['pos_mean']:.3f}cm p95={err['pos_p95']:.3f}cm | "
f"Aim: mean={err['aim_mean']:.3f}deg p95={err['aim_p95']:.3f}deg")
# Compute minimum safe discard time from shot contamination analysis
min_discard_ms = 10.0 # absolute minimum
if has_shots:
for name, frames in all_frames:
result = analyze_shot_contamination(frames)
if result and result['recommended_discard_ms'] > min_discard_ms:
min_discard_ms = result['recommended_discard_ms']
# Optimize
print(f"\n=== OPTIMIZATION ({args.strategy}) ===")
if args.grid:
best_params, best_score = optimize_grid_search(all_frames, args.strategy, min_discard_ms)
else:
best_params, best_score = optimize_differential_evolution(all_frames, args.strategy, args.max_iter, min_discard_ms)
# Results
print(f"\n{'=' * 70}")
print(f" BEST PARAMETERS FOUND:")
print(f" AdaptiveSensitivity = {best_params.sensitivity}")
print(f" AdaptiveDeadZone = {best_params.dead_zone}")
print(f" AdaptiveMinSpeed = {best_params.min_speed}")
print(f" ExtrapolationDamping = {best_params.damping}")
print(f" AntiRecoilBufferTimeMs = {best_params.buffer_time_ms}")
print(f" AntiRecoilDiscardTimeMs= {best_params.discard_time_ms}")
print(f"{'=' * 70}")
# Per-file breakdown with optimized params
print(f"\n=== OPTIMIZED RESULTS ===")
opt_scores = []
for name, frames in all_frames:
pos_errors, aim_errors = simulate_adaptive(frames, best_params)
score = compute_score(pos_errors, aim_errors)
opt_scores.append((name, score))
print_score_detail(name, score)
opt_agg = aggregate_scores(opt_scores, args.strategy)
print(f"\n Aggregate score ({args.strategy}): {opt_agg:.4f}")
# Improvement
print(f"\n=== IMPROVEMENT vs BASELINE ===")
for (name, baseline), (_, optimized) in zip(baseline_scores, opt_scores):
pos_pct = ((baseline.pos_mean - optimized.pos_mean) / baseline.pos_mean * 100) if baseline.pos_mean > 0 else 0
aim_pct = ((baseline.aim_mean - optimized.aim_mean) / baseline.aim_mean * 100) if baseline.aim_mean > 0 else 0
score_pct = ((baseline.score - optimized.score) / baseline.score * 100) if baseline.score > 0 else 0
print(f" {os.path.basename(name):30s} Pos: {pos_pct:+.1f}% | Aim: {aim_pct:+.1f}% | Score: {score_pct:+.1f}%")
total_pct = ((baseline_agg - opt_agg) / baseline_agg * 100) if baseline_agg > 0 else 0
print(f" {'TOTAL':30s} Score: {total_pct:+.1f}%")
# Plotting
if args.plot:
try:
import matplotlib.pyplot as plt
n_files = len(all_frames)
fig, axes = plt.subplots(n_files, 3, figsize=(18, 5 * n_files), squeeze=False)
for row, (name, frames) in enumerate(all_frames):
timestamps = [f.timestamp - frames[0].timestamp for f in frames]
short_name = os.path.basename(name)
# Baseline errors
bl_pos, bl_aim = simulate_adaptive(frames, default_params)
# Optimized errors
op_pos, op_aim = simulate_adaptive(frames, best_params)
# Time axis for simulated errors (offset by window_size)
t_start = window_size = 12
sim_timestamps = [frames[i].timestamp - frames[0].timestamp
for i in range(t_start + 1, t_start + 1 + len(bl_pos))]
# Position error
ax = axes[row][0]
if len(sim_timestamps) == len(bl_pos):
ax.plot(sim_timestamps, bl_pos, 'r-', alpha=0.4, linewidth=0.5, label='Baseline')
ax.plot(sim_timestamps, op_pos, 'g-', alpha=0.4, linewidth=0.5, label='Optimized')
ax.set_ylabel('Position Error (cm)')
ax.set_title(f'{short_name} - Position Error')
ax.legend()
# Aim error
ax = axes[row][1]
if len(sim_timestamps) >= len(bl_aim):
t_aim = sim_timestamps[:len(bl_aim)]
ax.plot(t_aim, bl_aim, 'r-', alpha=0.4, linewidth=0.5, label='Baseline')
if len(sim_timestamps) >= len(op_aim):
t_aim = sim_timestamps[:len(op_aim)]
ax.plot(t_aim, op_aim, 'g-', alpha=0.4, linewidth=0.5, label='Optimized')
ax.set_ylabel('Aim Error (deg)')
ax.set_title(f'{short_name} - Aim Error')
ax.legend()
# Speed profile
ax = axes[row][2]
speeds = [0]
for i in range(1, len(frames)):
dt = frames[i].timestamp - frames[i - 1].timestamp
if dt > 1e-6:
d = vec_dist(frames[i].real_pos, frames[i - 1].real_pos)
speeds.append(d / dt)
else:
speeds.append(speeds[-1])
ax.plot(timestamps, speeds, 'b-', alpha=0.7, linewidth=0.5)
ax.set_ylabel('Speed (cm/s)')
ax.set_xlabel('Time (s)')
ax.set_title(f'{short_name} - Speed Profile')
plt.tight_layout()
plot_path = args.csv_files[0].replace('.csv', '_optimizer.png')
plt.savefig(plot_path, dpi=150)
print(f"\nPlot saved: {plot_path}")
plt.show()
except ImportError:
print("\nmatplotlib not installed. Install with: pip install matplotlib")
print("\nDone.")
if __name__ == '__main__':
main()

366
Tools/analyze_shots.py Normal file
View File

@@ -0,0 +1,366 @@
"""
Shot Contamination Analyzer
============================
Analyzes the precise contamination zone around each shot event.
Shows speed/acceleration profiles before and after each shot to identify
the exact duration of IMU perturbation vs voluntary movement.
Usage:
python analyze_shots.py <csv_file> [--plot] [--window 100]
Protocol for best results:
1. Stay stable (no movement) for 2-3 seconds
2. Fire a single shot
3. Stay stable again for 2-3 seconds
4. Repeat 10+ times
This isolates the IMU shock from voluntary movement.
"""
import csv
import sys
import math
import os
import argparse
from dataclasses import dataclass
from typing import List, Tuple
@dataclass
class Frame:
timestamp: float
real_pos: Tuple[float, float, float]
real_aim: Tuple[float, float, float]
pred_pos: Tuple[float, float, float]
pred_aim: Tuple[float, float, float]
safe_count: int
buffer_count: int
extrap_time: float
shot_fired: bool = False
def load_csv(path: str) -> List[Frame]:
frames = []
with open(path, 'r') as f:
reader = csv.DictReader(f)
for row in reader:
frames.append(Frame(
timestamp=float(row['Timestamp']),
real_pos=(float(row['RealPosX']), float(row['RealPosY']), float(row['RealPosZ'])),
real_aim=(float(row['RealAimX']), float(row['RealAimY']), float(row['RealAimZ'])),
pred_pos=(float(row['PredPosX']), float(row['PredPosY']), float(row['PredPosZ'])),
pred_aim=(float(row['PredAimX']), float(row['PredAimY']), float(row['PredAimZ'])),
safe_count=int(row['SafeCount']),
buffer_count=int(row['BufferCount']),
extrap_time=float(row['ExtrapolationTime']),
shot_fired=int(row.get('ShotFired', 0)) == 1,
))
return frames
def vec_dist(a, b):
return math.sqrt(sum((ai - bi) ** 2 for ai, bi in zip(a, b)))
def vec_sub(a, b):
return tuple(ai - bi for ai, bi in zip(a, b))
def vec_len(a):
return math.sqrt(sum(ai * ai for ai in a))
def vec_normalize(a):
l = vec_len(a)
if l < 1e-10:
return (0, 0, 0)
return tuple(ai / l for ai in a)
def angle_between(a, b):
dot = sum(ai * bi for ai, bi in zip(a, b))
dot = max(-1.0, min(1.0, dot))
return math.degrees(math.acos(dot))
def compute_per_frame_metrics(frames):
"""Compute speed, acceleration, and aim angular speed per frame."""
n = len(frames)
pos_speed = [0.0] * n
aim_speed = [0.0] * n
pos_accel = [0.0] * n
for i in range(1, n):
dt = frames[i].timestamp - frames[i - 1].timestamp
if dt > 1e-6:
pos_speed[i] = vec_dist(frames[i].real_pos, frames[i - 1].real_pos) / dt
aim_a = vec_normalize(frames[i].real_aim)
aim_b = vec_normalize(frames[i - 1].real_aim)
if vec_len(aim_a) > 0.5 and vec_len(aim_b) > 0.5:
aim_speed[i] = angle_between(aim_a, aim_b) / dt # deg/s
for i in range(1, n):
dt = frames[i].timestamp - frames[i - 1].timestamp
if dt > 1e-6:
pos_accel[i] = (pos_speed[i] - pos_speed[i - 1]) / dt
return pos_speed, aim_speed, pos_accel
def analyze_single_shot(frames, shot_idx, pos_speed, aim_speed, pos_accel, window_ms=200.0):
"""Analyze contamination around a single shot event."""
window_s = window_ms / 1000.0
shot_time = frames[shot_idx].timestamp
# Collect frames in window before and after shot
before = [] # (time_relative_ms, pos_speed, aim_speed, pos_accel)
after = []
for i in range(max(0, shot_idx - 100), min(len(frames), shot_idx + 100)):
dt_ms = (frames[i].timestamp - shot_time) * 1000.0
if -window_ms <= dt_ms < 0:
before.append((dt_ms, pos_speed[i], aim_speed[i], pos_accel[i]))
elif dt_ms >= 0 and dt_ms <= window_ms:
after.append((dt_ms, pos_speed[i], aim_speed[i], pos_accel[i]))
if not before:
return None
# Baseline: average speed in the window before the shot
baseline_pos_speed = sum(s for _, s, _, _ in before) / len(before)
baseline_aim_speed = sum(s for _, _, s, _ in before) / len(before)
baseline_pos_std = math.sqrt(sum((s - baseline_pos_speed) ** 2 for _, s, _, _ in before) / len(before)) if len(before) > 1 else 0.0
baseline_aim_std = math.sqrt(sum((s - baseline_aim_speed) ** 2 for _, _, s, _ in before) / len(before)) if len(before) > 1 else 0.0
# Find contamination end: when speed returns to within 2 sigma of baseline
pos_threshold = baseline_pos_speed + max(2.0 * baseline_pos_std, 5.0) # at least 5 cm/s
aim_threshold = baseline_aim_speed + max(2.0 * baseline_aim_std, 5.0) # at least 5 deg/s
pos_contamination_end_ms = 0.0
aim_contamination_end_ms = 0.0
max_pos_spike = 0.0
max_aim_spike = 0.0
for dt_ms, ps, ais, _ in after:
if ps > pos_threshold:
pos_contamination_end_ms = dt_ms
if ais > aim_threshold:
aim_contamination_end_ms = dt_ms
max_pos_spike = max(max_pos_spike, ps - baseline_pos_speed)
max_aim_spike = max(max_aim_spike, ais - baseline_aim_speed)
return {
'shot_time': shot_time,
'baseline_pos_speed': baseline_pos_speed,
'baseline_aim_speed': baseline_aim_speed,
'baseline_pos_std': baseline_pos_std,
'baseline_aim_std': baseline_aim_std,
'pos_contamination_ms': pos_contamination_end_ms,
'aim_contamination_ms': aim_contamination_end_ms,
'max_contamination_ms': max(pos_contamination_end_ms, aim_contamination_end_ms),
'max_pos_spike': max_pos_spike,
'max_aim_spike': max_aim_spike,
'pos_threshold': pos_threshold,
'aim_threshold': aim_threshold,
'before': before,
'after': after,
'is_stable': baseline_pos_speed < 30.0 and baseline_aim_speed < 200.0,
}
def main():
parser = argparse.ArgumentParser(description="Shot Contamination Analyzer")
parser.add_argument("csv_file", help="CSV recording file with ShotFired column")
parser.add_argument("--plot", action="store_true", help="Generate per-shot plots (requires matplotlib)")
parser.add_argument("--window", type=float, default=200.0, help="Analysis window in ms before/after shot (default: 200)")
args = parser.parse_args()
if not os.path.exists(args.csv_file):
print(f"Error: File not found: {args.csv_file}")
sys.exit(1)
frames = load_csv(args.csv_file)
print(f"Loaded {len(frames)} frames from {os.path.basename(args.csv_file)}")
duration = frames[-1].timestamp - frames[0].timestamp
fps = len(frames) / duration if duration > 0 else 0
print(f"Duration: {duration:.1f}s | FPS: {fps:.0f}")
shot_indices = [i for i, f in enumerate(frames) if f.shot_fired]
print(f"Shots detected: {len(shot_indices)}")
if not shot_indices:
print("No shots found! Make sure the CSV has a ShotFired column.")
sys.exit(1)
pos_speed, aim_speed, pos_accel = compute_per_frame_metrics(frames)
# Analyze each shot
results = []
print(f"\n{'=' * 90}")
print(f"{'Shot':>4} {'Time':>8} {'Stable':>7} {'PosSpike':>10} {'AimSpike':>10} "
f"{'PosContam':>10} {'AimContam':>10} {'MaxContam':>10}")
print(f"{'':>4} {'(s)':>8} {'':>7} {'(cm/s)':>10} {'(deg/s)':>10} "
f"{'(ms)':>10} {'(ms)':>10} {'(ms)':>10}")
print(f"{'-' * 90}")
for idx, si in enumerate(shot_indices):
result = analyze_single_shot(frames, si, pos_speed, aim_speed, pos_accel, args.window)
if result is None:
continue
results.append(result)
stable_str = "YES" if result['is_stable'] else "no"
print(f"{idx + 1:>4} {result['shot_time']:>8.2f} {stable_str:>7} "
f"{result['max_pos_spike']:>10.1f} {result['max_aim_spike']:>10.1f} "
f"{result['pos_contamination_ms']:>10.1f} {result['aim_contamination_ms']:>10.1f} "
f"{result['max_contamination_ms']:>10.1f}")
# Summary: only stable shots (user was not moving)
stable_results = [r for r in results if r['is_stable']]
all_results = results
print(f"\n{'=' * 90}")
print(f"SUMMARY - ALL SHOTS ({len(all_results)} shots)")
if all_results:
contam_all = sorted([r['max_contamination_ms'] for r in all_results])
pos_spikes = sorted([r['max_pos_spike'] for r in all_results])
aim_spikes = sorted([r['max_aim_spike'] for r in all_results])
p95_idx = int(len(contam_all) * 0.95)
print(f" Contamination: mean={sum(contam_all)/len(contam_all):.1f}ms, "
f"median={contam_all[len(contam_all)//2]:.1f}ms, "
f"p95={contam_all[min(p95_idx, len(contam_all)-1)]:.1f}ms, "
f"max={contam_all[-1]:.1f}ms")
print(f" Pos spike: mean={sum(pos_spikes)/len(pos_spikes):.1f}cm/s, "
f"max={pos_spikes[-1]:.1f}cm/s")
print(f" Aim spike: mean={sum(aim_spikes)/len(aim_spikes):.1f}deg/s, "
f"max={aim_spikes[-1]:.1f}deg/s")
print(f"\nSUMMARY - STABLE SHOTS ONLY ({len(stable_results)} shots, baseline speed < 20cm/s)")
if stable_results:
contam_stable = sorted([r['max_contamination_ms'] for r in stable_results])
pos_spikes_s = sorted([r['max_pos_spike'] for r in stable_results])
aim_spikes_s = sorted([r['max_aim_spike'] for r in stable_results])
p95_idx = int(len(contam_stable) * 0.95)
print(f" Contamination: mean={sum(contam_stable)/len(contam_stable):.1f}ms, "
f"median={contam_stable[len(contam_stable)//2]:.1f}ms, "
f"p95={contam_stable[min(p95_idx, len(contam_stable)-1)]:.1f}ms, "
f"max={contam_stable[-1]:.1f}ms")
print(f" Pos spike: mean={sum(pos_spikes_s)/len(pos_spikes_s):.1f}cm/s, "
f"max={pos_spikes_s[-1]:.1f}cm/s")
print(f" Aim spike: mean={sum(aim_spikes_s)/len(aim_spikes_s):.1f}deg/s, "
f"max={aim_spikes_s[-1]:.1f}deg/s")
recommended = math.ceil(contam_stable[min(p95_idx, len(contam_stable)-1)] / 5.0) * 5.0
print(f"\n >>> RECOMMENDED DiscardTime (from stable shots P95): {recommended:.0f}ms <<<")
else:
print(" No stable shots found! Make sure you stay still before firing.")
print(" Shots where baseline speed > 20cm/s are excluded as 'not stable'.")
# Plotting
if args.plot:
try:
import matplotlib.pyplot as plt
# Plot each shot individually
n_shots = len(results)
cols = min(4, n_shots)
rows = math.ceil(n_shots / cols)
fig, axes = plt.subplots(rows, cols, figsize=(5 * cols, 4 * rows), squeeze=False)
fig.suptitle(f'Per-Shot Speed Profile ({os.path.basename(args.csv_file)})', fontsize=14)
for idx, result in enumerate(results):
r, c = divmod(idx, cols)
ax = axes[r][c]
# Before shot
if result['before']:
t_before = [b[0] for b in result['before']]
s_before = [b[1] for b in result['before']]
ax.plot(t_before, s_before, 'b-', linewidth=1, label='Before')
# After shot
if result['after']:
t_after = [a[0] for a in result['after']]
s_after = [a[1] for a in result['after']]
ax.plot(t_after, s_after, 'r-', linewidth=1, label='After')
# Shot line
ax.axvline(x=0, color='red', linestyle='--', alpha=0.7, label='Shot')
# Threshold
ax.axhline(y=result['pos_threshold'], color='orange', linestyle=':', alpha=0.5, label='Threshold')
# Contamination zone
if result['pos_contamination_ms'] > 0:
ax.axvspan(0, result['pos_contamination_ms'], alpha=0.15, color='red')
stable_str = "STABLE" if result['is_stable'] else "MOVING"
ax.set_title(f"Shot {idx+1} ({stable_str}) - {result['max_contamination_ms']:.0f}ms",
fontsize=9, color='green' if result['is_stable'] else 'orange')
ax.set_xlabel('Time from shot (ms)', fontsize=8)
ax.set_ylabel('Pos Speed (cm/s)', fontsize=8)
ax.tick_params(labelsize=7)
if idx == 0:
ax.legend(fontsize=6)
# Hide unused subplots
for idx in range(n_shots, rows * cols):
r, c = divmod(idx, cols)
axes[r][c].set_visible(False)
plt.tight_layout()
plot_path = args.csv_file.replace('.csv', '_shots.png')
plt.savefig(plot_path, dpi=150)
print(f"\nPlot saved: {plot_path}")
plt.show()
# Also plot aim speed
fig2, axes2 = plt.subplots(rows, cols, figsize=(5 * cols, 4 * rows), squeeze=False)
fig2.suptitle(f'Per-Shot Aim Angular Speed ({os.path.basename(args.csv_file)})', fontsize=14)
for idx, result in enumerate(results):
r, c = divmod(idx, cols)
ax = axes2[r][c]
if result['before']:
t_before = [b[0] for b in result['before']]
a_before = [b[2] for b in result['before']] # aim_speed
ax.plot(t_before, a_before, 'b-', linewidth=1)
if result['after']:
t_after = [a[0] for a in result['after']]
a_after = [a[2] for a in result['after']] # aim_speed
ax.plot(t_after, a_after, 'r-', linewidth=1)
ax.axvline(x=0, color='red', linestyle='--', alpha=0.7)
ax.axhline(y=result['aim_threshold'], color='orange', linestyle=':', alpha=0.5)
if result['aim_contamination_ms'] > 0:
ax.axvspan(0, result['aim_contamination_ms'], alpha=0.15, color='red')
stable_str = "STABLE" if result['is_stable'] else "MOVING"
ax.set_title(f"Shot {idx+1} ({stable_str}) - Aim {result['aim_contamination_ms']:.0f}ms",
fontsize=9, color='green' if result['is_stable'] else 'orange')
ax.set_xlabel('Time from shot (ms)', fontsize=8)
ax.set_ylabel('Aim Speed (deg/s)', fontsize=8)
ax.tick_params(labelsize=7)
for idx in range(n_shots, rows * cols):
r, c = divmod(idx, cols)
axes2[r][c].set_visible(False)
plt.tight_layout()
plot_path2 = args.csv_file.replace('.csv', '_shots_aim.png')
plt.savefig(plot_path2, dpi=150)
print(f"Plot saved: {plot_path2}")
plt.show()
except ImportError:
print("\nmatplotlib not installed. Install with: pip install matplotlib")
print("\nDone.")
if __name__ == '__main__':
main()

View File

@@ -29,24 +29,6 @@ static int32 GetSafeCount(const TArray<FTimestampedTransform>& History, double C
return SafeN; return SafeN;
} }
void UEBBarrel::TriggerDebugIMUShock()
{
if (!DebugSimulateIMUShock) return;
DebugIMUShockActive = true;
DebugIMUShockLineCaptured = false; // Reset so the new shock replaces the previous yellow line
DebugIMUShockStartTime = GetWorld()->GetTimeSeconds();
// Generate a random shock direction (sharp upward + random lateral, simulating recoil kick)
FVector RandomDir = FMath::VRand();
// Bias upward to simulate typical recoil pattern
RandomDir.Z = FMath::Abs(RandomDir.Z) * 2.0f;
RandomDir.Normalize();
DebugIMUShockAimOffset = RandomDir * FMath::DegreesToRadians(DebugIMUShockAngle);
DebugIMUShockPosOffset = RandomDir * DebugIMUShockPosition;
}
void UEBBarrel::UpdateTransformHistory() void UEBBarrel::UpdateTransformHistory()
{ {
if (AntiRecoilMode == EAntiRecoilMode::ARM_None) if (AntiRecoilMode == EAntiRecoilMode::ARM_None)
@@ -61,34 +43,10 @@ void UEBBarrel::UpdateTransformHistory()
Sample.Location = GetComponentTransform().GetLocation(); Sample.Location = GetComponentTransform().GetLocation();
Sample.Aim = GetComponentTransform().GetUnitAxis(EAxis::X); Sample.Aim = GetComponentTransform().GetUnitAxis(EAxis::X);
// Apply simulated IMU shock perturbation if active
if (DebugSimulateIMUShock && DebugIMUShockActive)
{
float ElapsedShock = (float)(CurrentTime - DebugIMUShockStartTime);
if (ElapsedShock < DebugIMUShockDuration)
{
// Decaying shock: intensity decreases over the shock duration
float ShockAlpha = 1.0f - (ElapsedShock / DebugIMUShockDuration);
// Add some high-frequency noise to simulate IMU vibration
FVector FrameNoise = FMath::VRand() * 0.3f;
// Perturb aim direction
FVector AimPerturbation = (DebugIMUShockAimOffset + FrameNoise * FMath::DegreesToRadians(DebugIMUShockAngle)) * ShockAlpha;
Sample.Aim = (Sample.Aim + AimPerturbation).GetSafeNormal();
// Perturb position
FVector PosPerturbation = (DebugIMUShockPosOffset + FrameNoise * DebugIMUShockPosition) * ShockAlpha;
Sample.Location += PosPerturbation;
}
else
{
DebugIMUShockActive = false;
}
}
TransformHistory.Add(Sample); TransformHistory.Add(Sample);
// Trim buffer: remove samples older than AntiRecoilBufferTime // Trim buffer: remove samples older than AntiRecoilBufferTimeMs
const float AntiRecoilBufferTime = AntiRecoilBufferTimeMs / 1000.0f;
double OldestAllowed = CurrentTime - FMath::Max(0.05f, AntiRecoilBufferTime); double OldestAllowed = CurrentTime - FMath::Max(0.05f, AntiRecoilBufferTime);
while (TransformHistory.Num() > 0 && TransformHistory[0].Timestamp < OldestAllowed) while (TransformHistory.Num() > 0 && TransformHistory[0].Timestamp < OldestAllowed)
{ {
@@ -120,7 +78,7 @@ void UEBBarrel::ComputeAntiRecoilTransform()
case EAntiRecoilMode::ARM_LinearExtrapolation: case EAntiRecoilMode::ARM_LinearExtrapolation:
{ {
int32 SafeN = GetSafeCount(TransformHistory, GetWorld()->GetTimeSeconds(), AntiRecoilDiscardTime); int32 SafeN = GetSafeCount(TransformHistory, GetWorld()->GetTimeSeconds(), AntiRecoilDiscardTimeMs / 1000.0f);
if (SafeN >= 2) if (SafeN >= 2)
{ {
PredictLinearExtrapolation(GetWorld()->GetTimeSeconds(), Location, Aim); PredictLinearExtrapolation(GetWorld()->GetTimeSeconds(), Location, Aim);
@@ -138,12 +96,12 @@ void UEBBarrel::ComputeAntiRecoilTransform()
} }
break; break;
case EAntiRecoilMode::ARM_WeightedRegression: case EAntiRecoilMode::ARM_WeightedLinearRegression:
{ {
int32 SafeN = GetSafeCount(TransformHistory, GetWorld()->GetTimeSeconds(), AntiRecoilDiscardTime); int32 SafeN = GetSafeCount(TransformHistory, GetWorld()->GetTimeSeconds(), AntiRecoilDiscardTimeMs / 1000.0f);
if (SafeN >= 2) if (SafeN >= 2)
{ {
PredictWeightedRegression(GetWorld()->GetTimeSeconds(), Location, Aim); PredictWeightedLinearRegression(GetWorld()->GetTimeSeconds(), Location, Aim);
} }
else if (TransformHistory.Num() > 0) else if (TransformHistory.Num() > 0)
{ {
@@ -160,7 +118,7 @@ void UEBBarrel::ComputeAntiRecoilTransform()
case EAntiRecoilMode::ARM_KalmanFilter: case EAntiRecoilMode::ARM_KalmanFilter:
{ {
int32 SafeN = GetSafeCount(TransformHistory, GetWorld()->GetTimeSeconds(), AntiRecoilDiscardTime); int32 SafeN = GetSafeCount(TransformHistory, GetWorld()->GetTimeSeconds(), AntiRecoilDiscardTimeMs / 1000.0f);
if (SafeN > 0) if (SafeN > 0)
{ {
// Feed only the latest SAFE sample to the Kalman filter // Feed only the latest SAFE sample to the Kalman filter
@@ -180,6 +138,26 @@ void UEBBarrel::ComputeAntiRecoilTransform()
} }
} }
break; break;
case EAntiRecoilMode::ARM_AdaptiveExtrapolation:
{
int32 SafeN = GetSafeCount(TransformHistory, GetWorld()->GetTimeSeconds(), AntiRecoilDiscardTimeMs / 1000.0f);
if (SafeN >= 2)
{
PredictAdaptiveExtrapolation(GetWorld()->GetTimeSeconds(), Location, Aim);
}
else if (TransformHistory.Num() > 0)
{
Aim = TransformHistory[0].Aim;
Location = TransformHistory[0].Location;
}
else
{
Aim = GetComponentTransform().GetUnitAxis(EAxis::X);
Location = GetComponentTransform().GetLocation();
}
}
break;
} }
} }
@@ -189,7 +167,7 @@ void UEBBarrel::ComputeAntiRecoilTransform()
void UEBBarrel::PredictLinearExtrapolation(double CurrentTime, FVector& OutLocation, FVector& OutAim) const void UEBBarrel::PredictLinearExtrapolation(double CurrentTime, FVector& OutLocation, FVector& OutAim) const
{ {
const int32 SafeN = GetSafeCount(TransformHistory, GetWorld()->GetTimeSeconds(), AntiRecoilDiscardTime); const int32 SafeN = GetSafeCount(TransformHistory, GetWorld()->GetTimeSeconds(), AntiRecoilDiscardTimeMs / 1000.0f);
if (SafeN < 2) if (SafeN < 2)
{ {
OutLocation = TransformHistory[0].Location; OutLocation = TransformHistory[0].Location;
@@ -229,7 +207,14 @@ void UEBBarrel::PredictLinearExtrapolation(double CurrentTime, FVector& OutLocat
const FTimestampedTransform& LastSafe = TransformHistory[SafeN - 1]; const FTimestampedTransform& LastSafe = TransformHistory[SafeN - 1];
double ExtrapolationTime = CurrentTime - LastSafe.Timestamp; double ExtrapolationTime = CurrentTime - LastSafe.Timestamp;
OutLocation = LastSafe.Location + AvgLinearVelocity * ExtrapolationTime; // Apply optional velocity damping: exponential decay toward zero
float DampingScale = 1.0f;
if (ExtrapolationDamping > 0.0f)
{
DampingScale = FMath::Exp(-ExtrapolationDamping * (float)ExtrapolationTime);
}
OutLocation = LastSafe.Location + AvgLinearVelocity * ExtrapolationTime * DampingScale;
// Angular extrapolation using quaternion slerp // Angular extrapolation using quaternion slerp
// Use first and last safe samples for rotation direction // Use first and last safe samples for rotation direction
@@ -241,7 +226,7 @@ void UEBBarrel::PredictLinearExtrapolation(double CurrentTime, FVector& OutLocat
FQuat FirstQuat = FRotationMatrix::MakeFromX(FirstSafe.Aim).ToQuat(); FQuat FirstQuat = FRotationMatrix::MakeFromX(FirstSafe.Aim).ToQuat();
FQuat LastQuat = FRotationMatrix::MakeFromX(LastSafe.Aim).ToQuat(); FQuat LastQuat = FRotationMatrix::MakeFromX(LastSafe.Aim).ToQuat();
double TotalAlpha = ExtrapolationTime / SafeDeltaT; double TotalAlpha = ExtrapolationTime / SafeDeltaT * DampingScale;
FQuat PredictedQuat = FQuat::Slerp(FirstQuat, LastQuat, 1.0 + TotalAlpha); FQuat PredictedQuat = FQuat::Slerp(FirstQuat, LastQuat, 1.0 + TotalAlpha);
OutAim = PredictedQuat.GetForwardVector().GetSafeNormal(); OutAim = PredictedQuat.GetForwardVector().GetSafeNormal();
@@ -257,12 +242,12 @@ void UEBBarrel::PredictLinearExtrapolation(double CurrentTime, FVector& OutLocat
} }
// --- Weighted Linear Regression --- // --- Weighted Linear Regression ---
// Fits a weighted least-squares line through only the SAFE samples, extrapolates to current time. // Fits a weighted least-squares line (y = a + bt) through SAFE samples, extrapolates to current time.
// More recent safe samples get higher weight. // Simple and stable. More recent safe samples get higher weight.
void UEBBarrel::PredictWeightedRegression(double CurrentTime, FVector& OutLocation, FVector& OutAim) const void UEBBarrel::PredictWeightedLinearRegression(double CurrentTime, FVector& OutLocation, FVector& OutAim) const
{ {
const int32 SafeN = GetSafeCount(TransformHistory, GetWorld()->GetTimeSeconds(), AntiRecoilDiscardTime); const int32 SafeN = GetSafeCount(TransformHistory, GetWorld()->GetTimeSeconds(), AntiRecoilDiscardTimeMs / 1000.0f);
if (SafeN < 2) if (SafeN < 2)
{ {
OutLocation = TransformHistory[0].Location; OutLocation = TransformHistory[0].Location;
@@ -270,11 +255,8 @@ void UEBBarrel::PredictWeightedRegression(double CurrentTime, FVector& OutLocati
return; return;
} }
// Use timestamps relative to the first safe sample to avoid precision issues
double T0 = TransformHistory[0].Timestamp; double T0 = TransformHistory[0].Timestamp;
// Weighted linear regression: y = a + b * t
// Weights: linearly increasing (1, 2, 3, ..., SafeN)
double SumW = 0.0; double SumW = 0.0;
double SumWT = 0.0; double SumWT = 0.0;
double SumWTT = 0.0; double SumWTT = 0.0;
@@ -285,7 +267,7 @@ void UEBBarrel::PredictWeightedRegression(double CurrentTime, FVector& OutLocati
for (int32 i = 0; i < SafeN; i++) for (int32 i = 0; i < SafeN; i++)
{ {
double w = FMath::Pow((double)(i + 1), (double)RegressionWeightExponent); // Weight curve controlled by exponent double w = FMath::Pow((double)(i + 1), (double)RegressionWeightExponent);
double t = TransformHistory[i].Timestamp - T0; double t = TransformHistory[i].Timestamp - T0;
SumW += w; SumW += w;
@@ -305,19 +287,25 @@ void UEBBarrel::PredictWeightedRegression(double CurrentTime, FVector& OutLocati
return; return;
} }
// Solve for position: intercept (a) and slope (b)
FVector PosIntercept = (SumWY * SumWTT - SumWTY * SumWT) / Det; FVector PosIntercept = (SumWY * SumWTT - SumWTY * SumWT) / Det;
FVector PosSlope = (SumWTY * SumW - SumWY * SumWT) / Det; FVector PosSlope = (SumWTY * SumW - SumWY * SumWT) / Det;
// Solve for aim: intercept (a) and slope (b)
FVector AimIntercept = (SumWAim * SumWTT - SumWTAim * SumWT) / Det; FVector AimIntercept = (SumWAim * SumWTT - SumWTAim * SumWT) / Det;
FVector AimSlope = (SumWTAim * SumW - SumWAim * SumWT) / Det; FVector AimSlope = (SumWTAim * SumW - SumWAim * SumWT) / Det;
// Extrapolate to current time
double TPred = CurrentTime - T0; double TPred = CurrentTime - T0;
OutLocation = PosIntercept + PosSlope * TPred;
FVector PredAim = AimIntercept + AimSlope * TPred; // Apply optional velocity damping
float DampingScale = 1.0f;
if (ExtrapolationDamping > 0.0f)
{
double TLastSafe = TransformHistory[SafeN - 1].Timestamp - T0;
float ExtrapolationTime = (float)(TPred - TLastSafe);
DampingScale = FMath::Exp(-ExtrapolationDamping * ExtrapolationTime);
}
OutLocation = PosIntercept + PosSlope * TPred * DampingScale;
FVector PredAim = AimIntercept + AimSlope * TPred * DampingScale;
OutAim = PredAim.GetSafeNormal(); OutAim = PredAim.GetSafeNormal();
if (OutAim.IsNearlyZero()) if (OutAim.IsNearlyZero())
{ {
@@ -403,12 +391,179 @@ void UEBBarrel::PredictKalmanFilter(double CurrentTime, FVector& OutLocation, FV
// Extrapolate from Kalman state to current time // Extrapolate from Kalman state to current time
float dt = (float)(CurrentTime - KalmanLastTimestamp); float dt = (float)(CurrentTime - KalmanLastTimestamp);
OutLocation = KalmanPosition + KalmanVelocity * dt; // Apply optional velocity damping
float DampingScale = 1.0f;
if (ExtrapolationDamping > 0.0f)
{
DampingScale = FMath::Exp(-ExtrapolationDamping * dt);
}
FVector PredAim = KalmanAim + KalmanAngularVelocity * dt; OutLocation = KalmanPosition + KalmanVelocity * dt * DampingScale;
FVector PredAim = KalmanAim + KalmanAngularVelocity * dt * DampingScale;
OutAim = PredAim.GetSafeNormal(); OutAim = PredAim.GetSafeNormal();
if (OutAim.IsNearlyZero()) if (OutAim.IsNearlyZero())
{ {
OutAim = KalmanAim.GetSafeNormal(); OutAim = KalmanAim.GetSafeNormal();
} }
} }
// --- Adaptive Extrapolation ---
// Computes weighted average velocity from safe samples, then scales extrapolation
// by a confidence factor based on velocity consistency.
// Low velocity variance → full extrapolation (steady movement).
// High velocity variance → reduced extrapolation (deceleration/direction change).
void UEBBarrel::PredictAdaptiveExtrapolation(double CurrentTime, FVector& OutLocation, FVector& OutAim) const
{
const int32 SafeN = GetSafeCount(TransformHistory, GetWorld()->GetTimeSeconds(), AntiRecoilDiscardTimeMs / 1000.0f);
if (SafeN < 2)
{
OutLocation = TransformHistory[0].Location;
OutAim = TransformHistory[0].Aim;
return;
}
// Compute weighted velocities from consecutive safe sample pairs
// Weight recent pairs more heavily
TArray<FVector> PosVelocities;
// Compute per-pair velocities
TArray<FVector> PosVels;
TArray<FVector> AimVels;
PosVels.Reserve(SafeN - 1);
AimVels.Reserve(SafeN - 1);
for (int32 i = 1; i < SafeN; i++)
{
double dt = TransformHistory[i].Timestamp - TransformHistory[i - 1].Timestamp;
if (dt > SMALL_NUMBER)
{
PosVels.Add((TransformHistory[i].Location - TransformHistory[i - 1].Location) / dt);
AimVels.Add((TransformHistory[i].Aim - TransformHistory[i - 1].Aim) / dt);
}
}
if (PosVels.Num() == 0)
{
OutLocation = TransformHistory[SafeN - 1].Location;
OutAim = TransformHistory[SafeN - 1].Aim;
return;
}
// Compute overall weighted average velocity (recent samples weighted more)
double TotalWeight = 0.0;
FVector AvgPosVel = FVector::ZeroVector;
FVector AvgAimVel = FVector::ZeroVector;
for (int32 i = 0; i < PosVels.Num(); i++)
{
double w = FMath::Pow((double)(i + 1), 2.0);
AvgPosVel += PosVels[i] * w;
AvgAimVel += AimVels[i] * w;
TotalWeight += w;
}
AvgPosVel /= TotalWeight;
AvgAimVel /= TotalWeight;
// Deceleration detection: compare recent speed (last 25% of pairs) vs overall speed.
// If the user is stopping, recent speed will drop toward 0 while avg is still high.
// Confidence = recentSpeed / avgSpeed, clamped to [0, 1].
// During steady movement: ratio ≈ 1 → full extrapolation, zero lag.
// During deceleration: ratio < 1 → reduced extrapolation, prevents overshoot.
int32 RecentStart = FMath::Max(0, PosVels.Num() - FMath::Max(1, PosVels.Num() / 4));
int32 RecentCount = PosVels.Num() - RecentStart;
// Recent average velocity (unweighted, just the latest samples)
FVector RecentPosVel = FVector::ZeroVector;
FVector RecentAimVel = FVector::ZeroVector;
for (int32 i = RecentStart; i < PosVels.Num(); i++)
{
RecentPosVel += PosVels[i];
RecentAimVel += AimVels[i];
}
RecentPosVel /= (double)RecentCount;
RecentAimVel /= (double)RecentCount;
// Compute speed ratio: recent / average
float AvgPosSpeed = AvgPosVel.Size();
float AvgAimSpeed = AvgAimVel.Size();
float RecentPosSpeed = RecentPosVel.Size();
float RecentAimSpeed = RecentAimVel.Size();
// Confidence: ratio of recent speed to average speed.
// Dead zone: ratios above AdaptiveDeadZone are treated as 1.0 (no correction).
// Only ratios BELOW AdaptiveDeadZone trigger extrapolation reduction.
// Below dead zone: remap [0, deadzone] → [0, 1] then apply sensitivity exponent.
// Minimum speed threshold: below this, speed ratios are unreliable (noise dominates),
// so we keep full confidence to avoid false deceleration detection.
const float MinSpeedThreshold = SMALL_NUMBER;
float PosRatio = 1.0f;
float PosConfidence = 1.0f;
float PosRemapped = 1.0f;
if (AvgPosSpeed > MinSpeedThreshold)
{
PosRatio = FMath::Clamp(RecentPosSpeed / AvgPosSpeed, 0.0f, 1.0f);
if (PosRatio >= AdaptiveDeadZone)
{
// Inside dead zone: no correction, full confidence
PosRemapped = 1.0f;
PosConfidence = 1.0f;
}
else
{
// Below dead zone: real deceleration detected
// Remap [0, deadzone] → [0, 1]
PosRemapped = (AdaptiveDeadZone > SMALL_NUMBER)
? FMath::Clamp(PosRatio / AdaptiveDeadZone, 0.0f, 1.0f)
: 0.0f;
PosConfidence = FMath::Pow(PosRemapped, AdaptiveSensitivity);
}
}
// else: AvgPosSpeed <= MinSpeedThreshold → keep defaults (Ratio=1, Conf=1)
float AimRatio = 1.0f;
float AimConfidence = 1.0f;
float AimRemapped = 1.0f;
if (AvgAimSpeed > MinSpeedThreshold)
{
AimRatio = FMath::Clamp(RecentAimSpeed / AvgAimSpeed, 0.0f, 1.0f);
if (AimRatio >= AdaptiveDeadZone)
{
// Inside dead zone: no correction, full confidence
AimRemapped = 1.0f;
AimConfidence = 1.0f;
}
else
{
// Below dead zone: real deceleration detected
// Remap [0, deadzone] → [0, 1]
AimRemapped = (AdaptiveDeadZone > SMALL_NUMBER)
? FMath::Clamp(AimRatio / AdaptiveDeadZone, 0.0f, 1.0f)
: 0.0f;
AimConfidence = FMath::Pow(AimRemapped, AdaptiveSensitivity);
}
}
// else: AvgAimSpeed <= MinSpeedThreshold → keep defaults (Ratio=1, Conf=1)
// Extrapolate from last safe sample
const FTimestampedTransform& LastSafe = TransformHistory[SafeN - 1];
double ExtrapolationTime = CurrentTime - LastSafe.Timestamp;
// Apply optional damping
float DampingScale = 1.0f;
if (ExtrapolationDamping > 0.0f)
{
DampingScale = FMath::Exp(-ExtrapolationDamping * (float)ExtrapolationTime);
}
OutLocation = LastSafe.Location + AvgPosVel * ExtrapolationTime * (PosConfidence * DampingScale);
FVector PredAim = LastSafe.Aim + AvgAimVel * ExtrapolationTime * (AimConfidence * DampingScale);
OutAim = PredAim.GetSafeNormal();
if (OutAim.IsNearlyZero())
{
OutAim = LastSafe.Aim;
}
}

View File

@@ -29,7 +29,7 @@ FPrimitiveSceneProxy* UEBBarrel::CreateSceneProxy() {
const FLinearColor DrawColor = GetViewSelectionColor(FColor::Green, *View, IsSelected(), IsHovered(), true, IsIndividuallySelected()); const FLinearColor DrawColor = GetViewSelectionColor(FColor::Green, *View, IsSelected(), IsHovered(), true, IsIndividuallySelected());
FPrimitiveDrawInterface* PDI = Collector.GetPDI(ViewIndex); FPrimitiveDrawInterface* PDI = Collector.GetPDI(ViewIndex);
DrawDirectionalArrow(PDI, Transform, DrawColor, Component->DebugArrowSize, Component->DebugArrowSize*0.1f, 16, Component->DebugArrowSize*0.01f); DrawDirectionalArrow(PDI, Transform, DrawColor, Component->DebugArrowSize, Component->DebugArrowSize*0.1f, 16, 0.0f);
} }
} }
} }

View File

@@ -1,9 +1,14 @@
// Copyright 2018 Mookie. All Rights Reserved. // Copyright 2018 Mookie. All Rights Reserved.
#include "EBBarrel.h" #include "EBBarrel.h"
#include "DrawDebugHelpers.h" #include "DrawDebugHelpers.h"
#include "Engine/Engine.h"
#include "HAL/PlatformFileManager.h"
#include "Misc/Paths.h"
#include "Misc/DateTime.h"
UEBBarrel::UEBBarrel() { UEBBarrel::UEBBarrel() {
PrimaryComponentTick.bCanEverTick = true; PrimaryComponentTick.bCanEverTick = true;
PrimaryComponentTick.TickGroup = TG_PostPhysics;
bHiddenInGame = true; bHiddenInGame = true;
bAutoActivate = true; bAutoActivate = true;
SetIsReplicatedByDefault(ReplicateVariables); SetIsReplicatedByDefault(ReplicateVariables);
@@ -13,6 +18,33 @@ UEBBarrel::UEBBarrel() {
GatlingRPS = FireRateMin; GatlingRPS = FireRateMin;
} }
void UEBBarrel::BeginPlay()
{
Super::BeginPlay();
// Add tick prerequisite on the attach parent so this barrel ticks after
// its parent's transform is updated. UE propagates transforms down the
// attach chain, so this is sufficient even with deep hierarchies
// (Pawn -> MotionController -> ChildActor -> Weapon -> EBBarrel).
if (USceneComponent* Parent = GetAttachParent())
{
PrimaryComponentTick.AddPrerequisite(Parent, Parent->PrimaryComponentTick);
}
}
void UEBBarrel::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
// Close CSV file on stop/exit so the file isn't left locked
if (bCSVFileOpen && CSVFileHandle)
{
delete CSVFileHandle;
CSVFileHandle = nullptr;
bCSVFileOpen = false;
}
Super::EndPlay(EndPlayReason);
}
void UEBBarrel::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) void UEBBarrel::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{ {
Super::TickComponent(DeltaTime, TickType, ThisTickFunction); Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
@@ -66,45 +98,81 @@ void UEBBarrel::TickComponent(float DeltaTime, ELevelTick TickType, FActorCompon
// Green line: raw tracker data (potentially shocked if IMU simulation is active) // Green line: raw tracker data (potentially shocked if IMU simulation is active)
DrawDebugLine(GetWorld(), RawLocation, RawLocation + RawAim * DebugAntiRecoilLineLength, DrawDebugLine(GetWorld(), RawLocation, RawLocation + RawAim * DebugAntiRecoilLineLength,
FColor::Green, false, -1.0f, 0, DebugAntiRecoilLineThickness); FColor::Green, false, -1.0f, 0, 0.0f);
// Red line: anti-recoil predicted aim (what would be used if shooting now) // Red line: anti-recoil predicted aim (what would be used if shooting now)
DrawDebugLine(GetWorld(), Location, Location + Aim * DebugAntiRecoilLineLength, DrawDebugLine(GetWorld(), Location, Location + Aim * DebugAntiRecoilLineLength,
FColor::Red, false, -1.0f, 0, DebugAntiRecoilLineThickness); FColor::Red, false, -1.0f, 0, 0.0f);
// Small spheres at origins for clarity // Small dots at origins for clarity
DrawDebugSphere(GetWorld(), RawLocation, 1.5f, 6, FColor::Green, false, -1.0f, 0, DebugAntiRecoilLineThickness * 0.5f); DrawDebugPoint(GetWorld(), RawLocation, 3.0f, FColor::Green, false, -1.0f, 0);
DrawDebugSphere(GetWorld(), Location, 1.5f, 6, FColor::Red, false, -1.0f, 0, DebugAntiRecoilLineThickness * 0.5f); DrawDebugPoint(GetWorld(), Location, 3.0f, FColor::Red, false, -1.0f, 0);
}
// Yellow line: shows where shot would land WITHOUT anti-recoil correction // CSV Prediction Recording
// Captures raw aim at shock onset and persists for DebugIMUShockDisplayTime seconds if (RecordPredictionCSV && AntiRecoilMode != EAntiRecoilMode::ARM_None)
if (DebugIMUShockActive && !DebugIMUShockLineCaptured) {
if (!bCSVFileOpen)
{ {
// Capture the raw (shocked) aim at the first frame of shock // Open new CSV file
DebugIMUShockCapturedLocation = RawLocation; FString Timestamp = FDateTime::Now().ToString(TEXT("%Y%m%d_%H%M%S"));
DebugIMUShockCapturedAim = RawAim; CSVFilePath = FPaths::ProjectSavedDir() / TEXT("Logs") / FString::Printf(TEXT("AntiRecoil_%s.csv"), *Timestamp);
DebugIMUShockLineCaptured = true; CSVFileHandle = FPlatformFileManager::Get().GetPlatformFile().OpenWrite(*CSVFilePath);
DebugIMUShockLineEndTime = GetWorld()->GetTimeSeconds() + DebugIMUShockDisplayTime; if (CSVFileHandle)
}
if (!DebugIMUShockActive && DebugIMUShockLineCaptured)
{
// Shock ended: keep displaying but update captured aim to worst-case (peak shock)
// which was already captured at onset
}
if (DebugIMUShockLineCaptured)
{
if (GetWorld()->GetTimeSeconds() < DebugIMUShockLineEndTime)
{ {
// Yellow line: uncorrected aim (where the bullet would have gone without anti-recoil) bCSVFileOpen = true;
DrawDebugLine(GetWorld(), DebugIMUShockCapturedLocation, FString Header = TEXT("Timestamp,RealPosX,RealPosY,RealPosZ,RealAimX,RealAimY,RealAimZ,PredPosX,PredPosY,PredPosZ,PredAimX,PredAimY,PredAimZ,SafeCount,BufferCount,ExtrapolationTime,ShotFired\n");
DebugIMUShockCapturedLocation + DebugIMUShockCapturedAim * DebugAntiRecoilLineLength, auto HeaderUtf8 = StringCast<ANSICHAR>(*Header);
FColor::Yellow, false, -1.0f, 0, DebugAntiRecoilLineThickness); CSVFileHandle->Write((const uint8*)HeaderUtf8.Get(), HeaderUtf8.Length());
DrawDebugSphere(GetWorld(), DebugIMUShockCapturedLocation, 3.0f, 8, FColor::Yellow, false, -1.0f, 0, DebugAntiRecoilLineThickness);
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(-700, 5.0f, FColor::Cyan,
FString::Printf(TEXT("CSV Recording started: %s"), *CSVFilePath));
}
} }
else }
if (bCSVFileOpen && CSVFileHandle)
{
FVector RealPos = GetComponentTransform().GetLocation();
FVector RealAim = GetComponentTransform().GetUnitAxis(EAxis::X);
// Count safe samples (same logic as GetSafeCount in AntiRecoilPredict.cpp)
double SafeCutoff = GetWorld()->GetTimeSeconds() - (AntiRecoilDiscardTimeMs / 1000.0f);
int32 SafeN = 0;
for (int32 si = 0; si < TransformHistory.Num(); si++)
{ {
DebugIMUShockLineCaptured = false; if (TransformHistory[si].Timestamp < SafeCutoff) SafeN++;
} }
double ExtrapTime = (SafeN > 0) ? (GetWorld()->GetTimeSeconds() - TransformHistory[SafeN - 1].Timestamp) : 0.0;
FString Line = FString::Printf(TEXT("%.6f,%.4f,%.4f,%.4f,%.6f,%.6f,%.6f,%.4f,%.4f,%.4f,%.6f,%.6f,%.6f,%d,%d,%.6f,%d\n"),
GetWorld()->GetTimeSeconds(),
RealPos.X, RealPos.Y, RealPos.Z,
RealAim.X, RealAim.Y, RealAim.Z,
Location.X, Location.Y, Location.Z,
Aim.X, Aim.Y, Aim.Z,
SafeN, TransformHistory.Num(), ExtrapTime,
bShotFiredThisFrame ? 1 : 0);
auto LineUtf8 = StringCast<ANSICHAR>(*Line);
CSVFileHandle->Write((const uint8*)LineUtf8.Get(), LineUtf8.Length());
bShotFiredThisFrame = false;
}
}
else if (bCSVFileOpen)
{
// Close CSV file
if (CSVFileHandle)
{
delete CSVFileHandle;
CSVFileHandle = nullptr;
}
bCSVFileOpen = false;
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(-700, 5.0f, FColor::Cyan,
FString::Printf(TEXT("CSV Recording stopped: %s"), *CSVFilePath));
} }
} }
@@ -296,6 +364,8 @@ void UEBBarrel::SpawnBullet(AActor* Owner, FVector InLocation, FVector InAim, in
} }
} }
bShotFiredThisFrame = true;
if (ReplicateShotFiredEvents) { if (ReplicateShotFiredEvents) {
ShotFiredMulticast(); ShotFiredMulticast();
} }
@@ -314,4 +384,5 @@ void UEBBarrel::ApplyRecoil_Implementation(UPrimitiveComponent* Component, FVect
if (Component->IsSimulatingPhysics()) { if (Component->IsSimulatingPhysics()) {
Component->AddImpulseAtLocation(Impulse, InLocation); Component->AddImpulseAtLocation(Impulse, InLocation);
} }
} }

View File

@@ -80,22 +80,58 @@ float AEBBullet::Trace(FVector start, FVector PreviousVelocity, float delta, TEn
} }
if (MaterialDensityControlsPenetrationDepth) { if (MaterialDensityControlsPenetrationDepth) {
penDepthMultiplier /= PhysMaterial->Density; float SafeDensity = FMath::Max(PhysMaterial->Density, 0.001f);
penDepthMultiplier /= SafeDensity;
} }
if (MaterialRestitutionControlsRicochet) { if (MaterialRestitutionControlsRicochet) {
RicochetRestitution *= PhysMaterial->Restitution; RicochetRestitution *= FMath::Clamp(PhysMaterial->Restitution, 0.0f, 1.0f);
} }
} }
// Guard: if bullet has near-zero velocity, stop it immediately
if (Velocity.SizeSquared() < SMALL_NUMBER)
{
SetActorLocation(HitResult.Location + HitResult.Normal * CollisionMargin);
FVector Impulse = Velocity * Mass * ImpulseMultiplier;
if (AddImpulse && HitResult.Component->IsSimulatingPhysics()) {
HitResult.Component->AddImpulseAtLocation(Impulse, HitResult.Location, HitResult.BoneName);
}
if (HasAuthority()) {
OnImpact(false, false, HitResult.Location, Velocity, HitResult.Normal, GetActorLocation(), FVector::ZeroVector, Impulse, 0.0f, HitResult.GetActor(), HitResult.Component.Get(), HitResult.BoneName, PhysMaterial, HitResult, fireEventID);
} else {
OnNetPredictedImpact(false, false, HitResult.Location, Velocity, HitResult.Normal, GetActorLocation(), FVector::ZeroVector, Impulse, 0.0f, HitResult.GetActor(), HitResult.Component.Get(), HitResult.BoneName, PhysMaterial, HitResult, fireEventID);
}
Velocity = FVector::ZeroVector;
Deactivate();
return 0.0f;
}
float dot = FVector::DotProduct(Velocity.GetSafeNormal(), HitResult.Normal) + 1.0f; float dot = FVector::DotProduct(Velocity.GetSafeNormal(), HitResult.Normal) + 1.0f;
FVector cross = FVector::CrossProduct(Velocity.GetSafeNormal(), HitResult.Normal); FVector cross = FVector::CrossProduct(Velocity.GetSafeNormal(), HitResult.Normal);
FVector flat = HitResult.Normal.RotateAngleAxis(-90.0f, cross);
// Guard: near-zero cross product at very shallow grazing angles
FVector flat;
if (cross.SizeSquared() < SMALL_NUMBER)
{
// Bullet nearly parallel to surface: project velocity onto surface plane
flat = FVector::VectorPlaneProject(Velocity.GetSafeNormal(), HitResult.Normal).GetSafeNormal();
if (flat.IsNearlyZero())
{
flat = FMath::Abs(HitResult.Normal.Z) < 0.9f
? FVector::CrossProduct(HitResult.Normal, FVector::UpVector).GetSafeNormal()
: FVector::CrossProduct(HitResult.Normal, FVector::RightVector).GetSafeNormal();
}
}
else
{
flat = HitResult.Normal.RotateAngleAxis(-90.0f, cross);
}
#ifdef WITH_EDITOR #ifdef WITH_EDITOR
if (DebugEnabled) { if (DebugEnabled) {
FColor DebugColor = FColor::MakeRedToGreenColorFromScalar(Velocity.Size() / MuzzleVelocityMax); FColor DebugColor = FColor::MakeRedToGreenColorFromScalar(Velocity.Size() / FMath::Max(MuzzleVelocityMax, 1.0f));
DrawDebugLine(GetWorld(), start, HitResult.Location, DebugColor, false, DebugTrailTime, 0, DebugTrailWidth); DrawDebugLine(GetWorld(), start, HitResult.Location, DebugColor, false, DebugTrailTime, 0, DebugTrailWidth);
}; };
#endif #endif
@@ -103,32 +139,43 @@ float AEBBullet::Trace(FVector start, FVector PreviousVelocity, float delta, TEn
float GrazingAngle = FMath::Pow(dot, GrazingAngleExponent); float GrazingAngle = FMath::Pow(dot, GrazingAngleExponent);
FVector PenetrationVector = RandomStream.VRandCone(Velocity, penEnterSpread); FVector PenetrationVector = RandomStream.VRandCone(Velocity, penEnterSpread);
PenetrationVector = FMath::Lerp(PenetrationVector, -HitResult.Normal, FMath::Lerp(penNormalization, penNormalizationGrazing, GrazingAngle)); PenetrationVector = FMath::Lerp(PenetrationVector, -HitResult.Normal, FMath::Lerp(penNormalization, penNormalizationGrazing, GrazingAngle));
float PenetrationDistance = FMath::Lerp(MinPenetration, MaxPenetration, RandomStream.FRand()) * FMath::Pow((Velocity.Size() / ((MuzzleVelocityMin + MuzzleVelocityMax) * 0.5f)), 2.0f) * penDepthMultiplier; float AvgMuzzleVelocity = FMath::Max((MuzzleVelocityMin + MuzzleVelocityMax) * 0.5f, 1.0f);
// Incidence angle factor: head-on (dot=2) -> full depth, grazing (dot=0) -> minimal depth
float IncidenceFactor = FMath::Clamp(dot * 0.5f, 0.05f, 1.0f);
float PenetrationDistance = FMath::Lerp(MinPenetration, MaxPenetration, RandomStream.FRand()) * FMath::Pow((Velocity.Size() / AvgMuzzleVelocity), 2.0f) * penDepthMultiplier * IncidenceFactor;
float PenetrationDepth = -FVector::DotProduct(PenetrationVector, HitResult.Normal) * PenetrationDistance; float PenetrationDepth = -FVector::DotProduct(PenetrationVector, HitResult.Normal) * PenetrationDistance;
float BlockTIme = 1.0f; float BlockTime = 1.0f;
if (PenetrationDistance > 0.0f) { if (PenetrationDistance > 0.0f) {
if (!neverPenetrate) { if (!neverPenetrate) {
BlockTIme = PenetrationTrace(HitResult.Location - (HitResult.Normal * CollisionMargin), HitResult.Location + PenetrationVector * PenetrationDistance, HitResult.Component, PenTraceType, CollisionChannel, exitLoc, exitNormal); BlockTime = PenetrationTrace(HitResult.Location - (HitResult.Normal * CollisionMargin), HitResult.Location + PenetrationVector * PenetrationDistance, HitResult.Component, PenTraceType, CollisionChannel, exitLoc, exitNormal);
} }
} }
if (BlockTIme >= 0.999999f) { if (BlockTime >= 0.999999f) {
//no pen //no pen
SetActorLocation(HitResult.Location + HitResult.Normal * CollisionMargin); SetActorLocation(HitResult.Location + HitResult.Normal * CollisionMargin);
float ricThreshold = 1.0f; float ricThreshold = 1.0f;
if (SpeedControlsRicochetProbability) { ricThreshold *= Velocity.Size() / MuzzleVelocityMax; }; if (SpeedControlsRicochetProbability) { ricThreshold *= Velocity.Size() / FMath::Max(MuzzleVelocityMax, 1.0f); };
if (!neverRicochet && RandomStream.FRand() * ricThreshold < FMath::Lerp(RicochetProbability * ricProbMultiplier, RicochetProbabilityGrazing * ricProbMultiplier, GrazingAngle)) { if (!neverRicochet && RandomStream.FRand() * ricThreshold < FMath::Lerp(RicochetProbability * ricProbMultiplier, RicochetProbabilityGrazing * ricProbMultiplier, GrazingAngle)) {
//bounce //bounce
FVector bounceAngle = flat * dot * (1.0f - ricFriction); FVector bounceAngle = flat * dot * (1.0f - ricFriction);
bounceAngle += HitResult.Normal * (1.0f - dot) * ricRestitution; bounceAngle += HitResult.Normal * (1.0f - dot) * ricRestitution;
bounceAngle = RandomStream.VRandCone(bounceAngle, ricSpread) * bounceAngle.Size(); float bounceSize = bounceAngle.Size();
if (bounceSize > SMALL_NUMBER)
NewVelocity = bounceAngle * Velocity.Size(); {
bounceAngle = RandomStream.VRandCone(bounceAngle, ricSpread) * bounceSize;
NewVelocity = bounceAngle * Velocity.Size();
}
else
{
// bounceAngle is zero (head-on + high friction + low restitution): reflect off normal
NewVelocity = RandomStream.VRandCone(HitResult.Normal, ricSpread) * Velocity.Size() * ricRestitution;
}
Ricochet = true; Ricochet = true;
OwnerSafe = false; OwnerSafe = false;
} }
@@ -139,7 +186,7 @@ float AEBBullet::Trace(FVector start, FVector PreviousVelocity, float delta, TEn
} }
else { else {
//penetration //penetration
float RemainingEnergy = FMath::Pow(1.0f - BlockTIme, 2.0f); float RemainingEnergy = FMath::Pow(1.0f - BlockTime, 2.0f);
SetActorLocation(exitLoc + exitNormal * CollisionMargin); SetActorLocation(exitLoc + exitNormal * CollisionMargin);
NewVelocity = RandomStream.VRandCone(PenetrationVector, penExitSpread * (1.0f - RemainingEnergy)); NewVelocity = RandomStream.VRandCone(PenetrationVector, penExitSpread * (1.0f - RemainingEnergy));
NewVelocity = FMath::Lerp(NewVelocity, Velocity.GetSafeNormal(), RemainingEnergy); NewVelocity = FMath::Lerp(NewVelocity, Velocity.GetSafeNormal(), RemainingEnergy);
@@ -189,7 +236,7 @@ float AEBBullet::Trace(FVector start, FVector PreviousVelocity, float delta, TEn
#ifdef WITH_EDITOR #ifdef WITH_EDITOR
if (DebugEnabled) { if (DebugEnabled) {
FLinearColor Color = GetDebugColor(Velocity.Size() / ((MuzzleVelocityMin + MuzzleVelocityMax)*0.5f)); FLinearColor Color = GetDebugColor(Velocity.Size() / FMath::Max((MuzzleVelocityMin + MuzzleVelocityMax)*0.5f, 1.0f));
DrawDebugLine(GetWorld(), start, start + TraceDistance, Color.ToFColor(true), false, DebugTrailTime, 0, 0); DrawDebugLine(GetWorld(), start, start + TraceDistance, Color.ToFColor(true), false, DebugTrailTime, 0, 0);
} }
} }

View File

@@ -28,8 +28,9 @@ enum class EAntiRecoilMode : uint8
ARM_None UMETA(DisplayName = "Disabled", ToolTip = "No anti-recoil processing. Uses raw tracker data directly. Use this when no IMU shock compensation is needed."), ARM_None UMETA(DisplayName = "Disabled", ToolTip = "No anti-recoil processing. Uses raw tracker data directly. Use this when no IMU shock compensation is needed."),
ARM_Buffer UMETA(DisplayName = "Buffer (No Prediction)", ToolTip = "Legacy mode. Returns the oldest sample in the buffer, guaranteed to be pre-shock. Simple and reliable but introduces a fixed time delay equal to BufferTime. Best for static or slow-moving aiming."), ARM_Buffer UMETA(DisplayName = "Buffer (No Prediction)", ToolTip = "Legacy mode. Returns the oldest sample in the buffer, guaranteed to be pre-shock. Simple and reliable but introduces a fixed time delay equal to BufferTime. Best for static or slow-moving aiming."),
ARM_LinearExtrapolation UMETA(DisplayName = "Linear Extrapolation", ToolTip = "Computes average linear and angular velocity from consecutive safe (pre-shock) samples, then extrapolates forward to the current time. Good balance of simplicity and accuracy for steady movements. May overshoot on sudden direction changes."), ARM_LinearExtrapolation UMETA(DisplayName = "Linear Extrapolation", ToolTip = "Computes average linear and angular velocity from consecutive safe (pre-shock) samples, then extrapolates forward to the current time. Good balance of simplicity and accuracy for steady movements. May overshoot on sudden direction changes."),
ARM_WeightedRegression UMETA(DisplayName = "Weighted Regression", ToolTip = "Fits a weighted least-squares regression line through all safe samples (recent safe samples weighted higher), then extrapolates to current time. More robust to individual noisy samples than linear extrapolation. Slightly heavier computation."), ARM_WeightedLinearRegression UMETA(DisplayName = "Weighted Linear Regression", ToolTip = "Fits a weighted least-squares line (y=a+bt) through safe samples. Recent samples weighted higher (controlled by RegressionWeightExponent). Simple, stable, no oscillation. May overshoot on sudden stops since it assumes constant velocity."),
ARM_KalmanFilter UMETA(DisplayName = "Kalman Filter", ToolTip = "Maintains an internal state model (position + velocity, aim + angular velocity) updated only with safe samples. Predicts forward using the estimated dynamics. Best for smooth continuous tracking with optimal noise rejection. Requires tuning ProcessNoise and MeasurementNoise for best results.") ARM_KalmanFilter UMETA(DisplayName = "Kalman Filter", ToolTip = "Maintains an internal state model (position + velocity, aim + angular velocity) updated only with safe samples. Predicts forward using the estimated dynamics. Best for smooth continuous tracking with optimal noise rejection. Requires tuning ProcessNoise and MeasurementNoise for best results."),
ARM_AdaptiveExtrapolation UMETA(DisplayName = "Adaptive Extrapolation", ToolTip = "Deceleration-aware linear extrapolation. Compares recent speed (last 25% of safe window) to average speed. During steady movement: full extrapolation (zero lag). During deceleration/stop: extrapolation is reduced proportionally. Prevents overshoot on fast draw-aim-fire sequences without adding lag during normal tracking. Tuning: AdaptiveSensitivity controls the power curve (1=linear, 2=aggressive, 0.5=gentle).")
}; };
USTRUCT() USTRUCT()
@@ -52,6 +53,9 @@ public:
// Sets default values for this component's properties // Sets default values for this component's properties
UEBBarrel(); UEBBarrel();
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
// Called every frame // Called every frame
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override; virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
@@ -65,32 +69,28 @@ public:
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Debug", meta = (ToolTip = "Draw real-time debug lines: Green = raw tracker, Red = anti-recoil predicted aim")) UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Debug", meta = (ToolTip = "Draw real-time debug lines: Green = raw tracker, Red = anti-recoil predicted aim"))
bool DebugAntiRecoil = false; bool DebugAntiRecoil = false;
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Debug", meta = (ToolTip = "Length of the debug aim lines (cm)", EditCondition = "DebugAntiRecoil")) UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Debug", meta = (ToolTip = "Length of the debug aim lines (cm)", EditCondition = "DebugAntiRecoil"))
float DebugAntiRecoilLineLength = 200.0f; float DebugAntiRecoilLineLength = 400.0f;
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Debug", meta = (ToolTip = "Thickness of the debug aim lines", EditCondition = "DebugAntiRecoil"))
float DebugAntiRecoilLineThickness = 0.0f;
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Debug|IMU Shock Simulation", meta = (ToolTip = "Enable IMU shock simulation for testing anti-recoil prediction without firing")) UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Debug|CSV Recording", meta = (ToolTip = "Record per-frame prediction data to CSV for offline analysis. File saved to project Saved/Logs/ folder. Toggle off to stop and close the file."))
bool DebugSimulateIMUShock = false; bool RecordPredictionCSV = false;
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Debug|IMU Shock Simulation", meta = (ToolTip = "Angular perturbation intensity in degrees", EditCondition = "DebugSimulateIMUShock", ClampMin = "0.0"))
float DebugIMUShockAngle = 15.0f; // CSV recording state (not exposed)
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Debug|IMU Shock Simulation", meta = (ToolTip = "Position perturbation intensity in cm", EditCondition = "DebugSimulateIMUShock", ClampMin = "0.0")) bool bCSVFileOpen = false;
float DebugIMUShockPosition = 2.0f; FString CSVFilePath;
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Debug|IMU Shock Simulation", meta = (ToolTip = "Duration of the simulated shock in seconds", EditCondition = "DebugSimulateIMUShock", ClampMin = "0.01")) IFileHandle* CSVFileHandle = nullptr;
float DebugIMUShockDuration = 0.08f; bool bShotFiredThisFrame = false;
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Debug|IMU Shock Simulation", meta = (ToolTip = "How long (seconds) the yellow debug line persists after a shock, showing where the shot would have gone without anti-recoil correction", EditCondition = "DebugSimulateIMUShock", ClampMin = "0.5"))
float DebugIMUShockDisplayTime = 3.0f;
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "AntiRecoil", meta = (ToolTip = "Selects the anti-recoil compensation algorithm. Hover over each option in the dropdown for a detailed description of how it works.")) UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "AntiRecoil", meta = (ToolTip = "Selects the anti-recoil compensation algorithm. Hover over each option in the dropdown for a detailed description of how it works."))
EAntiRecoilMode AntiRecoilMode = EAntiRecoilMode::ARM_KalmanFilter; EAntiRecoilMode AntiRecoilMode = EAntiRecoilMode::ARM_AdaptiveExtrapolation;
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "AntiRecoil", meta = (ToolTip = "Total time window (seconds) of tracker history to keep. Determines how far back in time samples are stored. Must be greater than DiscardTime. Example: 0.2s at 60fps stores ~12 samples.", ClampMin = "0.05")) UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "AntiRecoil", meta = (ToolTip = "Total time window (ms) of tracker history to keep. Determines how far back in time samples are stored. Must be greater than DiscardTime. Example: 200ms at 60fps stores ~12 samples.", ClampMin = "5"))
float AntiRecoilBufferTime = 0.15f; float AntiRecoilBufferTimeMs = 200.0f;
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "AntiRecoil", meta = (ToolTip = "Time window (seconds) of most recent samples to exclude as potentially contaminated by IMU recoil shock. The prediction algorithms only use samples older than this. Increase if the shock lasts longer. Safe window = BufferTime - DiscardTime.", ClampMin = "0.0")) UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "AntiRecoil", meta = (ToolTip = "Time window (ms) of most recent samples to exclude as potentially contaminated by IMU recoil shock. The prediction algorithms only use samples older than this. Increase if the shock lasts longer. Safe window = BufferTime - DiscardTime.", ClampMin = "0.0"))
float AntiRecoilDiscardTime = 0.03f; float AntiRecoilDiscardTimeMs = 30.0f;
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "AntiRecoil", meta = (ToolTip = "Controls how the weight curve grows across safe samples in Weighted Regression mode. 1.0 = linear growth (default), >1.0 = recent samples weighted much more heavily (convex curve), <1.0 = more uniform weighting (concave curve), 0.0 = all samples weighted equally (unweighted regression). Formula: weight = pow(sampleIndex+1, exponent).", EditCondition = "AntiRecoilMode == EAntiRecoilMode::ARM_WeightedRegression", ClampMin = "0.0", ClampMax = "5.0")) UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "AntiRecoil", meta = (ToolTip = "Controls how the weight curve grows across safe samples in regression modes. 1.0 = linear growth, >1.0 = recent samples weighted much more heavily (convex curve), <1.0 = more uniform weighting (concave curve), 0.0 = all samples weighted equally. Formula: weight = pow(sampleIndex+1, exponent).", EditCondition = "AntiRecoilMode == EAntiRecoilMode::ARM_WeightedLinearRegression", ClampMin = "0.0", ClampMax = "5.0"))
float RegressionWeightExponent = 3.0f; float RegressionWeightExponent = 2.0f;
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "AntiRecoil", meta = (ToolTip = "Kalman filter process noise (higher = more responsive to movement changes, lower = smoother). Since safe samples are already filtered by DiscardTime, this should be high enough to track aiming movements.", EditCondition = "AntiRecoilMode == EAntiRecoilMode::ARM_KalmanFilter", ClampMin = "0.01")) UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "AntiRecoil", meta = (ToolTip = "Kalman filter process noise (higher = more responsive to movement changes, lower = smoother). Since safe samples are already filtered by DiscardTime, this should be high enough to track aiming movements.", EditCondition = "AntiRecoilMode == EAntiRecoilMode::ARM_KalmanFilter", ClampMin = "0.01"))
float KalmanProcessNoise = 200.0f; float KalmanProcessNoise = 200.0f;
@@ -98,6 +98,15 @@ public:
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "AntiRecoil", meta = (ToolTip = "Kalman filter measurement noise (higher = trusts model over measurements, lower = trusts measurements). Should be low since safe samples are clean.", EditCondition = "AntiRecoilMode == EAntiRecoilMode::ARM_KalmanFilter", ClampMin = "0.001")) UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "AntiRecoil", meta = (ToolTip = "Kalman filter measurement noise (higher = trusts model over measurements, lower = trusts measurements). Should be low since safe samples are clean.", EditCondition = "AntiRecoilMode == EAntiRecoilMode::ARM_KalmanFilter", ClampMin = "0.001"))
float KalmanMeasurementNoise = 0.01f; float KalmanMeasurementNoise = 0.01f;
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "AntiRecoil", meta = (ToolTip = "Power curve exponent for deceleration detection. Controls how aggressively slowing down reduces extrapolation. confidence = (remappedRatio)^sensitivity. 1.0 = linear (gentle). 2.0 = quadratic (aggressive). 0.5 = square root (very gentle). During steady movement, ratio is ~1 so confidence is always 1 regardless of this value.", EditCondition = "AntiRecoilMode == EAntiRecoilMode::ARM_AdaptiveExtrapolation", ClampMin = "0.1", ClampMax = "5.0"))
float AdaptiveSensitivity = 3.0f;
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "AntiRecoil", meta = (ToolTip = "Dead zone for deceleration detection. Speed ratios (recent/avg) above this value are treated as 1.0 (no correction). Only ratios below trigger extrapolation reduction. Higher = more tolerant to natural speed fluctuations (less false positives). Lower = more sensitive to deceleration. 0.8 = ignore normal jitter, only react to real braking.", EditCondition = "AntiRecoilMode == EAntiRecoilMode::ARM_AdaptiveExtrapolation", ClampMin = "0.0", ClampMax = "0.95"))
float AdaptiveDeadZone = 0.95f;
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "AntiRecoil", meta = (ToolTip = "Velocity damping during extrapolation. 0 = disabled (default). Higher values cause extrapolated velocity to decay exponentially toward zero over the discard window. Reduces overshoot on fast draw-aim-fire sequences where the user stops moving before firing. Applies to all prediction modes except Buffer. Typical range: 5-15.", ClampMin = "0.0", ClampMax = "50.0"))
float ExtrapolationDamping = 5.0f;
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Velocity", meta = (ToolTip = "Bullet inherits barrel velocity, only works with physics enabled or with additional velocity set")) float InheritVelocity = 1.0f; UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Velocity", meta = (ToolTip = "Bullet inherits barrel velocity, only works with physics enabled or with additional velocity set")) float InheritVelocity = 1.0f;
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Velocity", meta = (ToolTip = "Amount of recoil applied to the barrel, only works with physics enabled")) float RecoilMultiplier = 1.0f; UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Velocity", meta = (ToolTip = "Amount of recoil applied to the barrel, only works with physics enabled")) float RecoilMultiplier = 1.0f;
UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Velocity", meta = (ToolTip = "Additional velocity, for use with InheritVelocity")) FVector AdditionalVelocity = FVector(0,0,0); UPROPERTY(BlueprintReadWrite, EditAnywhere, Category = "Velocity", meta = (ToolTip = "Additional velocity, for use with InheritVelocity")) FVector AdditionalVelocity = FVector(0,0,0);
@@ -173,9 +182,6 @@ public:
UFUNCTION(Server, Reliable, WithValidation, BlueprintCallable, Category = "Shooting") void GatlingSpool(bool Spool); UFUNCTION(Server, Reliable, WithValidation, BlueprintCallable, Category = "Shooting") void GatlingSpool(bool Spool);
UFUNCTION(BlueprintCallable, Category = "Shooting") void Shoot(bool Trigger, int nextEventFire); UFUNCTION(BlueprintCallable, Category = "Shooting") void Shoot(bool Trigger, int nextEventFire);
UFUNCTION(BlueprintCallable, Category = "Debug", meta = (ToolTip = "Trigger a simulated IMU shock for testing anti-recoil prediction. Requires DebugSimulateIMUShock to be enabled."))
void TriggerDebugIMUShock();
UFUNCTION(BlueprintCallable, meta = (AutoCreateRefTerm = "IgnoredActors"), Category = "Prediction") void PredictHit(bool& Hit, FHitResult& TraceResult, FVector& HitLocation, float& HitTime, AActor*& HitActor, TArray<FVector>& Trajectory, TSubclassOf<class AEBBullet> BulletClass, TArray<AActor*>IgnoredActors, float MaxTime = 10.0f, float Step = 0.1f) const; UFUNCTION(BlueprintCallable, meta = (AutoCreateRefTerm = "IgnoredActors"), Category = "Prediction") void PredictHit(bool& Hit, FHitResult& TraceResult, FVector& HitLocation, float& HitTime, AActor*& HitActor, TArray<FVector>& Trajectory, TSubclassOf<class AEBBullet> BulletClass, TArray<AActor*>IgnoredActors, float MaxTime = 10.0f, float Step = 0.1f) const;
UFUNCTION(BlueprintCallable, meta = (AutoCreateRefTerm = "IgnoredActors"), Category = "Prediction") void PredictHitFromLocation(bool &Hit, FHitResult& TraceResult, FVector& HitLocation, float& HitTime, AActor*& HitActor, TArray<FVector>& Trajectory, TSubclassOf<class AEBBullet> BulletClass, FVector StartLocation, FVector AimDirection, TArray<AActor*>IgnoredActors, float MaxTime = 10.0f, float Step = 0.1f) const; UFUNCTION(BlueprintCallable, meta = (AutoCreateRefTerm = "IgnoredActors"), Category = "Prediction") void PredictHitFromLocation(bool &Hit, FHitResult& TraceResult, FVector& HitLocation, float& HitTime, AActor*& HitActor, TArray<FVector>& Trajectory, TSubclassOf<class AEBBullet> BulletClass, FVector StartLocation, FVector AimDirection, TArray<AActor*>IgnoredActors, float MaxTime = 10.0f, float Step = 0.1f) const;
UFUNCTION(BlueprintCallable, Category = "Prediction") void CalculateAimDirection(TSubclassOf<class AEBBullet> BulletClass, FVector TargetLocation, FVector TargetVelocity, FVector& AimDirection, FVector& PredictedTargetLocation, FVector& PredictedIntersectionLocation, float& PredictedFlightTime, float& Error, float MaxTime = 10.0f, float Step = 0.1f, int NumIterations = 4) const; UFUNCTION(BlueprintCallable, Category = "Prediction") void CalculateAimDirection(TSubclassOf<class AEBBullet> BulletClass, FVector TargetLocation, FVector TargetVelocity, FVector& AimDirection, FVector& PredictedTargetLocation, FVector& PredictedIntersectionLocation, float& PredictedFlightTime, float& Error, float MaxTime = 10.0f, float Step = 0.1f, int NumIterations = 4) const;
@@ -228,18 +234,6 @@ private:
TArray<FTimestampedTransform> TransformHistory; TArray<FTimestampedTransform> TransformHistory;
// Debug IMU shock simulation state
double DebugIMUShockStartTime = 0.0;
bool DebugIMUShockActive = false;
FVector DebugIMUShockAimOffset = FVector::ZeroVector;
FVector DebugIMUShockPosOffset = FVector::ZeroVector;
// Debug yellow line persistence (shows uncorrected aim after shock)
bool DebugIMUShockLineCaptured = false;
double DebugIMUShockLineEndTime = 0.0;
FVector DebugIMUShockCapturedLocation = FVector::ZeroVector;
FVector DebugIMUShockCapturedAim = FVector::ForwardVector;
// Kalman filter state // Kalman filter state
FVector KalmanPosition = FVector::ZeroVector; FVector KalmanPosition = FVector::ZeroVector;
FVector KalmanVelocity = FVector::ZeroVector; FVector KalmanVelocity = FVector::ZeroVector;
@@ -255,7 +249,8 @@ private:
void UpdateTransformHistory(); void UpdateTransformHistory();
void ComputeAntiRecoilTransform(); void ComputeAntiRecoilTransform();
void PredictLinearExtrapolation(double CurrentTime, FVector& OutLocation, FVector& OutAim) const; void PredictLinearExtrapolation(double CurrentTime, FVector& OutLocation, FVector& OutAim) const;
void PredictWeightedRegression(double CurrentTime, FVector& OutLocation, FVector& OutAim) const; void PredictWeightedLinearRegression(double CurrentTime, FVector& OutLocation, FVector& OutAim) const;
void PredictAdaptiveExtrapolation(double CurrentTime, FVector& OutLocation, FVector& OutAim) const;
void UpdateKalmanFilter(double CurrentTime, const FVector& MeasuredLocation, const FVector& MeasuredAim); void UpdateKalmanFilter(double CurrentTime, const FVector& MeasuredLocation, const FVector& MeasuredAim);
void PredictKalmanFilter(double CurrentTime, FVector& OutLocation, FVector& OutAim) const; void PredictKalmanFilter(double CurrentTime, FVector& OutLocation, FVector& OutAim) const;

35
Unreal/build Lancelot.bat Normal file
View File

@@ -0,0 +1,35 @@
@echo off
chcp 65001 >nul
title Build PS_AI_Agent
echo ============================================================
echo PS_AI_Agent - Compilation plugin ElevenLabs (UE 5.5)
echo ============================================================
echo.
echo ATTENTION : Ferme l'Unreal Editor avant de continuer !
echo (Les DLL seraient verrouillees et la compilation echouerait)
echo.
pause
echo.
echo Compilation en cours...
echo (Seuls les .cpp modifies sont recompiles, ~16s)
echo.
powershell.exe -Command "& 'C:\Program Files\Epic Games\UE_5.5\Engine\Build\BatchFiles\RunUAT.bat' BuildEditor -project='E:\ASTERION\GIT\PS_Ballistics\Unreal\PS_Ballistics.uproject' -notools -noP4 2>&1"
echo.
if %ERRORLEVEL% == 0 (
echo ============================================================
echo SUCCES - Compilation terminee sans erreur.
echo Tu peux relancer l'Unreal Editor.
echo ============================================================
) else (
echo ============================================================
echo ECHEC - Erreur de compilation (code %ERRORLEVEL%)
echo Consulte le log ci-dessus pour le detail.
echo ============================================================
)
echo.
pause

1
test.txt Normal file
View File

@@ -0,0 +1 @@
Fichier de test