SteamController: Use Roslyn Scripting to compile UserProfiles

- This looks into `UserProfiles/` and compiles user profiles
- This exposes a very minimal scripting interface as defined by `Dynamic.Globals`
This commit is contained in:
Kamil Trzciński 2022-12-10 14:39:42 +01:00
parent b24ae302b1
commit e5debff45b
8 changed files with 504 additions and 11 deletions

View file

@ -10,6 +10,12 @@ namespace SteamController
public const String Title = "Steam Controller";
public static readonly String TitleWithVersion = Title + " v" + Application.ProductVersion.ToString();
public static readonly Dictionary<String, Profiles.Profile> PreconfiguredUserProfiles = new Dictionary<String, Profiles.Profile>()
{
{ "*.desktop.cs", new Profiles.Predefined.DesktopProfile() { Name = "Desktop" } },
{ "*.x360.cs", new Profiles.Predefined.X360HapticProfile() { Name = "X360" } }
};
Container components = new Container();
NotifyIcon notifyIcon;
StartupManager startupManager = new StartupManager(Title);
@ -55,17 +61,39 @@ namespace SteamController
startupManager.Startup = false;
});
// Set available profiles
ProfilesSettings.Helpers.ProfileStringConverter.Profiles = context.Profiles.
Where((profile) => profile.Visible).
Select((profile) => profile.Name).ToArray();
Instance.RunOnce(TitleWithVersion, "Global\\SteamController");
Instance.RunUpdater(TitleWithVersion);
if (Instance.WantsRunOnStartup)
startupManager.Startup = true;
notifyIcon = new NotifyIcon(components);
notifyIcon.Icon = WindowsDarkMode.IsDarkModeEnabled ? Resources.microsoft_xbox_controller_off_white : Resources.microsoft_xbox_controller_off;
notifyIcon.Text = TitleWithVersion;
notifyIcon.Visible = true;
#if DEBUG
foreach (var profile in Profiles.Dynamic.RoslynDynamicProfile.GetUserProfiles(PreconfiguredUserProfiles))
{
profile.ErrorsChanged += (errors) =>
{
notifyIcon.ShowBalloonTip(
3000, profile.Name,
String.Join("\n", errors),
ToolTipIcon.Error
);
};
profile.Compile();
profile.Watch();
context.Profiles.Add(profile);
}
#endif
// Set available profiles
ProfilesSettings.Helpers.ProfileStringConverter.Profiles = context.Profiles.
Where((profile) => profile.Visible).
Select((profile) => profile.Name).ToArray();
var contextMenu = new ContextMenuStrip(components);
var enabledItem = new ToolStripMenuItem("&Enabled");
@ -81,7 +109,12 @@ namespace SteamController
var profileItem = new ToolStripMenuItem(profile.Name);
profileItem.Click += delegate { context.SelectProfile(profile.Name); };
contextMenu.Opening += delegate { profileItem.Checked = context.CurrentProfile == profile; };
contextMenu.Opening += delegate
{
profileItem.Checked = context.CurrentProfile == profile;
profileItem.ToolTipText = String.Join("\n", profile.Errors ?? new string[0]);
profileItem.Enabled = profile.Errors is null;
};
contextMenu.Items.Add(profileItem);
}
@ -116,10 +149,6 @@ namespace SteamController
var exitItem = contextMenu.Items.Add("&Exit");
exitItem.Click += delegate { Application.Exit(); };
notifyIcon = new NotifyIcon(components);
notifyIcon.Icon = WindowsDarkMode.IsDarkModeEnabled ? Resources.microsoft_xbox_controller_off_white : Resources.microsoft_xbox_controller_off;
notifyIcon.Text = TitleWithVersion;
notifyIcon.Visible = true;
notifyIcon.ContextMenuStrip = contextMenu;
var contextStateUpdate = new System.Windows.Forms.Timer(components);

View file

