using System;
using UnityEngine.Events;
using UnityEngine.InputSystem.Layouts;
using UnityEngine.InputSystem.LowLevel;
////TODO: allow multiple device paths
////TODO: streaming support
////REVIEW: consider this for inclusion directly in the input system
namespace UnityEngine.InputSystem
{
///
/// A wrapper component around that provides an easy interface for recording input
/// from a GameObject.
///
///
/// This component comes with a custom inspector that provides an easy recording and playback interface and also
/// gives feedback about what has been recorded in the trace. The interface also allows saving and loading event
/// traces.
///
/// Capturing can either be constrained by a or capture all input occuring in the system.
///
/// Replay by default will happen frame by frame (see ).
/// If frame markers are disabled (see ), all events are queued right away in the first
/// frame and replay completes immediately.
///
/// Other than frame-by-frame, replay can be made to happen in a way that tries to simulate the original input
/// timing. To do so, enable . This will make use of
///
public class InputRecorder : MonoBehaviour
{
///
/// Whether a capture is currently in progress.
///
/// True if a capture is in progress.
public bool captureIsRunning => m_EventTrace != null && m_EventTrace.enabled;
///
/// Whether a replay is currently being run by the component.
///
/// True if replay is running.
///
///
///
public bool replayIsRunning => m_ReplayController != null && !m_ReplayController.finished;
///
/// If true, input recording is started immediately when the component is enabled. Disabled by default.
/// Call to manually start capturing.
///
/// True if component will start recording automatically in .
///
public bool startRecordingWhenEnabled
{
get => m_StartRecordingWhenEnabled;
set
{
m_StartRecordingWhenEnabled = value;
if (value && enabled && !captureIsRunning)
StartCapture();
}
}
///
/// Total number of events captured.
///
/// Number of captured events.
public long eventCount => m_EventTrace?.eventCount ?? 0;
///
/// Total size of captured events.
///
/// Size of captured events in bytes.
public long totalEventSizeInBytes => m_EventTrace?.totalEventSizeInBytes ?? 0;
///
/// Total size of capture memory currently allocated.
///
/// Size of memory allocated for capture.
public long allocatedSizeInBytes => m_EventTrace?.allocatedSizeInBytes ?? 0;
///
/// Whether to record frame marker events when capturing input. Enabled by default.
///
/// True if frame marker events will be recorded.
///
public bool recordFrames
{
get => m_RecordFrames;
set
{
if (m_RecordFrames == value)
return;
m_RecordFrames = value;
if (m_EventTrace != null)
m_EventTrace.recordFrameMarkers = m_RecordFrames;
}
}
///
/// Whether to record only s and s. Disabled by
/// default.
///
/// True if anything but state events should be ignored.
public bool recordStateEventsOnly
{
get => m_RecordStateEventsOnly;
set => m_RecordStateEventsOnly = value;
}
///
/// Path that constrains the devices to record from.
///
/// Input control path to match devices or null/empty.
///
/// By default, this is not set. Meaning that input will be recorded from all devices. By setting this property
/// to a path, only events for devices that match the given path (as dictated by )
/// will be recorded from.
///
/// By setting this property to the exact path of a device at runtime, recording can be restricted to just that
/// device.
///
///
///
public string devicePath
{
get => m_DevicePath;
set => m_DevicePath = value;
}
public string recordButtonPath
{
get => m_RecordButtonPath;
set
{
m_RecordButtonPath = value;
HookOnInputEvent();
}
}
public string playButtonPath
{
get => m_PlayButtonPath;
set
{
m_PlayButtonPath = value;
HookOnInputEvent();
}
}
///
/// The underlying event trace that contains the captured input events.
///
/// Underlying event trace.
///
/// This will be null if no capture is currently associated with the recorder.
///
public InputEventTrace capture => m_EventTrace;
///
/// The replay controller for when a replay is running.
///
/// Replay controller for the event trace while replay is running.
///
///
public InputEventTrace.ReplayController replay => m_ReplayController;
public int replayPosition
{
get
{
if (m_ReplayController != null)
return m_ReplayController.position;
return 0;
}
////TODO: allow setting replay position
}
///
/// Whether a replay should create new devices or replay recorded events as is. Disabled by default.
///
/// True if replay should temporary create new devices.
///
public bool replayOnNewDevices
{
get => m_ReplayOnNewDevices;
set => m_ReplayOnNewDevices = value;
}
///
/// Whether to attempt to re-create the original event timing when replaying events. Disabled by default.
///
/// If true, events are queued based on their timestamp rather than based on their recorded frames (if any).
///
public bool simulateOriginalTimingOnReplay
{
get => m_SimulateOriginalTimingOnReplay;
set => m_SimulateOriginalTimingOnReplay = value;
}
public ChangeEvent changeEvent
{
get
{
if (m_ChangeEvent == null)
m_ChangeEvent = new ChangeEvent();
return m_ChangeEvent;
}
}
public void StartCapture()
{
if (m_EventTrace != null && m_EventTrace.enabled)
return;
CreateEventTrace();
m_EventTrace.Enable();
m_ChangeEvent?.Invoke(Change.CaptureStarted);
}
public void StopCapture()
{
if (m_EventTrace != null && m_EventTrace.enabled)
{
m_EventTrace.Disable();
m_ChangeEvent?.Invoke(Change.CaptureStopped);
}
}
public void StartReplay()
{
if (m_EventTrace == null)
return;
if (replayIsRunning && replay.paused)
{
replay.paused = false;
return;
}
StopCapture();
// Configure replay controller.
m_ReplayController = m_EventTrace.Replay()
.OnFinished(StopReplay)
.OnEvent(_ => m_ChangeEvent?.Invoke(Change.EventPlayed));
if (m_ReplayOnNewDevices)
m_ReplayController.WithAllDevicesMappedToNewInstances();
// Start replay.
if (m_SimulateOriginalTimingOnReplay)
m_ReplayController.PlayAllEventsAccordingToTimestamps();
else
m_ReplayController.PlayAllFramesOneByOne();
m_ChangeEvent?.Invoke(Change.ReplayStarted);
}
public void StopReplay()
{
if (m_ReplayController != null)
{
m_ReplayController.Dispose();
m_ReplayController = null;
m_ChangeEvent?.Invoke(Change.ReplayStopped);
}
}
public void PauseReplay()
{
if (m_ReplayController != null)
m_ReplayController.paused = true;
}
public void ClearCapture()
{
m_EventTrace?.Clear();
}
public void LoadCaptureFromFile(string fileName)
{
if (string.IsNullOrEmpty(fileName))
throw new ArgumentNullException(nameof(fileName));
CreateEventTrace();
m_EventTrace.ReadFrom(fileName);
}
public void SaveCaptureToFile(string fileName)
{
if (string.IsNullOrEmpty(fileName))
throw new ArgumentNullException(nameof(fileName));
m_EventTrace?.WriteTo(fileName);
}
protected void OnEnable()
{
// Hook InputSystem.onEvent before the event trace does.
HookOnInputEvent();
if (m_StartRecordingWhenEnabled)
StartCapture();
}
protected void OnDisable()
{
StopCapture();
StopReplay();
UnhookOnInputEvent();
}
protected void OnDestroy()
{
m_ReplayController?.Dispose();
m_ReplayController = null;
m_EventTrace?.Dispose();
m_EventTrace = null;
}
private bool OnFilterInputEvent(InputEventPtr eventPtr, InputDevice device)
{
// Filter out non-state events, if enabled.
if (m_RecordStateEventsOnly && !eventPtr.IsA() && !eventPtr.IsA())
return false;
// Match device path, if set.
if (string.IsNullOrEmpty(m_DevicePath) || device == null)
return true;
return InputControlPath.MatchesPrefix(m_DevicePath, device);
}
private void OnEventRecorded(InputEventPtr eventPtr)
{
m_ChangeEvent?.Invoke(Change.EventCaptured);
}
private void OnInputEvent(InputEventPtr eventPtr, InputDevice device)
{
if (!eventPtr.IsA() && !eventPtr.IsA())
return;
if (!string.IsNullOrEmpty(m_PlayButtonPath))
{
var playControl = InputControlPath.TryFindControl(device, m_PlayButtonPath) as InputControl;
if (playControl != null && playControl.ReadValueFromEvent(eventPtr) >= InputSystem.settings.defaultButtonPressPoint)
{
if (replayIsRunning)
StopReplay();
else
StartReplay();
eventPtr.handled = true;
}
}
if (!string.IsNullOrEmpty(m_RecordButtonPath))
{
var recordControl = InputControlPath.TryFindControl(device, m_RecordButtonPath) as InputControl;
if (recordControl != null && recordControl.ReadValueFromEvent(eventPtr) >= InputSystem.settings.defaultButtonPressPoint)
{
if (captureIsRunning)
StopCapture();
else
StartCapture();
eventPtr.handled = true;
}
}
}
#if UNITY_EDITOR
protected void OnValidate()
{
if (m_EventTrace != null)
m_EventTrace.recordFrameMarkers = m_RecordFrames;
}
#endif
[SerializeField] private bool m_StartRecordingWhenEnabled = false;
[Tooltip("If enabled, additional events will be recorded that demarcate frame boundaries. When replaying, this allows "
+ "spacing out input events across frames corresponding to the original distribution across frames when input was "
+ "recorded. If this is turned off, all input events will be queued in one block when replaying the trace.")]
[SerializeField] private bool m_RecordFrames = true;
[Tooltip("If enabled, new devices will be created for captured events when replaying them. If disabled (default), "
+ "events will be queued as is and thus keep their original device ID.")]
[SerializeField] private bool m_ReplayOnNewDevices;
[Tooltip("If enabled, the system will try to simulate the original event timing on replay. This differs from replaying frame "
+ "by frame in that replay will try to compensate for differences in frame timings and redistribute events to frames that "
+ "more closely match the original timing. Note that this is not perfect and will not necessarily create a 1:1 match.")]
[SerializeField] private bool m_SimulateOriginalTimingOnReplay;
[Tooltip("If enabled, only StateEvents and DeltaStateEvents will be captured.")]
[SerializeField] private bool m_RecordStateEventsOnly;
[SerializeField] private int m_CaptureMemoryDefaultSize = 2 * 1024 * 1024;
[SerializeField] private int m_CaptureMemoryMaxSize = 10 * 1024 * 1024;
[SerializeField]
[InputControl(layout = "InputDevice")]
private string m_DevicePath;
[SerializeField]
[InputControl(layout = "Button")]
private string m_RecordButtonPath;
[SerializeField]
[InputControl(layout = "Button")]
private string m_PlayButtonPath;
[SerializeField] private ChangeEvent m_ChangeEvent;
private Action m_OnInputEventDelegate;
private InputEventTrace m_EventTrace;
private InputEventTrace.ReplayController m_ReplayController;
private void CreateEventTrace()
{
////FIXME: remaining configuration should come through, too, if changed after the fact
if (m_EventTrace == null || m_EventTrace.maxSizeInBytes == 0)
{
m_EventTrace?.Dispose();
m_EventTrace = new InputEventTrace(m_CaptureMemoryDefaultSize, growBuffer: true, maxBufferSizeInBytes: m_CaptureMemoryMaxSize);
}
m_EventTrace.recordFrameMarkers = m_RecordFrames;
m_EventTrace.onFilterEvent += OnFilterInputEvent;
m_EventTrace.onEvent += OnEventRecorded;
}
private void HookOnInputEvent()
{
if (string.IsNullOrEmpty(m_PlayButtonPath) && string.IsNullOrEmpty(m_RecordButtonPath))
{
UnhookOnInputEvent();
return;
}
if (m_OnInputEventDelegate == null)
m_OnInputEventDelegate = OnInputEvent;
InputSystem.onEvent += m_OnInputEventDelegate;
}
private void UnhookOnInputEvent()
{
if (m_OnInputEventDelegate != null)
InputSystem.onEvent -= m_OnInputEventDelegate;
}
public enum Change
{
None,
EventCaptured,
EventPlayed,
CaptureStarted,
CaptureStopped,
ReplayStarted,
ReplayStopped,
}
[Serializable]
public class ChangeEvent : UnityEvent
{
}
}
}