Skip to content

Commit 15bb300

Browse files
HelloHimclaude
andcommitted
fix(angle-calibration): keep one calibration across every panel
Angle Calibration is a single profile-wide setting that Gyro Mouse, Trackpad Mouse, Joystick Mouse, both Flick Sticks and the Flick Turn bind all read, and each panel keeps its own cached copy of it. Those copies had drifted apart. Editing RWC in Real World Calibration mode wrote the new value to the profile but never recomputed the panel's own Counts. The panel kept showing the pre-edit Counts, and switching to Counts mode afterwards re-derived RWC from that stale number, silently reverting the edit that had just been made. The Flick Turn panel already did this correctly; the shared panel now does the same. A Game Calibration Preset names an RWC and nothing else, so picking one now sets RWC and leaves In-Game Sensitivity exactly as the player set it, in both modes. From Counts mode that means Counts moves to whatever reproduces the preset's RWC at that sensitivity, rather than the sensitivity being rewritten to fit the Counts total. The Flick Turn panel's write reached the flick sticks and the camera turn outputs but skipped Gyro Mouse, so calibrating from there left gyro aiming on the old value until the profile was reloaded. It also never listened for calibration changed elsewhere, so its cached copy went stale and was written back over them on its next edit. Both are fixed, and every panel now follows the profile for as long as it is on screen: CalibrationModeControl attaches on Loaded and detaches on Unloaded, which also ends the subscriptions that used to be held for the life of the profile, one per panel rebuild. Two ways an untouched panel could corrupt the calibration by itself are closed as well. HandyControl's NumericUpDown fires ValueChanged with its Minimum while initialising, and the Flick Turn panel's master field did not guard against that init write, so opening the tab could push a zeroed RWC into the profile; the guard now covers every bound field, and re-arms per control rather than once per ViewModel. The mode's hidden field also keeps a live two-way binding and clamps whatever it is handed to its own Maximum before writing it back, so Counts totals past a million came back truncated from a field that was not even on screen; all three fields now share one range. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QtStWXc9oKZRmxXfEUzEfg
1 parent 43e551c commit 15bb300

6 files changed

Lines changed: 470 additions & 53 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
namespace DS4MapperTest.Common
2+
{
3+
// Angle Calibration is one profile-wide setting (Profile.CalibMode/CalibRwc/
4+
// CalibInGameSens/CalibCounts/CalibPresetName) surfaced by every panel that shows
5+
// CalibrationModeControl: Gyro, Stick/Trackpad Mouse, Stick/Touchpad Flick Stick,
6+
// Hybrid Aim and the Flick Turn output binding. Each of those panels caches the
7+
// values in its own ViewModel, so a panel has to listen to the profile while it is
8+
// on screen or it would show, and then write back, numbers another panel has since
9+
// replaced.
10+
//
11+
// CalibrationModeControl drives this from its own Loaded/Unloaded, so the listening
12+
// lasts exactly as long as the panel is in the visual tree instead of for the life
13+
// of the profile. Attach is expected to pull the current profile values back in, and
14+
// both calls are reference counted so several controls can share one ViewModel.
15+
public interface ICalibrationPanelViewModel
16+
{
17+
void AttachProfileCalibEvents();
18+
void DetachProfileCalibEvents();
19+
20+
// Called by the control before its own fields go live. HandyControl's NumericUpDown
21+
// fires ValueChanged(Minimum) while it initialises, before the binding has handed it
22+
// the real number, so a ViewModel has to ignore writes until the control settles or
23+
// that init write lands in the profile as a zeroed calibration. A ViewModel that
24+
// outlives its control -- a stick mode panel rebuilt when the user switches modes
25+
// and back, for example -- gets a fresh control and so needs a fresh window here.
26+
void BeginPanelInit();
27+
}
28+
}

DS4MapperTest/ViewModels/ButtonActionEditViewModel.cs