@ -149,7 +149,7 @@ namespace SteamController.Devices
/// Generated when button was repeated for a given period
/// but triggered exactly once
public bool HoldRepeat(TimeSpan duration, TimeSpan repeatEvery, string consume)
public bool HoldRepeat(TimeSpan duration, TimeSpan repeatEvery, string? consume)
{
// always generate at least one keypress
if (Pressed(duration))

View file

@ -14,6 +14,7 @@ namespace SteamController.Devices
private Stopwatch stopwatch = new Stopwatch();
private TimeSpan? lastUpdate;
private int failures;
public long ElapsedMilliseconds { get => stopwatch.ElapsedMilliseconds; }
public double DeltaTime { get; private set; }
internal SteamController()

View file

@ -0,0 +1,244 @@
using Nefarius.ViGEm.Client.Targets.Xbox360;
using SteamController.ProfilesSettings;
namespace SteamController.Profiles.Dynamic
{
[Flags]
public enum KeyModifiers
{
MOD_None = 0,
MOD_SHIFT = 1,
MOD_ALT = 2,
MOD_CONTROL = 4,
MOD_WIN = 8
}
public class Globals
{
private const string Consumed = "RoslynGlobals";
private RoslynDynamicProfile _profile;
private Context _context;
public class SteamAPI
{
internal Devices.SteamController Target;
internal SteamAPI(Devices.SteamController target) { Target = target; }
public struct Button
{
internal Devices.SteamButton Target;
public static implicit operator bool(Button button) => button.Target;
public Button(Devices.SteamButton target) { Target = target; }
}
public struct Axis
{
internal Devices.SteamAxis Target;
public static implicit operator short(Axis button) => button.Target;
public Axis(Devices.SteamAxis target) { Target = target; }
}
public Button BtnL5 { get => new Button(Target.BtnL5); }
public Button BtnOptions { get => new Button(Target.BtnOptions); }
public Button BtnSteam { get => new Button(Target.BtnSteam); }
public Button BtnMenu { get => new Button(Target.BtnMenu); }
public Button BtnDpadDown { get => new Button(Target.BtnDpadDown); }
public Button BtnDpadLeft { get => new Button(Target.BtnDpadLeft); }
public Button BtnDpadRight { get => new Button(Target.BtnDpadRight); }
public Button BtnDpadUp { get => new Button(Target.BtnDpadUp); }
public Button BtnA { get => new Button(Target.BtnA); }
public Button BtnX { get => new Button(Target.BtnX); }
public Button BtnB { get => new Button(Target.BtnB); }
public Button BtnY { get => new Button(Target.BtnY); }
public Button BtnL1 { get => new Button(Target.BtnL1); }
public Button BtnL2 { get => new Button(Target.BtnL2); }
public Button BtnR1 { get => new Button(Target.BtnR1); }
public Button BtnR2 { get => new Button(Target.BtnR2); }
public Button BtnLeftStickPress { get => new Button(Target.BtnLeftStickPress); }
public Button BtnLPadTouch { get => new Button(Target.BtnLPadTouch); }
public Button BtnLPadPress { get => new Button(Target.BtnLPadPress); }
public Button BtnRPadPress { get => new Button(Target.BtnRPadPress); }
public Button BtnRPadTouch { get => new Button(Target.BtnRPadTouch); }
public Button BtnR5 { get => new Button(Target.BtnR5); }
public Button BtnRightStickPress { get => new Button(Target.BtnRightStickPress); }
public Button BtnLStickTouch { get => new Button(Target.BtnLStickTouch); }
public Button BtnRStickTouch { get => new Button(Target.BtnRStickTouch); }
public Button BtnR4 { get => new Button(Target.BtnR4); }
public Button BtnL4 { get => new Button(Target.BtnL4); }
public Button BtnQuickAccess { get => new Button(Target.BtnQuickAccess); }
public Button BtnVirtualLeftThumbUp { get => new Button(Target.BtnVirtualLeftThumbUp); }
public Button BtnVirtualLeftThumbDown { get => new Button(Target.BtnVirtualLeftThumbDown); }
public Button BtnVirtualLeftThumbLeft { get => new Button(Target.BtnVirtualLeftThumbLeft); }
public Button BtnVirtualLeftThumbRight { get => new Button(Target.BtnVirtualLeftThumbRight); }
public Axis LPadX { get => new Axis(Target.LPadX); }
public Axis LPadY { get => new Axis(Target.LPadY); }
public Axis RPadX { get => new Axis(Target.RPadX); }
public Axis RPadY { get => new Axis(Target.RPadY); }
public Axis AccelX { get => new Axis(Target.AccelX); }
public Axis AccelY { get => new Axis(Target.AccelY); }
public Axis AccelZ { get => new Axis(Target.AccelZ); }
public Axis GyroPitch { get => new Axis(Target.GyroPitch); }
public Axis GyroYaw { get => new Axis(Target.GyroYaw); }
public Axis GyroRoll { get => new Axis(Target.GyroRoll); }
public Axis LeftTrigger { get => new Axis(Target.LeftTrigger); }
public Axis RightTrigger { get => new Axis(Target.RightTrigger); }
public Axis LeftThumbX { get => new Axis(Target.LeftThumbX); }
public Axis LeftThumbY { get => new Axis(Target.LeftThumbY); }
public Axis RightThumbX { get => new Axis(Target.RightThumbX); }
public Axis RightThumbY { get => new Axis(Target.RightThumbY); }
public Axis LPadPressure { get => new Axis(Target.LPadPressure); }
public Axis RPadPressure { get => new Axis(Target.RPadPressure); }
}
public class KeyboardAPI
{
internal Devices.KeyboardController Target;
internal KeyboardAPI(Devices.KeyboardController target) { Target = target; }
public bool this[VirtualKeyCode key]
{
get { return Target[key.ToWindowsInput()]; }
set { Target.Overwrite(key.ToWindowsInput(), value); }
}
public void KeyPress(params VirtualKeyCode[] keyCodes)
{
KeyPress(KeyModifiers.MOD_None, keyCodes);
}
public void KeyPress(KeyModifiers modifiers, params VirtualKeyCode[] keyCodes)
{
var virtualCodes = keyCodes.Select((code) => (WindowsInput.VirtualKeyCode)code).ToArray();
if (modifiers != KeyModifiers.MOD_None)
{
List<WindowsInput.VirtualKeyCode> modifierCodes = new List<WindowsInput.VirtualKeyCode>();
if (modifiers.HasFlag(KeyModifiers.MOD_SHIFT))
modifierCodes.Add(WindowsInput.VirtualKeyCode.SHIFT);
if (modifiers.HasFlag(KeyModifiers.MOD_CONTROL))
modifierCodes.Add(WindowsInput.VirtualKeyCode.CONTROL);
if (modifiers.HasFlag(KeyModifiers.MOD_ALT))
modifierCodes.Add(WindowsInput.VirtualKeyCode.MENU);
if (modifiers.HasFlag(KeyModifiers.MOD_WIN))
modifierCodes.Add(WindowsInput.VirtualKeyCode.LWIN);
Target.KeyPress(modifierCodes, virtualCodes);
}
else
{
Target.KeyPress(virtualCodes);
}
}
}
public class MouseAPI
{
internal Devices.MouseController Target;
internal MouseAPI(Devices.MouseController target) { Target = target; }
public struct Button
{
internal Devices.MouseController? Controller = null;
internal Devices.MouseController.Button Target = Devices.MouseController.Button.Left;
internal bool Value;
internal Button(Devices.MouseController controller, Devices.MouseController.Button target) { Controller = controller; Target = target; Value = Controller[Target]; }
internal Button(bool value) { this.Value = value; }
public void Click() { Controller?.MouseClick(Target); }
public void DoubleClick() { Controller?.MouseClick(Target); }
internal void Set(Button value) { Controller?.Overwrite(Target, value.Value); }
public static implicit operator Button(bool value) { return new Button(value); }
}
public Button BtnLeft { get => new Button(Target, Devices.MouseController.Button.Left); set => this.BtnLeft.Set(value); }
public Button BtnRight { get => new Button(Target, Devices.MouseController.Button.Right); set => this.BtnRight.Set(value); }
public Button BtnMiddle { get => new Button(Target, Devices.MouseController.Button.Middle); set => this.BtnMiddle.Set(value); }
public Button BtnX { get => new Button(Target, Devices.MouseController.Button.X); set => this.BtnX.Set(value); }
public Button BtnY { get => new Button(Target, Devices.MouseController.Button.Y); set => this.BtnY.Set(value); }
public void MoveBy(double pixelDeltaX, double pixelDeltaY) { Target.MoveBy(pixelDeltaX, pixelDeltaY); }
public void MoveTo(double absoluteX, double absoluteY) { Target.MoveTo(absoluteX, absoluteY); }
public void VerticalScroll(double scrollAmountInClicks) { Target.VerticalScroll(scrollAmountInClicks); }
public void HorizontalScroll(double scrollAmountInClicks) { Target.HorizontalScroll(scrollAmountInClicks); }
}
public class X360API
{
internal const int MinimumPresTimeMilliseconds = 30;
internal Devices.Xbox360Controller Target;
internal X360API(Devices.Xbox360Controller target) { Target = target; }
public bool Connected { set => Target.Connected = value; }
public bool BtnUp { set => Target.Overwrite(Xbox360Button.Up, value, MinimumPresTimeMilliseconds); }
public bool BtnDown { set => Target.Overwrite(Xbox360Button.Down, value, MinimumPresTimeMilliseconds); }
public bool BtnLeft { set => Target.Overwrite(Xbox360Button.Left, value, MinimumPresTimeMilliseconds); }
public bool BtnRight { set => Target.Overwrite(Xbox360Button.Right, value, MinimumPresTimeMilliseconds); }
public bool BtnA { set => Target.Overwrite(Xbox360Button.A, value, MinimumPresTimeMilliseconds); }
public bool BtnB { set => Target.Overwrite(Xbox360Button.B, value, MinimumPresTimeMilliseconds); }
public bool BtnX { set => Target.Overwrite(Xbox360Button.X, value, MinimumPresTimeMilliseconds); }
public bool BtnY { set => Target.Overwrite(Xbox360Button.Y, value, MinimumPresTimeMilliseconds); }
public bool BtnGuide { set => Target.Overwrite(Xbox360Button.Guide, value, MinimumPresTimeMilliseconds); }
public bool BtnBack { set => Target.Overwrite(Xbox360Button.Back, value, MinimumPresTimeMilliseconds); }
public bool BtnStart { set => Target.Overwrite(Xbox360Button.Start, value, MinimumPresTimeMilliseconds); }
public bool BtnLeftShoulder { set => Target.Overwrite(Xbox360Button.LeftShoulder, value, MinimumPresTimeMilliseconds); }
public bool BtnRightShoulder { set => Target.Overwrite(Xbox360Button.RightShoulder, value, MinimumPresTimeMilliseconds); }
public bool BtnLeftThumb { set => Target.Overwrite(Xbox360Button.LeftThumb, value, MinimumPresTimeMilliseconds); }
public bool BtnRightThumb { set => Target.Overwrite(Xbox360Button.RightThumb, value, MinimumPresTimeMilliseconds); }
public short AxisLeftThumbX { set => Target[Xbox360Axis.LeftThumbX] = value; }
public short AxisLeftThumbY { set => Target[Xbox360Axis.LeftThumbY] = value; }
public short AxisRightThumbX { set => Target[Xbox360Axis.RightThumbX] = value; }
public short AxisRightThumbY { set => Target[Xbox360Axis.RightThumbY] = value; }
public short SliderLeftTrigger { set => Target[Xbox360Slider.LeftTrigger] = value; }
public short SliderRightTrigger { set => Target[Xbox360Slider.RightTrigger] = value; }
}
private SteamAPI? _steamAPI;
private X360API? _x360API;
private KeyboardAPI? _keyboardAPI;
private MouseAPI? _mouseAPI;
public SteamAPI Steam { get => _steamAPI ??= new SteamAPI(_context.Steam); }
public X360API X360 { get => _x360API ??= new X360API(_context.X360); }
public KeyboardAPI Keyboard { get => _keyboardAPI ??= new KeyboardAPI(_context.Keyboard); }
public MouseAPI Mouse { get => _mouseAPI ??= new MouseAPI(_context.Mouse); }
public Globals(RoslynDynamicProfile profile, Context context)
{
this._profile = profile;
this._context = context;
}
public bool Pressed(SteamAPI.Button button)
{
return button.Target.Pressed();
}
public bool JustPressed(SteamAPI.Button button)
{
return button.Target.JustPressed();
}
public bool HoldFor(SteamAPI.Button button, int minTimeMs)
{
return button.Target.Hold(TimeSpan.FromMilliseconds(minTimeMs), null);
}
public bool Turbo(SteamAPI.Button button, int timesPerSec)
{
var interval = TimeSpan.FromMilliseconds(1000 / timesPerSec);
return button.Target.JustPressed() || button.Target.HoldRepeat(interval, interval, null);
}
public void Log(string format, params object?[] arg)
{
var output = String.Format(format, arg);
CommonHelpers.Log.TraceLine("{0}: {1}: {2}", _profile.Name, _context.Steam.ElapsedMilliseconds, output);
}
}
}

View file

@ -0,0 +1,186 @@
using System.Reflection;
using CommonHelpers;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
namespace SteamController.Profiles.Dynamic
{
public sealed partial class RoslynDynamicProfile : Profile
{
private const int ScriptTimeout = 10; // max 10ms
private String fileName;
private Script? compiledScript;
private Profile? inherited;
private DateTime? lastModifiedTime;
private System.Windows.Forms.Timer? watchTimer;
public RoslynDynamicProfile(string name, string fileName, Profile? inherited = null)
{
this.fileName = fileName;
this.inherited = inherited;
this.Name = name;
if (inherited is not null)
this.Name = inherited.Name + ": " + name;
this.Visible = inherited?.Visible ?? true;
this.IsDesktop = inherited?.IsDesktop ?? false;
}
private static ScriptOptions CompilationOptions
{
get
{
var options = ScriptOptions.Default;
// Add Keyboard controls
options = options.AddReferences(typeof(KeyModifiers).Assembly);
options = options.AddImports(typeof(KeyModifiers).FullName);
options = options.AddReferences(typeof(ProfilesSettings.VirtualKeyCode).Assembly);
options = options.AddImports(typeof(ProfilesSettings.VirtualKeyCode).FullName);
return options;
}
}
public bool Compile()
{
this.compiledScript = null;
this.lastModifiedTime = null;
this.Errors = null;
if (!File.Exists(fileName))
{
this.Errors = new string[] { String.Format("File '{0}' does not exist.", fileName) };
return false;
}
try
{
this.lastModifiedTime = File.GetLastWriteTimeUtc(fileName);
using (var file = System.IO.File.OpenRead(fileName))
{
var options = CompilationOptions.WithFilePath(Path.GetFileName(fileName));
var script = CSharpScript.Create(file, options, typeof(Globals));
var compileResult = script.Compile();
var errors = compileResult.Where((result) => result.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error);
if (!errors.Any())
{
this.compiledScript = script;
return true;
}
this.Errors = errors.Select((result) => result.ToString()).ToArray();
OnErrorsChanged();
}
}
catch (Exception e)
{
this.Errors = new string[] { e.Message };
OnErrorsChanged();
}
Log.TraceLine("UserProfile: {0}: Compilation Error", fileName);
foreach (var error in this.Errors)
Log.TraceLine("\t{0}", error);
return false;
}
public void Watch()
{
if (this.lastModifiedTime is null)
return;
if (this.watchTimer is not null)
return;
watchTimer = new System.Windows.Forms.Timer();
watchTimer.Interval = 1000;
watchTimer.Tick += delegate
{
try
{
if (this.lastModifiedTime is null)
return;
var latest = File.GetLastWriteTimeUtc(fileName);
if (this.lastModifiedTime >= latest)
return;
Log.TraceLine("UserProfile: {0}. Detected modification: '{1}' vs '{2}'", fileName, this.lastModifiedTime, latest);
}
catch (Exception) { return; }
Compile();
};
watchTimer.Start();
}
public override bool Selected(Context context)
{
return (this.compiledScript is not null) && (inherited?.Selected(context) ?? true);
}
public override Status Run(Context context)
{
if (inherited?.Run(context).IsDone ?? false)
return Status.Done;
if (this.compiledScript is null)
return Status.Continue;
try
{
var cancelToken = new CancellationTokenSource();
var task = this.compiledScript.RunAsync(new Globals(this, context), cancelToken.Token);
if (!task.Wait(ScriptTimeout))
{
cancelToken.Cancel();
task.Wait();
Log.TraceLine("UserProfile: {0}: Timedout. Canceled.");
}
}
catch (Exception e)
{
Log.TraceLine("UserProfile: {0}: {1}", fileName, e);
}
return Status.Continue;
}
public static IEnumerable<RoslynDynamicProfile> GetUserProfiles(Dictionary<String, Profile> preconfiguredProfiles)
{
var files = new Dictionary<String, String>();
foreach (var directory in GetUserProfilesPaths())
{
foreach (var profile in preconfiguredProfiles)
{
foreach (string file in Directory.GetFiles(directory, profile.Key))
{
String name = Path.GetFileNameWithoutExtension(file);
name = Path.GetFileNameWithoutExtension(name);
yield return new RoslynDynamicProfile(name, file, profile.Value);
}
}
}
}
private static IEnumerable<String> GetUserProfilesPaths()
{
var exePath = Assembly.GetExecutingAssembly().Location;
var exeFolder = Path.GetDirectoryName(exePath);
if (exeFolder is not null)
{
var exeUserProfiles = Path.Combine(exeFolder, "UserProfiles");
if (Directory.Exists(exeUserProfiles))
yield return exeUserProfiles;
}
var documentsFolder = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
var steamControllerDocumentsFolder = Path.Combine(documentsFolder, "SteamController", "UserProfiles");
if (Directory.Exists(steamControllerDocumentsFolder))
yield return steamControllerDocumentsFolder;
}
}
}

View file

@ -10,12 +10,25 @@ namespace SteamController.Profiles
public bool IsDone { get; set; }
}
public event Action<string[]> ErrorsChanged;
public virtual String Name { get; set; } = "";
public virtual bool Visible { get; set; } = true;
public virtual bool IsDesktop { get; set; }
public virtual string[]? Errors { get; set; }
public abstract bool Selected(Context context);
public abstract Status Run(Context context);
public Profile()
{
ErrorsChanged += delegate { };
}
protected void OnErrorsChanged()
{
ErrorsChanged(this.Errors ?? new string[0]);
}
}
}

View file

@ -10,6 +10,10 @@
<ApplicationIcon>Resources\microsoft-xbox-controller.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<Compile Remove="UserProfiles\Turbo.x360.cs" />
</ItemGroup>
<ItemGroup>
<None Remove="app.manifest" />
</ItemGroup>
@ -18,8 +22,15 @@
<AdditionalFiles Include="app.manifest" />
</ItemGroup>
<ItemGroup>
<Content Include="UserProfiles\Turbo.x360.cs">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="H.InputSimulator" Version="1.3.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.4.0" />
<PackageReference Include="Nefarius.ViGEm.Client" Version="1.21.232" />
</ItemGroup>

View file

@ -0,0 +1,9 @@
// If L5 is hold, the A, B, X, Y is turbo: 10x per second
if (Steam.BtnL5)
{
X360.BtnA = Turbo(Steam.BtnA, 10);
X360.BtnB = Turbo(Steam.BtnB, 10);
X360.BtnX = Turbo(Steam.BtnX, 10);
X360.BtnY = Turbo(Steam.BtnY, 10);
}