Lines changed: 109 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
namespace DS4MapperTest.ViewModels
1818
{
19-
public class ButtonActionEditViewModel : INotifyPropertyChanged
19+
public class ButtonActionEditViewModel : INotifyPropertyChanged, ICalibrationPanelViewModel
2020
{
2121
public event PropertyChangedEventHandler PropertyChanged;
2222
public enum ActionComboBoxTypes
@@ -422,9 +422,11 @@ public GameCalibPreset SelectedCameraTurnPreset
422422
_applyingCameraTurnPreset = true;
423423
if (IsCountsMode)
424424
{
425-
// Counts is this mode's fixed master: keep it as-is and let sensitivity
426-
// move to whatever value reproduces the preset's RWC at that Counts.
427-
if (CameraTurnCounts360 > 0.0) CameraTurnInGameSens = next.RWC * 360.0 / CameraTurnCounts360;
425+
// A preset only ever names an RWC, and In-Game Sensitivity is the player's
426+
// own game setting, so it stays exactly as they had it in either mode.
427+
// From Counts mode that means moving Counts to whatever reproduces the
428+
// preset's RWC at that sensitivity.
429+
if (CameraTurnInGameSens > 0.0) CameraTurnCounts360 = next.RWC * 360.0 / CameraTurnInGameSens;
428430
}
429431
else
430432
{
@@ -529,6 +531,11 @@ public double MasterCalibrationValue
529531
get => IsCountsMode ? CameraTurnCounts360 : CameraTurnRWC;
530532
set
531533
{
534+
// Same reason CameraTurnInGameSens and the preset check it: HandyControl's
535+
// NumericUpDown fires ValueChanged(Minimum) while it initialises, before the
536+
// binding has handed it the real number. Unguarded, that init write pushed a
537+
// zeroed RWC/Counts straight into the profile the moment this panel appeared.
538+
if (!_cameraTurnReady) return;
532539
if (IsCountsMode) CameraTurnCounts360 = value;
533540
else CameraTurnRWC = value;
534541
}
@@ -564,6 +571,78 @@ private void RaiseCalibModePropertyChanges()
564571
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DerivedValue)));
565572
}
566573

574+
// Reference counted for the same reason as GyroCalibrationViewModel's copy: the
575+
// Angle Calibration panel attaches while it is on screen and detaches when it goes,
576+
// so this editor never holds a subscription to a profile that outlives it.
577+
private int _calibPanelAttachCount = 0;
578+
579+
public void AttachProfileCalibEvents()
580+
{
581+
_calibPanelAttachCount++;
582+
if (_calibPanelAttachCount == 1)
583+
{
584+
mapper.ActionProfile.CalibModeChanged += ActionProfile_CalibModeChanged;
585+
mapper.ActionProfile.CalibPresetNameChanged += ActionProfile_CalibPresetNameChanged;
586+
mapper.ActionProfile.CalibRwcChanged += ActionProfile_CalibValuesChanged;
587+
mapper.ActionProfile.CalibInGameSensChanged += ActionProfile_CalibValuesChanged;
588+
mapper.ActionProfile.CalibCountsChanged += ActionProfile_CalibValuesChanged;
589+
}
590+
591+
// Calibration is one profile-wide setting, so whatever another panel left in the
592+
// profile is the current truth. BeginPanelInit reloads it and holds the fields
593+
// read-only until the freshly built control has finished initialising.
594+
BeginPanelInit();
595+
}
596+
597+
// Reloads the profile-wide calibration and keeps this panel's fields from writing
598+
// back until the control settles; see ICalibrationPanelViewModel.BeginPanelInit.
599+
public void BeginPanelInit()
600+
{
601+
_cameraTurnReady = false;
602+
LoadCameraTurnCalibFromProfile(updateSelectedSlot: true);
603+
RaiseCameraTurnCalibPropertiesChanged();
604+
System.Windows.Application.Current.Dispatcher.BeginInvoke(
605+
System.Windows.Threading.DispatcherPriority.Background,
606+
new Action(() =>
607+
{
608+
LoadCameraTurnCalibFromProfile(updateSelectedSlot: true);
609+
RaiseCameraTurnCalibPropertiesChanged();
610+
System.Windows.Application.Current.Dispatcher.BeginInvoke(
611+
System.Windows.Threading.DispatcherPriority.ApplicationIdle,
612+
new Action(() =>
613+
{
614+
LoadCameraTurnCalibFromProfile(updateSelectedSlot: true);
615+
_cameraTurnReady = true;
616+
RaiseCameraTurnCalibPropertiesChanged();
617+
}));
618+
}));
619+
}
620+
621+
public void DetachProfileCalibEvents()
622+
{
623+
if (_calibPanelAttachCount == 0) return;
624+
_calibPanelAttachCount--;
625+
if (_calibPanelAttachCount > 0) return;
626+
627+
mapper.ActionProfile.CalibModeChanged -= ActionProfile_CalibModeChanged;
628+
mapper.ActionProfile.CalibPresetNameChanged -= ActionProfile_CalibPresetNameChanged;
629+
mapper.ActionProfile.CalibRwcChanged -= ActionProfile_CalibValuesChanged;
630+
mapper.ActionProfile.CalibInGameSensChanged -= ActionProfile_CalibValuesChanged;
631+
mapper.ActionProfile.CalibCountsChanged -= ActionProfile_CalibValuesChanged;
632+
}
633+
634+
// A gyro, stick or touchpad panel changed the profile-wide calibration. Take those
635+
// values as they are: this editor shows the same setting, and its cached copy would
636+
// otherwise be written back over them the next time anything here is edited.
637+
// Skipped while this ViewModel is the one writing the profile, since its own
638+
// multi-field write leaves the profile briefly inconsistent between steps.
639+
private void ActionProfile_CalibValuesChanged(object sender, EventArgs e)
640+
{
641+
if (_syncingProfileCalib) return;
642+
LoadCameraTurnCalibFromProfile(updateSelectedSlot: true);
643+
RaiseCameraTurnCalibPropertiesChanged();
644+
}
645+
567646
private void ActionProfile_CalibModeChanged(object sender, EventArgs e)
568647
{
569648
RaiseCalibModePropertyChanges();
@@ -602,24 +681,7 @@ public bool ShowCameraTurnOptions
602681
showCameraTurnOptions = value;
603682
if (value)
604683
{
605-
_cameraTurnReady = false;
606-
LoadCameraTurnCalibFromProfile(updateSelectedSlot: true);
607-
RaiseCameraTurnCalibPropertiesChanged();
608-
System.Windows.Application.Current.Dispatcher.BeginInvoke(
609-
System.Windows.Threading.DispatcherPriority.Background,
610-
new Action(() =>
611-
{
612-
LoadCameraTurnCalibFromProfile(updateSelectedSlot: true);
613-
RaiseCameraTurnCalibPropertiesChanged();
614-
System.Windows.Application.Current.Dispatcher.BeginInvoke(
615-
System.Windows.Threading.DispatcherPriority.ApplicationIdle,
616-
new Action(() =>
617-
{
618-
LoadCameraTurnCalibFromProfile(updateSelectedSlot: true);
619-
_cameraTurnReady = true;
620-
RaiseCameraTurnCalibPropertiesChanged();
621-
}));
622-
}));
684+
BeginPanelInit();
623685
}
624686
ShowCameraTurnOptionsChanged?.Invoke(this, EventArgs.Empty);
625687
}
@@ -730,8 +792,9 @@ public ButtonActionEditViewModel(Mapper mapper, ButtonAction currentAction, Acti
730792
CameraTurnRWC = cameraTurnCalculatedRWC;
731793
});
732794

733-
mapper.ActionProfile.CalibModeChanged += ActionProfile_CalibModeChanged;
734-
mapper.ActionProfile.CalibPresetNameChanged += ActionProfile_CalibPresetNameChanged;
795+
// The calibration subscriptions belong to the Angle Calibration panel's own
796+
// Loaded/Unloaded (see AttachProfileCalibEvents), so this editor follows the
797+
// profile-wide values exactly while that panel is on screen.
735798
mapper.ActionProfile.OutputGamepadSettings.OutputGamepadChanged += OutputGamepadSettings_OutputGamepadChanged;
736799

737800
SetupEvents();
@@ -1863,20 +1926,39 @@ private void UpdateCameraTurnPresetFromCurrentRwc()
18631926
mapper.ActionProfile.CalibPresetName = matchedName;
18641927
}
18651928

1929+
private bool _syncingProfileCalib = false;
1930+
18661931
private void SyncCalibFromCameraTurnToProfile()
18671932
{
18681933
double counts = cameraTurnCounts360;
18691934
double inGameSens = cameraTurnInGameSens;
18701935
double rwc = inGameSens > 0.0 ? inGameSens * counts / 360.0 : 0.0;
1871-
mapper.ActionProfile.CalibCounts = counts;
1872-
mapper.ActionProfile.CalibInGameSens = inGameSens;
1873-
mapper.ActionProfile.CalibRwc = rwc;
1936+
// Guards ActionProfile_CalibValuesChanged against this instance's own writes:
1937+
// the three profile fields are written one at a time, so reloading from the
1938+
// profile between them would pull back a half-updated calibration.
1939+
_syncingProfileCalib = true;
1940+
try
1941+
{
1942+
mapper.ActionProfile.CalibCounts = counts;
1943+
mapper.ActionProfile.CalibInGameSens = inGameSens;
1944+
mapper.ActionProfile.CalibRwc = rwc;
1945+
}
1946+
finally { _syncingProfileCalib = false; }
18741947
mapper.ProcessMappingChangeAction(() =>
18751948
{
18761949
foreach (var set in mapper.ActionProfile.ActionSets)
18771950
foreach (var layer in set.ActionLayers)
18781951
foreach (var mapAction in layer.normalActionDict.Values)
18791952
{
1953+
// Gyro Mouse reads the same profile calibration as the flick stick
1954+
// and camera turn outputs, so an edit made from this panel has to
1955+
// reach its live params too; leaving it out kept gyro aiming on the
1956+
// pre-edit calibration until the profile was reloaded.
1957+
if (mapAction is GyroMouse gyroMouse)
1958+
{
1959+
gyroMouse.mouseParams.realWorldCalibration = rwc;
1960+
gyroMouse.mouseParams.inGameSens = inGameSens;
1961+
}
18801962
if (mapAction is ButtonAction ba)
18811963
foreach (var func in ba.ActionFuncs)
18821964
foreach (var data in func.OutputActions)

0 commit comments

Comments
 (0)