diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..1cb8f25 --- /dev/null +++ b/.clang-format @@ -0,0 +1,4 @@ +BasedOnStyle: Microsoft +IndentWidth: 4 +ColumnLimit: 80 +SortIncludes: CaseSensitive diff --git a/.gitignore b/.gitignore index b7fe92a..ebe7bf5 100644 --- a/.gitignore +++ b/.gitignore @@ -35,8 +35,13 @@ bld/ # Potentially sensitive USB and HID diagnostic captures *.pcap *.pcapng +*.dfu +*.mov +*.mp4 +*_capture.zip rs50_passive_*.txt [Dd]iagnostic-[Cc]aptures/ +rs50-build-j-transcript-*.jsonl # Visual Studio 2015/2017 cache/options directory .vs/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ce301fe --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,186 @@ +# Codex Project Instructions + +## Legal, IP, Reverse-Engineering, and Repository Safety Rules + +This project is an independent interoperability project for Logitech racing hardware. Treat legal/IP hygiene as a first-class engineering constraint. + +### Primary objective + +Implement functionality using independently written code and experimentally verified protocol behavior. + +Prefer documented or physically observed device behavior over copying vendor implementations. + +### Logitech proprietary material + +Do NOT commit, redistribute, embed, or reproduce proprietary Logitech material, including: + +- G HUB binaries or DLLs +- Logitech firmware or DFU files +- proprietary SDK binaries +- extracted firmware assets +- fonts or glyph bitmap data extracted from firmware +- graphics or icons extracted from Logitech software/firmware +- large disassembly or decompilation listings +- substantial reconstructed vendor source code +- raw proprietary resources +- complete USB captures that may contain unrelated or sensitive traffic + +Local proprietary files may be inspected for interoperability research when necessary, but they must remain outside the repository. + +Document findings as independently written technical descriptions, protocol structures, behavioral observations, hashes, addresses when useful for reproducibility, and small factual byte sequences when needed to explain interoperability. + +Do not turn decompiled vendor implementation into source code by transcription. + +### Clean implementation principle + +When implementing a discovered protocol: + +1. derive the public implementation from documented protocol behavior; +2. write new source code from scratch; +3. expose only the minimum functionality required by LogiDynamicDash; +4. do not copy vendor class structures, naming, algorithms, or implementation details unless they are necessary functional interface facts; +5. separate evidence/research from production implementation. + +Facts such as feature IDs, function IDs, packet formats, field lengths, layout IDs, acknowledgement behavior, USB interfaces, timing constraints, and observed device behavior may be documented as interoperability information. + +### RS50 OLED protocol + +The currently confirmed interoperability target is public HID++ feature: + +`0x8130 — Display Game Data` + +Do not assume a runtime feature index. Always discover `0x8130` through the HID++ Root feature before using it. + +The confirmed protocol currently includes typed firmware-rendered layouts rather than a host framebuffer. + +Do not claim arbitrary pixel/framebuffer control unless independently demonstrated. + +Do not probe undocumented or unknown HID++ functions by guessing. + +Do not fuzz the RS50. + +Do not perform firmware writes, firmware modification, bootloader operations, arbitrary HID writes, or speculative commands. + +### Hardware safety + +Hardware experiments must remain narrowly scoped and fail closed. + +Do not introduce a generic raw HID command interface. + +Do not allow callers to choose arbitrary: + +- feature IDs +- runtime indices +- HID++ functions +- device indices +- report IDs +- USB interfaces +- raw payload bytes + +Use typed operations with strict validation. + +For OLED communication, avoid operations that interfere with Force Feedback, TRUEFORCE, LEDs, profiles, or firmware. + +Feature `0x8123` Force Feedback must not be invoked as part of OLED transport. + +The previous exclusive DirectInput path demonstrated destructive FFB side effects and must not be treated as the production OLED transport. + +Prefer the validated shared HID++ path over DirectInput acquisition. + +Physical testing must progress in bounded stages and must not move to driving or full-lap testing until the previous coexistence stage has passed. + +### Research evidence + +Preserve enough evidence to reproduce conclusions without publishing proprietary source material. + +Good repository evidence includes: + +- sanitized protocol descriptions +- USB request/response summaries +- hashes of locally inspected files +- independently written diagrams +- experimental procedures +- physical observations +- decoded protocol fields +- timings +- test results + +Keep raw PCAP/PCAPNG, firmware, DLLs, extracted assets, and large binary dumps local and ignored by Git. + +### Third-party open-source projects + +Technical findings from third-party projects may be used as references. + +Do not copy or adapt third-party source code without checking its license first. + +In particular, code from `mescon/logitech-trueforce-linux-driver` may be GPL/LGPL licensed depending on the component. + +Distinguish clearly between: + +- learning a protocol fact from another project; and +- copying or adapting that project's implementation. + +If code is adapted, identify the source and license before committing it. + +### Trademark / affiliation + +Treat Logitech, Logitech G, G HUB, RS50, PRO Racing Wheel, TRUEFORCE, and related names as third-party trademarks. + +Do not imply that LogiDynamicDash is an official Logitech product. + +Project-facing documentation should describe the project as independent and unofficial when appropriate. + +Do not use Logitech logos or proprietary visual assets in the project without explicit permission. + +### Documentation language + +Repository code, comments, commits, README content, technical documentation, issues, pull requests, and other public-facing project material should be written in English. + +Explain technical work to me conversationally in Spanish. + +Use first-person singular ("I") rather than "we" when drafting messages or statements on my behalf. + +### Claims + +Separate these evidence levels: + +- Confirmed: physically or independently verified. +- Likely: supported by multiple observations but not fully verified. +- Unknown: insufficient evidence. + +Do not upgrade an inference into a confirmed fact. + +Do not claim that reverse engineering establishes ownership of Logitech technology. + +The goal is interoperability with independently written software. + +### Before committing + +Before adding research-derived material to Git, check that the change does not include: + +- vendor binaries +- firmware +- extracted proprietary assets +- decompiled source +- copyrighted font or bitmap data +- raw USB captures +- secrets or personal identifiers +- third-party source copied without license compliance + +When uncertain whether material is appropriate to publish, stop and flag the issue instead of committing it. + +### Design preference + +Whenever two implementations are technically equivalent, prefer the one that: + +1. uses independently written code; +2. relies on standard OS/HID interfaces; +3. uses the smallest verified protocol surface; +4. minimizes dependency on Logitech proprietary software; +5. avoids redistributing vendor material; +6. provides clear attribution for external research; +7. is easiest to audit for hardware safety and interoperability. + +These constraints apply to all future RS50, OLED, HID++, G HUB, firmware, DirectInput, TRUEFORCE, and protocol-research work in this repository. + +Do not "clean up" or reimplement code by translating decompiled Logitech logic line-by-line. First reduce any reverse-engineering finding to a functional interoperability specification, then implement independently from that specification. diff --git a/LogiDynamicDash.Tests/CompositeDisplaySinkTests.cs b/LogiDynamicDash.Tests/CompositeDisplaySinkTests.cs new file mode 100644 index 0000000..ca777b7 --- /dev/null +++ b/LogiDynamicDash.Tests/CompositeDisplaySinkTests.cs @@ -0,0 +1,67 @@ +using LogiDynamicDash.Displays; +using LogiDynamicDash.Models; + +namespace LogiDynamicDash.Tests; + +public sealed class CompositeDisplaySinkTests +{ + [Fact] + public void Lifecycle_InitializesInOrderAndStopsInReverse() + { + List events = []; + RecordingSink first = new("first", events); + RecordingSink second = new("second", events); + CompositeDisplaySink sink = new(first, second); + LayoutJFrame frame = new("SPEED", "1 KMH", "GEAR", "1"); + + sink.Initialize(); + sink.Render(frame); + sink.Stop(); + + Assert.Equal( + ["first:init", "second:init", "first:render", "second:render", + "second:stop", "first:stop"], + events); + } + + [Fact] + public void InitializeFailure_StopsOnlyInitializedSinks() + { + List events = []; + RecordingSink first = new("first", events); + RecordingSink second = new("second", events) + { + FailInitialize = true + }; + RecordingSink third = new("third", events); + CompositeDisplaySink sink = new(first, second, third); + + Assert.Throws(sink.Initialize); + + Assert.Equal( + ["first:init", "second:init", "first:stop"], + events); + } + + private sealed class RecordingSink( + string name, + List events) : IDisplaySink + { + public bool FailInitialize { get; set; } + + public void Initialize() + { + events.Add($"{name}:init"); + if (FailInitialize) + { + throw new InvalidOperationException("Simulated failure."); + } + } + + public void Render(LayoutJFrame frame) => + events.Add($"{name}:render"); + + public void Stop() => + events.Add($"{name}:stop"); + } +} diff --git a/LogiDynamicDash.Tests/DisplayControllerTests.cs b/LogiDynamicDash.Tests/DisplayControllerTests.cs new file mode 100644 index 0000000..ee0e89e --- /dev/null +++ b/LogiDynamicDash.Tests/DisplayControllerTests.cs @@ -0,0 +1,147 @@ +using LogiDynamicDash.Controllers; +using LogiDynamicDash.Models; + +namespace LogiDynamicDash.Tests; + +public sealed class DisplayControllerTests +{ + [Fact] + public void SelectMode_UsesConnectionStateAsTopLevelGate() + { + DisplayController controller = new(new ManualTimeProvider()); + + DisplayMode mode = controller.SelectMode(new TelemetrySnapshot + { + ConnectionState = "WAITING" + }); + + Assert.Equal(DisplayMode.ConnectionProblem, mode); + } + + [Fact] + public void DisconnectedTelemetry_DoesNotCreateTransientModesOnReconnect() + { + DisplayController controller = new(new ManualTimeProvider()); + TelemetrySnapshot snapshot = new() + { + ConnectionState = "WAITING", + BrakeBiasPercent = 50.0f, + LastLapTimeSeconds = 80.0f + }; + + controller.SelectMode(snapshot); + snapshot = snapshot with + { + BrakeBiasPercent = 51.0f, + LastLapTimeSeconds = 79.0f + }; + controller.SelectMode(snapshot); + + snapshot = snapshot with + { + ConnectionState = "CONNECTED" + }; + + Assert.Equal(DisplayMode.Normal, controller.SelectMode(snapshot)); + } + + [Fact] + public void BrakeBiasChange_ExpiresAfterTwoSeconds() + { + ManualTimeProvider time = new(); + DisplayController controller = new(time); + TelemetrySnapshot snapshot = ConnectedSnapshot(); + snapshot = snapshot with { BrakeBiasPercent = 50.0f }; + + Assert.Equal(DisplayMode.Normal, controller.SelectMode(snapshot)); + + snapshot = snapshot with { BrakeBiasPercent = 50.1f }; + Assert.Equal(DisplayMode.BrakeBias, controller.SelectMode(snapshot)); + + time.Advance(TimeSpan.FromMilliseconds(1999)); + Assert.Equal(DisplayMode.BrakeBias, controller.SelectMode(snapshot)); + + time.Advance(TimeSpan.FromMilliseconds(1)); + Assert.Equal(DisplayMode.Normal, controller.SelectMode(snapshot)); + } + + [Fact] + public void LastLapChange_ExpiresAfterThreeSeconds() + { + ManualTimeProvider time = new(); + DisplayController controller = new(time); + TelemetrySnapshot snapshot = ConnectedSnapshot(); + snapshot = snapshot with { LastLapTimeSeconds = 80.0f }; + + Assert.Equal(DisplayMode.Normal, controller.SelectMode(snapshot)); + + snapshot = snapshot with { LastLapTimeSeconds = 79.5f }; + Assert.Equal(DisplayMode.LastLap, controller.SelectMode(snapshot)); + + time.Advance(TimeSpan.FromSeconds(3)); + Assert.Equal(DisplayMode.Normal, controller.SelectMode(snapshot)); + } + + [Fact] + public void BrakeBiasTemporarilyTakesPriorityOverLastLap() + { + ManualTimeProvider time = new(); + DisplayController controller = new(time); + TelemetrySnapshot snapshot = ConnectedSnapshot(); + snapshot = snapshot with + { + BrakeBiasPercent = 50.0f, + LastLapTimeSeconds = 80.0f + }; + controller.SelectMode(snapshot); + + snapshot = snapshot with + { + BrakeBiasPercent = 50.1f, + LastLapTimeSeconds = 79.5f + }; + Assert.Equal(DisplayMode.BrakeBias, controller.SelectMode(snapshot)); + + time.Advance(TimeSpan.FromSeconds(2)); + Assert.Equal(DisplayMode.LastLap, controller.SelectMode(snapshot)); + + time.Advance(TimeSpan.FromSeconds(1)); + Assert.Equal(DisplayMode.Normal, controller.SelectMode(snapshot)); + } + + [Fact] + public void InvalidTelemetry_DoesNotActivateTransientModes() + { + DisplayController controller = new(new ManualTimeProvider()); + TelemetrySnapshot snapshot = ConnectedSnapshot(); + snapshot = snapshot with + { + BrakeBiasPercent = 50.0f, + LastLapTimeSeconds = 80.0f + }; + controller.SelectMode(snapshot); + + snapshot = snapshot with + { + BrakeBiasPercent = float.NaN, + LastLapTimeSeconds = float.PositiveInfinity + }; + + Assert.Equal(DisplayMode.Normal, controller.SelectMode(snapshot)); + } + + private static TelemetrySnapshot ConnectedSnapshot() => new() + { + ConnectionState = "CONNECTED" + }; + + private sealed class ManualTimeProvider : TimeProvider + { + private DateTimeOffset utcNow = + new(2026, 7, 22, 0, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => utcNow; + + public void Advance(TimeSpan duration) => utcNow += duration; + } +} diff --git a/LogiDynamicDash.Tests/DistinctDisplaySinkTests.cs b/LogiDynamicDash.Tests/DistinctDisplaySinkTests.cs new file mode 100644 index 0000000..7137775 --- /dev/null +++ b/LogiDynamicDash.Tests/DistinctDisplaySinkTests.cs @@ -0,0 +1,84 @@ +using LogiDynamicDash.Displays; +using LogiDynamicDash.Models; + +namespace LogiDynamicDash.Tests; + +public sealed class DistinctDisplaySinkTests +{ + [Fact] + public void Render_ForwardsOnlyChanges() + { + RecordingDisplaySink inner = new(); + DistinctDisplaySink sink = new(inner); + LayoutJFrame first = new("SPEED", "200 KMH", "GEAR", "4"); + LayoutJFrame equivalent = new("SPEED", "200 KMH", "GEAR", "4"); + LayoutJFrame changed = new("SPEED", "201 KMH", "GEAR", "4"); + + sink.Initialize(); + sink.Render(first); + sink.Render(equivalent); + sink.Render(changed); + + Assert.Equal([first, changed], inner.Frames); + } + + [Fact] + public void Render_RetriesFrameRejectedByWrappedSink() + { + RecordingDisplaySink inner = new() + { + FailNextRender = true + }; + DistinctDisplaySink sink = new(inner); + LayoutJFrame frame = new("SPEED", "200 KMH", "GEAR", "4"); + + Assert.Throws(() => sink.Render(frame)); + sink.Render(frame); + + Assert.Equal([frame], inner.Frames); + } + + [Fact] + public void InitializeAndStop_ResetRememberedFrame() + { + RecordingDisplaySink inner = new(); + DistinctDisplaySink sink = new(inner); + LayoutJFrame frame = new("SPEED", "200 KMH", "GEAR", "4"); + + sink.Render(frame); + sink.Stop(); + sink.Render(frame); + sink.Initialize(); + sink.Render(frame); + + Assert.Equal([frame, frame, frame], inner.Frames); + Assert.Equal(1, inner.InitializeCount); + Assert.Equal(1, inner.StopCount); + } + + private sealed class RecordingDisplaySink : IDisplaySink + { + public List Frames { get; } = []; + + public bool FailNextRender { get; set; } + + public int InitializeCount { get; private set; } + + public int StopCount { get; private set; } + + public void Initialize() => InitializeCount++; + + public void Render(LayoutJFrame frame) + { + if (FailNextRender) + { + FailNextRender = false; + throw new InvalidOperationException("Simulated sink failure."); + } + + Frames.Add(frame); + } + + public void Stop() => StopCount++; + } +} diff --git a/LogiDynamicDash.Tests/LayoutJTelemetryFormatterTests.cs b/LogiDynamicDash.Tests/LayoutJTelemetryFormatterTests.cs new file mode 100644 index 0000000..1a507c0 --- /dev/null +++ b/LogiDynamicDash.Tests/LayoutJTelemetryFormatterTests.cs @@ -0,0 +1,206 @@ +using System.Globalization; +using LogiDynamicDash.Displays; +using LogiDynamicDash.Models; + +namespace LogiDynamicDash.Tests; + +public sealed class LayoutJTelemetryFormatterTests +{ + [Theory] + [InlineData(-1, "R")] + [InlineData(0, "N")] + [InlineData(1, "1")] + [InlineData(9, "9")] + [InlineData(10, "?")] + public void NormalMode_MapsSpeedAndGearToFourRows( + int gear, + string expectedGear) + { + TelemetrySnapshot snapshot = new() + { + SpeedMetersPerSecond = 55.5f, + Gear = gear + }; + + LayoutJFrame frame = LayoutJTelemetryFormatter.Format( + snapshot, + DisplayMode.Normal); + + Assert.Equal("SPEED", frame.Line1); + Assert.Equal("200 KMH", frame.Line2); + Assert.Equal("GEAR", frame.Line3); + Assert.Equal(expectedGear, frame.Line4); + AssertFitsLayoutJ(frame); + } + + [Fact] + public void NormalMode_RejectsInvalidTelemetryWithoutInvalidText() + { + TelemetrySnapshot snapshot = new() + { + SpeedMetersPerSecond = float.NaN, + Gear = null + }; + + LayoutJFrame frame = LayoutJTelemetryFormatter.Format( + snapshot, + DisplayMode.Normal); + + Assert.Equal("N/A", frame.Line2); + Assert.Equal("?", frame.Line4); + AssertFitsLayoutJ(frame); + } + + [Theory] + [InlineData(-1.0f, "N/A")] + [InlineData(float.PositiveInfinity, "N/A")] + [InlineData(1000.0f, "999 KMH")] + public void NormalMode_BoundsInvalidOrExtremeSpeed( + float metersPerSecond, + string expected) + { + TelemetrySnapshot snapshot = new() + { + SpeedMetersPerSecond = metersPerSecond, + Gear = 1 + }; + + LayoutJFrame frame = LayoutJTelemetryFormatter.Format( + snapshot, + DisplayMode.Normal); + + Assert.Equal(expected, frame.Line2); + AssertFitsLayoutJ(frame); + } + + [Fact] + public void BrakeBiasMode_UsesRecoveredSecondRowLimit() + { + TelemetrySnapshot snapshot = new() + { + BrakeBiasPercent = 54.25f + }; + + LayoutJFrame frame = LayoutJTelemetryFormatter.Format( + snapshot, + DisplayMode.BrakeBias); + + Assert.Equal("BRAKE BIAS", frame.Line1); + Assert.Equal("54.2%", frame.Line2); + AssertFitsLayoutJ(frame); + } + + [Fact] + public void LastLapMode_FormatsTimeWithinTenCharacters() + { + TelemetrySnapshot snapshot = new() + { + LastLapTimeSeconds = 83.456f + }; + + LayoutJFrame frame = LayoutJTelemetryFormatter.Format( + snapshot, + DisplayMode.LastLap); + + Assert.Equal("LAST LAP", frame.Line1); + Assert.Equal("1:23.456", frame.Line2); + AssertFitsLayoutJ(frame); + } + + [Theory] + [InlineData(0.0f)] + [InlineData(6000.0f)] + [InlineData(float.NaN)] + public void LastLapMode_RejectsInvalidOrOversizedTimes(float seconds) + { + TelemetrySnapshot snapshot = new() + { + LastLapTimeSeconds = seconds + }; + + LayoutJFrame frame = LayoutJTelemetryFormatter.Format( + snapshot, + DisplayMode.LastLap); + + Assert.Equal("N/A", frame.Line2); + AssertFitsLayoutJ(frame); + } + + [Theory] + [InlineData("WAITING", "WAITING")] + [InlineData("ERROR", "ERROR")] + [InlineData("unexpected long status", "OFFLINE")] + public void ConnectionProblemMode_UsesBoundedStatus( + string state, + string expected) + { + TelemetrySnapshot snapshot = new() + { + ConnectionState = state + }; + + LayoutJFrame frame = LayoutJTelemetryFormatter.Format( + snapshot, + DisplayMode.ConnectionProblem); + + Assert.Equal("IRACING", frame.Line1); + Assert.Equal(expected, frame.Line2); + AssertFitsLayoutJ(frame); + } + + [Fact] + public void Frame_RejectsOversizedOrUnsupportedText() + { + Assert.Throws( + () => new LayoutJFrame( + new string('A', LayoutJFrame.Line1MaximumLength + 1), + string.Empty, + string.Empty, + string.Empty)); + Assert.Throws( + () => new LayoutJFrame( + "SPEED 🏁", + string.Empty, + string.Empty, + string.Empty)); + } + + [Fact] + public void Formatter_IsIndependentOfCurrentCulture() + { + CultureInfo previousCulture = CultureInfo.CurrentCulture; + + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("es-MX"); + TelemetrySnapshot snapshot = new() + { + BrakeBiasPercent = 54.2f + }; + + LayoutJFrame frame = LayoutJTelemetryFormatter.Format( + snapshot, + DisplayMode.BrakeBias); + + Assert.Equal("54.2%", frame.Line2); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } + + private static void AssertFitsLayoutJ(LayoutJFrame frame) + { + Assert.True(frame.Line1.Length <= LayoutJFrame.Line1MaximumLength); + Assert.True(frame.Line2.Length <= LayoutJFrame.Line2MaximumLength); + Assert.True(frame.Line3.Length <= LayoutJFrame.Line3MaximumLength); + Assert.True(frame.Line4.Length <= LayoutJFrame.Line4MaximumLength); + + Assert.All( + new[] { frame.Line1, frame.Line2, frame.Line3, frame.Line4 }, + line => Assert.All( + line, + character => Assert.InRange(character, (char)0x20, (char)0x7F))); + } +} diff --git a/LogiDynamicDash.Tests/LogiDynamicDash.Tests.csproj b/LogiDynamicDash.Tests/LogiDynamicDash.Tests.csproj new file mode 100644 index 0000000..c8f3793 --- /dev/null +++ b/LogiDynamicDash.Tests/LogiDynamicDash.Tests.csproj @@ -0,0 +1,37 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LogiDynamicDash.Tests/ProgramArmingTests.cs b/LogiDynamicDash.Tests/ProgramArmingTests.cs new file mode 100644 index 0000000..64e5666 --- /dev/null +++ b/LogiDynamicDash.Tests/ProgramArmingTests.cs @@ -0,0 +1,56 @@ +using LogiDynamicDash.Displays; + +namespace LogiDynamicDash.Tests; + +public sealed class ProgramArmingTests +{ + [Fact] + public void NoArguments_UsesSafeConsolePreview() + { + bool accepted = Program.TryCreateDashboard([], out IDisplaySink sink); + + Assert.True(accepted); + Assert.IsType(sink); + } + + [Theory] + [InlineData("--enable-rs50-oled")] + [InlineData( + "--enable-rs50-oled", + "--confirm-exclusive-layout-j-stream")] + [InlineData( + "--enable-rs50-oled", + "--confirm-telemetry-transmission", + "--confirm-exclusive-layout-j-stream")] + [InlineData( + "--enable-rs50-oled", + "--confirm-exclusive-layout-j-stream", + "--confirm-telemetry-transmission")] + [InlineData("--unknown")] + public void PartialWrongOrReorderedArguments_AreRejected( + params string[] arguments) + { + bool accepted = + Program.TryCreateDashboard(arguments, out _); + + Assert.False(accepted); + } + + [Fact] + public void ExactFourArguments_ArmsRs50CompositionWithoutOpeningHardware() + { + string[] arguments = + [ + "--enable-rs50-oled", + "--confirm-exclusive-layout-j-stream", + "--confirm-telemetry-transmission", + "--confirm-10-second-trial" + ]; + + bool accepted = + Program.TryCreateDashboard(arguments, out IDisplaySink sink); + + Assert.True(accepted); + Assert.IsType(sink); + } +} diff --git a/LogiDynamicDash.Tests/Rs50DisplaySinkTests.cs b/LogiDynamicDash.Tests/Rs50DisplaySinkTests.cs new file mode 100644 index 0000000..1883cf9 --- /dev/null +++ b/LogiDynamicDash.Tests/Rs50DisplaySinkTests.cs @@ -0,0 +1,152 @@ +using LogiDynamicDash.Displays; +using LogiDynamicDash.Models; +using LogiDynamicDash.Native; + +namespace LogiDynamicDash.Tests; + +public sealed class Rs50DisplaySinkTests +{ + [Fact] + public void InitializeRenderStop_OwnsCompleteStreamLifecycle() + { + List events = []; + RecordingBridge bridge = new(events); + RecordingOwnerWindow window = new(events); + Rs50DisplaySink sink = new(bridge, window); + LayoutJFrame frame = + new("SPEED", "123 KMH", "GEAR", "4"); + + sink.Initialize(); + sink.Render(frame); + sink.Stop(); + + Assert.Equal( + ["window:create", "bridge:open:123", "bridge:begin", + "bridge:send", "bridge:end", "bridge:dispose", + "window:dispose"], + events); + Assert.Same(frame, bridge.LastFrame); + } + + [Fact] + public void InitializeFailure_ReleasesBridgeAndWindow() + { + List events = []; + RecordingBridge bridge = new(events) + { + FailBegin = true + }; + RecordingOwnerWindow window = new(events); + Rs50DisplaySink sink = new(bridge, window); + + Assert.Throws(sink.Initialize); + + Assert.Contains("bridge:dispose", events); + Assert.Equal("window:dispose", events[^1]); + } + + [Fact] + public void RenderBeforeInitialize_IsRejected() + { + Rs50DisplaySink sink = + new(new RecordingBridge([]), new RecordingOwnerWindow([])); + + Assert.Throws( + () => sink.Render(new("", "", "", ""))); + } + + [Fact] + public void RateLimitedFrame_IsDelayedAndRetriedOnce() + { + List events = []; + RecordingBridge bridge = new(events); + bridge.Outcomes.Enqueue(new(false, false, true)); + bridge.Outcomes.Enqueue(new(true, false, false)); + RecordingOwnerWindow window = new(events); + List delays = []; + Rs50DisplaySink sink = + new(bridge, window, delays.Add); + + sink.Initialize(); + sink.Render(new("SPEED", "123 KMH", "GEAR", "4")); + + Assert.Equal(2, bridge.SendCount); + Assert.Equal([TimeSpan.FromMilliseconds(200)], delays); + } + + [Fact] + public void RepeatedRateLimit_IsRejectedSoDistinctSinkCanRetry() + { + List events = []; + RecordingBridge bridge = new(events); + bridge.Outcomes.Enqueue(new(false, false, true)); + bridge.Outcomes.Enqueue(new(false, false, true)); + Rs50DisplaySink sink = + new(bridge, new RecordingOwnerWindow(events), _ => { }); + + sink.Initialize(); + + Assert.Throws( + () => sink.Render(new("SPEED", "123 KMH", "GEAR", "4"))); + } + + private sealed class RecordingOwnerWindow( + List events) : IRs50OwnerWindow + { + public nint Handle { get; private set; } + + public void Create() + { + Handle = 123; + events.Add("window:create"); + } + + public void Dispose() + { + events.Add("window:dispose"); + Handle = 0; + } + } + + private sealed class RecordingBridge( + List events) : IRs50DisplayBridge + { + public bool FailBegin { get; set; } + + public LayoutJFrame? LastFrame { get; private set; } + + public Queue Outcomes { get; } = []; + + public int SendCount { get; private set; } + + public void Open(nint ownerWindow) + { + events.Add($"bridge:open:{ownerWindow}"); + } + + public void BeginLayoutJStream() + { + events.Add("bridge:begin"); + if (FailBegin) + { + throw new InvalidOperationException("Simulated begin failure."); + } + } + + public Rs50FrameOutcome Send(LayoutJFrame frame) + { + events.Add("bridge:send"); + LastFrame = frame; + SendCount++; + return Outcomes.TryDequeue(out Rs50FrameOutcome outcome) + ? outcome + : new(true, false, false); + } + + public void EndLayoutJStream() => + events.Add("bridge:end"); + + public void Dispose() => + events.Add("bridge:dispose"); + } +} diff --git a/LogiDynamicDash.Tests/Rs50HidppDeviceExchangeTests.cs b/LogiDynamicDash.Tests/Rs50HidppDeviceExchangeTests.cs new file mode 100644 index 0000000..8574a87 --- /dev/null +++ b/LogiDynamicDash.Tests/Rs50HidppDeviceExchangeTests.cs @@ -0,0 +1,440 @@ +using LogiDynamicDash.Hidpp; +using LogiDynamicDash.Models; +using Rs50SharedHidppTransport; + +namespace LogiDynamicDash.Tests; + +public sealed class Rs50HidppDeviceExchangeTests +{ + [Fact] + public void OpenSessionAndSend_RoutesOnlyAcrossCol01AndCol03() + { + FakeFixture fixture = FakeFixture.Valid(); + fixture.VeryLongStream.Reads.Enqueue(ValidDiscoveryResponse()); + fixture.VeryLongStream.Reads.Enqueue(ValidLayoutJAcknowledgement()); + + using Rs50HidppDeviceExchange exchange = + Rs50HidppDeviceExchange.Open(fixture.Catalog); + using Rs50SharedHidppDisplaySession session = new(exchange); + + session.Open(); + session.Send(new("SPEED", "0 KMH", "GEAR", "N")); + + Assert.Single(fixture.ShortStream.Writes); + Assert.Equal( + [0x10, 0xFF, 0x00, 0x0A, 0x81, 0x30, 0x00], + fixture.ShortStream.Writes[0]); + Assert.Single(fixture.VeryLongStream.Writes); + Assert.Equal(0x12, fixture.VeryLongStream.Writes[0][0]); + Assert.Equal(0x3A, fixture.VeryLongStream.Writes[0][3]); + Assert.Equal(0x09, fixture.VeryLongStream.Writes[0][4]); + } + + [Fact] + public void Exchange_SkipsUnrelatedReportsBeforeExactResponse() + { + FakeFixture fixture = FakeFixture.Valid(); + fixture.VeryLongStream.Reads.Enqueue( + Report(0x12, 0x01, 0x03, 0x00)); + fixture.VeryLongStream.Reads.Enqueue( + Report(0x12, 0xFF, 0x17, 0x00)); + fixture.VeryLongStream.Reads.Enqueue(ValidDiscoveryResponse()); + + using Rs50HidppDeviceExchange exchange = + Rs50HidppDeviceExchange.Open(fixture.Catalog); + + byte[] response = + exchange.Exchange( + Rs50HidppDisplayProtocol.CreateDiscovery()); + + Assert.Equal(0x12, response[4]); + Assert.Equal(3, fixture.VeryLongStream.ReadCount); + } + + [Fact] + public void Exchange_ReturnsMatchingHidppErrorForProtocolRejection() + { + FakeFixture fixture = FakeFixture.Valid(); + byte[] error = new byte[64]; + error[0] = 0x12; + error[1] = 0xFF; + error[2] = 0xFF; + error[3] = 0x0A; + error[4] = 0x00; + error[5] = 0x0A; + error[6] = 0x09; + fixture.VeryLongStream.Reads.Enqueue(error); + + using Rs50HidppDeviceExchange exchange = + Rs50HidppDeviceExchange.Open(fixture.Catalog); + + byte[] response = + exchange.Exchange( + Rs50HidppDisplayProtocol.CreateDiscovery()); + + Assert.Equal(error, response); + } + + [Fact] + public void Exchange_RejectsShortVeryLongRead() + { + FakeFixture fixture = FakeFixture.Valid(); + fixture.VeryLongStream.Reads.Enqueue(new byte[63]); + + using Rs50HidppDeviceExchange exchange = + Rs50HidppDeviceExchange.Open(fixture.Catalog); + + Assert.Throws( + () => exchange.Exchange( + Rs50HidppDisplayProtocol.CreateDiscovery())); + } + + [Fact] + public void Exchange_StopsAfterSixteenUnrelatedReports() + { + FakeFixture fixture = FakeFixture.Valid(); + for (int index = 0; index < 16; index++) + { + fixture.VeryLongStream.Reads.Enqueue( + Report(0x12, 0x01, 0x03, 0x00)); + } + + using Rs50HidppDeviceExchange exchange = + Rs50HidppDeviceExchange.Open(fixture.Catalog); + + Assert.Throws( + () => exchange.Exchange( + Rs50HidppDisplayProtocol.CreateDiscovery())); + Assert.Equal(16, fixture.VeryLongStream.ReadCount); + } + + [Fact] + public void Dispose_DisposesBothStreamsAndBlocksExchange() + { + FakeFixture fixture = FakeFixture.Valid(); + Rs50HidppDeviceExchange exchange = + Rs50HidppDeviceExchange.Open(fixture.Catalog); + + exchange.Dispose(); + exchange.Dispose(); + + Assert.True(fixture.ShortStream.Disposed); + Assert.True(fixture.VeryLongStream.Disposed); + Assert.Throws( + () => exchange.Exchange( + Rs50HidppDisplayProtocol.CreateDiscovery())); + } + + [Fact] + public void Open_WhenSecondStreamFails_DisposesFirstStream() + { + FakeFixture fixture = FakeFixture.Valid(); + fixture.VeryLongCollection.OpenFailure = + new IOException("Simulated COL03 open failure."); + + Assert.Throws( + () => Rs50HidppDeviceExchange.Open(fixture.Catalog)); + + Assert.True(fixture.ShortStream.Disposed); + } + + [Fact] + public void Open_AllowsDistinctTopLevelCollectionInstancePaths() + { + FakeFixture fixture = FakeFixture.Valid(); + + using Rs50HidppDeviceExchange exchange = + Rs50HidppDeviceExchange.Open(fixture.Catalog); + + Assert.NotEqual( + fixture.ShortCollection.DevicePath, + fixture.VeryLongCollection.DevicePath); + Assert.Equal(1, fixture.ShortCollection.OpenCount); + Assert.Equal(1, fixture.VeryLongCollection.OpenCount); + } + + [Theory] + [InlineData(CollectionMutation.RemoveShort)] + [InlineData(CollectionMutation.RemoveVeryLong)] + [InlineData(CollectionMutation.DuplicateShort)] + [InlineData(CollectionMutation.DuplicateVeryLong)] + [InlineData(CollectionMutation.WrongShortVendor)] + [InlineData(CollectionMutation.WrongVeryLongProduct)] + [InlineData(CollectionMutation.WrongShortUsage)] + [InlineData(CollectionMutation.ExtraVeryLongUsage)] + [InlineData(CollectionMutation.WrongShortInputLength)] + [InlineData(CollectionMutation.WrongShortOutputLength)] + [InlineData(CollectionMutation.WrongVeryLongInputLength)] + [InlineData(CollectionMutation.WrongVeryLongOutputLength)] + [InlineData(CollectionMutation.WrongShortPath)] + [InlineData(CollectionMutation.WrongVeryLongPath)] + public void Open_RejectsAnyIdentityOrShapeMismatch( + CollectionMutation mutation) + { + FakeFixture fixture = FakeFixture.Valid(); + fixture.Apply(mutation); + + Assert.Throws( + () => Rs50HidppDeviceExchange.Open(fixture.Catalog)); + + Assert.Equal(0, fixture.ShortCollection.OpenCount); + Assert.Equal(0, fixture.VeryLongCollection.OpenCount); + Assert.Empty(fixture.ShortStream.Writes); + Assert.Empty(fixture.VeryLongStream.Writes); + } + + public enum CollectionMutation + { + RemoveShort, + RemoveVeryLong, + DuplicateShort, + DuplicateVeryLong, + WrongShortVendor, + WrongVeryLongProduct, + WrongShortUsage, + ExtraVeryLongUsage, + WrongShortInputLength, + WrongShortOutputLength, + WrongVeryLongInputLength, + WrongVeryLongOutputLength, + WrongShortPath, + WrongVeryLongPath + } + + private static byte[] ValidDiscoveryResponse() + { + byte[] response = new byte[64]; + response[0] = 0x12; + response[1] = 0xFF; + response[2] = 0x00; + response[3] = 0x0A; + response[4] = 0x12; + return response; + } + + private static byte[] ValidLayoutJAcknowledgement() + { + byte[] response = new byte[64]; + response[0] = 0x12; + response[1] = 0xFF; + response[2] = 0x12; + response[3] = 0x3A; + return response; + } + + private static byte[] Report( + byte reportId, + byte device, + byte feature, + byte function) + { + byte[] report = new byte[64]; + report[0] = reportId; + report[1] = device; + report[2] = feature; + report[3] = function; + return report; + } + + private sealed class FakeFixture + { + private const string ShortPath = + @"\\?\hid#vid_046d&pid_c276&mi_01&col01#8&abc#{guid}"; + private const string VeryLongPath = + @"\\?\hid#vid_046d&pid_c276&mi_01&col03#9&other#{guid}"; + + private FakeFixture( + FakeCatalog catalog, + FakeCollection shortCollection, + FakeCollection veryLongCollection, + FakeStream shortStream, + FakeStream veryLongStream) + { + Catalog = catalog; + ShortCollection = shortCollection; + VeryLongCollection = veryLongCollection; + ShortStream = shortStream; + VeryLongStream = veryLongStream; + } + + public FakeCatalog Catalog { get; } + + public FakeCollection ShortCollection { get; } + + public FakeCollection VeryLongCollection { get; } + + public FakeStream ShortStream { get; } + + public FakeStream VeryLongStream { get; } + + public static FakeFixture Valid() + { + FakeStream shortStream = new(); + FakeStream veryLongStream = new(); + FakeCollection shortCollection = new( + 0x046D, + 0xC276, + ShortPath, + new HashSet { 0xFF430701 }, + 7, + 7, + shortStream); + FakeCollection veryLongCollection = new( + 0x046D, + 0xC276, + VeryLongPath, + new HashSet { 0xFF430704 }, + 64, + 64, + veryLongStream); + FakeCatalog catalog = + new([shortCollection, veryLongCollection]); + + return new( + catalog, + shortCollection, + veryLongCollection, + shortStream, + veryLongStream); + } + + public void Apply(CollectionMutation mutation) + { + switch (mutation) + { + case CollectionMutation.RemoveShort: + Catalog.Collections.Remove(ShortCollection); + break; + case CollectionMutation.RemoveVeryLong: + Catalog.Collections.Remove(VeryLongCollection); + break; + case CollectionMutation.DuplicateShort: + Catalog.Collections.Add(ShortCollection.Clone()); + break; + case CollectionMutation.DuplicateVeryLong: + Catalog.Collections.Add(VeryLongCollection.Clone()); + break; + case CollectionMutation.WrongShortVendor: + ShortCollection.VendorId = 0x0001; + break; + case CollectionMutation.WrongVeryLongProduct: + VeryLongCollection.ProductId = 0x0001; + break; + case CollectionMutation.WrongShortUsage: + ShortCollection.Usages = new HashSet { 0xFF430702 }; + break; + case CollectionMutation.ExtraVeryLongUsage: + VeryLongCollection.Usages = + new HashSet { 0xFF430704, 0xFF430701 }; + break; + case CollectionMutation.WrongShortInputLength: + ShortCollection.MaximumInputReportLength = 8; + break; + case CollectionMutation.WrongShortOutputLength: + ShortCollection.MaximumOutputReportLength = 8; + break; + case CollectionMutation.WrongVeryLongInputLength: + VeryLongCollection.MaximumInputReportLength = 65; + break; + case CollectionMutation.WrongVeryLongOutputLength: + VeryLongCollection.MaximumOutputReportLength = 65; + break; + case CollectionMutation.WrongShortPath: + ShortCollection.DevicePath = + ShortPath.Replace("col01", "col02"); + break; + case CollectionMutation.WrongVeryLongPath: + VeryLongCollection.DevicePath = + VeryLongPath.Replace("col03", "col02"); + break; + default: + throw new ArgumentOutOfRangeException(nameof(mutation)); + } + } + } + + private sealed class FakeCatalog( + IEnumerable collections) + : IRs50HidCollectionCatalog + { + public List Collections { get; } = + [.. collections]; + + public IReadOnlyList Enumerate() => + Collections; + } + + private sealed class FakeCollection( + int vendorId, + int productId, + string devicePath, + IReadOnlySet usages, + int maximumInputReportLength, + int maximumOutputReportLength, + FakeStream stream) : IRs50HidCollection + { + public int VendorId { get; set; } = vendorId; + + public int ProductId { get; set; } = productId; + + public string DevicePath { get; set; } = devicePath; + + public IReadOnlySet Usages { get; set; } = usages; + + public int MaximumInputReportLength { get; set; } = + maximumInputReportLength; + + public int MaximumOutputReportLength { get; set; } = + maximumOutputReportLength; + + public Exception? OpenFailure { get; set; } + + public int OpenCount { get; private set; } + + public IRs50HidStream Open() + { + OpenCount++; + if (OpenFailure is not null) + { + throw OpenFailure; + } + + return stream; + } + + public FakeCollection Clone() => + new( + VendorId, + ProductId, + DevicePath, + new HashSet(Usages), + MaximumInputReportLength, + MaximumOutputReportLength, + new FakeStream()); + } + + private sealed class FakeStream : IRs50HidStream + { + public Queue Reads { get; } = []; + + public List Writes { get; } = []; + + public int ReadCount { get; private set; } + + public bool Disposed { get; private set; } + + public int Read(byte[] buffer) + { + ReadCount++; + byte[] report = Reads.Dequeue(); + report.CopyTo(buffer, 0); + return report.Length; + } + + public void Write(byte[] report) => + Writes.Add((byte[])report.Clone()); + + public void Dispose() => + Disposed = true; + } +} diff --git a/LogiDynamicDash.Tests/Rs50HidppDisplayProtocolTests.cs b/LogiDynamicDash.Tests/Rs50HidppDisplayProtocolTests.cs new file mode 100644 index 0000000..29da256 --- /dev/null +++ b/LogiDynamicDash.Tests/Rs50HidppDisplayProtocolTests.cs @@ -0,0 +1,255 @@ +using System.Text; +using LogiDynamicDash.Hidpp; +using LogiDynamicDash.Models; + +namespace LogiDynamicDash.Tests; + +public sealed class Rs50HidppDisplayProtocolTests +{ + [Fact] + public void CreateDiscovery_UsesOnlyRootGetFeatureFor8130() + { + Rs50HidppDisplayTransaction transaction = + Rs50HidppDisplayProtocol.CreateDiscovery(); + + Assert.Equal( + Rs50HidppDisplayTransactionKind.DiscoverDisplayFeature, + transaction.Kind); + Assert.Equal( + [0x10, 0xFF, 0x00, 0x0A, 0x81, 0x30, 0x00], + transaction.Request.ToArray()); + } + + [Fact] + public void ParseDiscoveryResponse_AcceptsCapturedShape() + { + byte[] response = ValidDiscoveryResponse(); + + byte runtime = + Rs50HidppDisplayProtocol.ParseDiscoveryResponse(response); + + Assert.Equal(0x12, runtime); + } + + [Theory] + [InlineData(0, 0x11)] + [InlineData(1, 0x01)] + [InlineData(2, 0x01)] + [InlineData(3, 0x0B)] + [InlineData(5, 0x40)] + [InlineData(6, 0x01)] + [InlineData(7, 0x01)] + public void ParseDiscoveryResponse_RejectsAnyUnexpectedField( + int offset, + byte value) + { + byte[] response = ValidDiscoveryResponse(); + response[offset] = value; + + Assert.Throws( + () => Rs50HidppDisplayProtocol.ParseDiscoveryResponse(response)); + } + + [Fact] + public void ParseDiscoveryResponse_RejectsWrongLength() + { + Assert.Throws( + () => Rs50HidppDisplayProtocol.ParseDiscoveryResponse( + new byte[63])); + } + + [Fact] + public void ParseDiscoveryResponse_RejectsHidppError() + { + byte[] response = ErrorResponse( + expectedFeature: 0x00, + expectedFunction: 0x0A, + error: 0x09); + + Rs50HidppProtocolException exception = + Assert.Throws( + () => + Rs50HidppDisplayProtocol.ParseDiscoveryResponse( + response)); + + Assert.Contains("0x09", exception.Message); + } + + [Fact] + public void CreateLayoutJ_MatchesSuccessfulWireFrame() + { + LayoutJFrame frame = + new("SPEED", "0 KMH", "GEAR", "N"); + + Rs50HidppDisplayTransaction transaction = + Rs50HidppDisplayProtocol.CreateLayoutJ( + 0x12, + frame.Line1, + frame.Line2, + frame.Line3, + frame.Line4); + byte[] request = transaction.Request.ToArray(); + + Assert.Equal( + Rs50HidppDisplayTransactionKind.SetLayoutJ, + transaction.Kind); + Assert.Equal(64, request.Length); + Assert.Equal([0x12, 0xFF, 0x12, 0x3A, 0x09], request[..5]); + Assert.Equal("SPEED", ReadText(request, 5, 19)); + Assert.Equal("0 KMH", ReadText(request, 24, 10)); + Assert.Equal("GEAR", ReadText(request, 34, 19)); + Assert.Equal("N", ReadText(request, 53, 10)); + Assert.Equal(0, request[63]); + } + + [Theory] + [InlineData(0x00)] + [InlineData(0x01)] + [InlineData(0xFF)] + public void CreateLayoutJ_RejectsInvalidRuntimeIndex(byte runtime) + { + Assert.Throws( + () => Rs50HidppDisplayProtocol.CreateLayoutJ( + runtime, + "", + "", + "", + "")); + } + + [Fact] + public void CreateLayoutJ_RevalidatesEveryTextField() + { + Assert.Throws( + () => Rs50HidppDisplayProtocol.CreateLayoutJ( + 0x12, + new string('A', 20), + "", + "", + "")); + Assert.Throws( + () => Rs50HidppDisplayProtocol.CreateLayoutJ( + 0x12, + "", + new string('A', 11), + "", + "")); + Assert.Throws( + () => Rs50HidppDisplayProtocol.CreateLayoutJ( + 0x12, + "", + "", + new string('A', 20), + "")); + Assert.Throws( + () => Rs50HidppDisplayProtocol.CreateLayoutJ( + 0x12, + "", + "", + "", + new string('A', 11))); + Assert.Throws( + () => Rs50HidppDisplayProtocol.CreateLayoutJ( + 0x12, + "SPEED", + "0 KMH", + "GEAR", + "\u0080")); + } + + [Fact] + public void ParseLayoutJAcknowledgement_AcceptsExactZeroBody() + { + Rs50HidppDisplayProtocol.ParseLayoutJAcknowledgement( + 0x12, + ValidLayoutJAcknowledgement()); + } + + [Theory] + [InlineData(0, 0x11)] + [InlineData(1, 0x01)] + [InlineData(2, 0x13)] + [InlineData(3, 0x3B)] + [InlineData(4, 0x01)] + [InlineData(63, 0x01)] + public void ParseLayoutJAcknowledgement_RejectsUnexpectedData( + int offset, + byte value) + { + byte[] response = ValidLayoutJAcknowledgement(); + response[offset] = value; + + Assert.Throws( + () => Rs50HidppDisplayProtocol.ParseLayoutJAcknowledgement( + 0x12, + response)); + } + + [Fact] + public void ParseLayoutJAcknowledgement_RejectsHidppError() + { + byte[] response = ErrorResponse( + expectedFeature: 0x12, + expectedFunction: 0x3A, + error: 0x08); + + Assert.Throws( + () => Rs50HidppDisplayProtocol.ParseLayoutJAcknowledgement( + 0x12, + response)); + } + + private static byte[] ValidDiscoveryResponse() + { + byte[] response = new byte[64]; + response[0] = 0x12; + response[1] = 0xFF; + response[2] = 0x00; + response[3] = 0x0A; + response[4] = 0x12; + return response; + } + + internal static byte[] ValidLayoutJAcknowledgement( + byte runtime = 0x12) + { + byte[] response = new byte[64]; + response[0] = 0x12; + response[1] = 0xFF; + response[2] = runtime; + response[3] = 0x3A; + return response; + } + + private static byte[] ErrorResponse( + byte expectedFeature, + byte expectedFunction, + byte error) + { + byte[] response = new byte[64]; + response[0] = 0x12; + response[1] = 0xFF; + response[2] = 0xFF; + response[3] = Rs50HidppDisplayProtocol.SoftwareId; + response[4] = expectedFeature; + response[5] = expectedFunction; + response[6] = error; + return response; + } + + private static string ReadText( + byte[] report, + int offset, + int length) + { + ReadOnlySpan field = + report.AsSpan(offset, length); + int terminator = field.IndexOf((byte)0); + if (terminator >= 0) + { + field = field[..terminator]; + } + + return Encoding.ASCII.GetString(field); + } +} diff --git a/LogiDynamicDash.Tests/Rs50NativeDisplayBridgeTests.cs b/LogiDynamicDash.Tests/Rs50NativeDisplayBridgeTests.cs new file mode 100644 index 0000000..1e321ff --- /dev/null +++ b/LogiDynamicDash.Tests/Rs50NativeDisplayBridgeTests.cs @@ -0,0 +1,77 @@ +using System.Runtime.InteropServices; +using System.Text; +using LogiDynamicDash.Models; +using LogiDynamicDash.Native; + +namespace LogiDynamicDash.Tests; + +public sealed class Rs50NativeDisplayBridgeTests +{ + [Fact] + public void NativeLayoutJFrame_MatchesAuditedAbi() + { + Assert.Equal( + 68, + Marshal.SizeOf< + Rs50NativeDisplayBridge.NativeLayoutJFrame>()); + Assert.Equal( + 8, + OffsetOf< + Rs50NativeDisplayBridge.NativeLayoutJFrame>("Row1")); + Assert.Equal( + 27, + OffsetOf< + Rs50NativeDisplayBridge.NativeLayoutJFrame>("Row2")); + Assert.Equal( + 37, + OffsetOf< + Rs50NativeDisplayBridge.NativeLayoutJFrame>("Row3")); + Assert.Equal( + 56, + OffsetOf< + Rs50NativeDisplayBridge.NativeLayoutJFrame>("Row4")); + } + + [Fact] + public void NativeStreamResult_MatchesAuditedAbi() + { + Assert.Equal( + 560, + Marshal.SizeOf< + Rs50NativeDisplayBridge.NativeStreamResult>()); + Assert.Equal( + 40, + OffsetOf< + Rs50NativeDisplayBridge.NativeStreamResult>("ProductName")); + } + + [Fact] + public void From_EncodesVisualRowsWithoutApplyingDriverPermutation() + { + LayoutJFrame frame = + new("SPEED", "123 KMH", "GEAR", "4"); + + Rs50NativeDisplayBridge.NativeLayoutJFrame native = + Rs50NativeDisplayBridge.NativeLayoutJFrame.From(frame); + + Assert.Equal(68u, native.StructSize); + Assert.Equal(5, native.Row1Length); + Assert.Equal(7, native.Row2Length); + Assert.Equal(4, native.Row3Length); + Assert.Equal(1, native.Row4Length); + Assert.Equal("SPEED", Decode(native.Row1, native.Row1Length)); + Assert.Equal("123 KMH", Decode(native.Row2, native.Row2Length)); + Assert.Equal("GEAR", Decode(native.Row3, native.Row3Length)); + Assert.Equal("4", Decode(native.Row4, native.Row4Length)); + Assert.All( + native.Row1[native.Row1Length..], + value => Assert.Equal(0, value)); + Assert.All(native.Reserved, value => Assert.Equal(0, value)); + } + + private static int OffsetOf(string fieldName) => + checked((int)Marshal.OffsetOf(fieldName)); + + private static string Decode(byte[] value, int length) => + Encoding.ASCII.GetString(value, 0, length); +} diff --git a/LogiDynamicDash.Tests/Rs50SharedHidppBoundedStreamTests.cs b/LogiDynamicDash.Tests/Rs50SharedHidppBoundedStreamTests.cs new file mode 100644 index 0000000..f86366d --- /dev/null +++ b/LogiDynamicDash.Tests/Rs50SharedHidppBoundedStreamTests.cs @@ -0,0 +1,288 @@ +using System.Text; +using LogiDynamicDash.Hidpp; +using Rs50SharedHidppBoundedStream; + +namespace LogiDynamicDash.Tests; + +public sealed class Rs50SharedHidppBoundedStreamTests +{ + private static readonly string[] ValidArguments = + [ + "--arm-rs50-shared-hidpp-bounded-stream", + "--confirm-ghub-closed", + "--confirm-iracing-closed", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running", + "--confirm-one-hz-five-fixed-frames" + ]; + + [Fact] + public void MissingArguments_RejectWithoutOpenOrDelay() + { + bool opened = false; + var delay = new RecordingDelay(); + + int exitCode = BuildHBoundedStreamProgram.Run( + [], + () => + { + opened = true; + throw new InvalidOperationException(); + }, + delay, + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(2, exitCode); + Assert.False(opened); + Assert.Equal(0, delay.WaitCount); + } + + [Theory] + [InlineData( + "--arm-rs50-shared-hidpp-bounded-stream", + "--confirm-ghub-closed", + "--confirm-iracing-closed", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running")] + [InlineData( + "--confirm-ghub-closed", + "--arm-rs50-shared-hidpp-bounded-stream", + "--confirm-iracing-closed", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running", + "--confirm-one-hz-five-fixed-frames")] + [InlineData( + "--arm-rs50-shared-hidpp-bounded-stream", + "--confirm-ghub-closed", + "--confirm-iracing-closed", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running", + "--confirm-one-hz-five-fixed-frames", + "--extra")] + public void PartialReorderedOrExtraArguments_Reject( + params string[] arguments) + { + bool opened = false; + + int exitCode = BuildHBoundedStreamProgram.Run( + arguments, + () => + { + opened = true; + throw new InvalidOperationException(); + }, + new RecordingDelay(), + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(2, exitCode); + Assert.False(opened); + } + + [Fact] + public void ExactArguments_SendFiveFixedFramesAtOneHzAndDispose() + { + var exchange = new RecordingExchange(); + var delay = new RecordingDelay(); + + int exitCode = BuildHBoundedStreamProgram.Run( + ValidArguments, + () => exchange, + delay, + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(0, exitCode); + Assert.True(exchange.Disposed); + Assert.Equal(4, delay.WaitCount); + Assert.Equal(6, exchange.Transactions.Count); + Assert.Equal( + Rs50HidppDisplayTransactionKind.DiscoverDisplayFeature, + exchange.Transactions[0].Kind); + + for (int index = 1; index <= 5; index++) + { + byte[] report = + exchange.Transactions[index].Request.ToArray(); + Assert.Equal("RS50 SHARED HIDPP", ReadText(report, 5, 19)); + Assert.Equal("BUILD H", ReadText(report, 24, 10)); + Assert.Equal( + $"FRAME {index} OF 5", + ReadText(report, 34, 19)); + Assert.Equal("1 HZ", ReadText(report, 53, 10)); + } + } + + [Fact] + public void DiscoveryFailure_SendsNoFrameOrDelayAndDisposes() + { + var exchange = new RecordingExchange + { + DiscoveryResponse = new byte[64] + }; + var delay = new RecordingDelay(); + + int exitCode = BuildHBoundedStreamProgram.Run( + ValidArguments, + () => exchange, + delay, + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(1, exitCode); + Assert.True(exchange.Disposed); + Assert.Single(exchange.Transactions); + Assert.Equal(0, delay.WaitCount); + } + + [Fact] + public void ThirdFrameFailure_StopsWithoutLaterFramesAndDisposes() + { + var exchange = new RecordingExchange + { + FailingLayoutNumber = 3 + }; + var delay = new RecordingDelay(); + + int exitCode = BuildHBoundedStreamProgram.Run( + ValidArguments, + () => exchange, + delay, + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(1, exitCode); + Assert.True(exchange.Disposed); + Assert.Equal(4, exchange.Transactions.Count); + Assert.Equal(2, delay.WaitCount); + } + + [Fact] + public void DelayFailure_StopsBeforeSecondFrameAndDisposes() + { + var exchange = new RecordingExchange(); + var delay = new RecordingDelay + { + Failure = new InvalidOperationException("delay rejected") + }; + + int exitCode = BuildHBoundedStreamProgram.Run( + ValidArguments, + () => exchange, + delay, + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(1, exitCode); + Assert.True(exchange.Disposed); + Assert.Equal(2, exchange.Transactions.Count); + Assert.Equal(1, delay.WaitCount); + } + + [Fact] + public void OpenFailure_IsReportedFailClosed() + { + using var error = new StringWriter(); + + int exitCode = BuildHBoundedStreamProgram.Run( + ValidArguments, + () => throw new IOException("open rejected"), + new RecordingDelay(), + TextWriter.Null, + error); + + Assert.Equal(1, exitCode); + Assert.Contains("failed closed", error.ToString()); + } + + private static string ReadText( + byte[] report, + int offset, + int length) + { + ReadOnlySpan field = report.AsSpan(offset, length); + int terminator = field.IndexOf((byte)0); + if (terminator >= 0) + { + field = field[..terminator]; + } + + return Encoding.ASCII.GetString(field); + } + + private sealed class RecordingDelay : IBuildHDelay + { + public Exception? Failure { get; init; } + + public int WaitCount { get; private set; } + + public void WaitOneSecond() + { + WaitCount++; + if (Failure is not null) + { + throw Failure; + } + } + } + + private sealed class RecordingExchange : IRs50HidppDisplayExchange + { + private int layoutCount; + + public byte[] DiscoveryResponse { get; init; } = + ValidDiscoveryResponse(); + + public int? FailingLayoutNumber { get; init; } + + public List Transactions { get; } = + []; + + public bool Disposed { get; private set; } + + public byte[] Exchange( + Rs50HidppDisplayTransaction transaction) + { + Transactions.Add(transaction); + if (transaction.Kind == + Rs50HidppDisplayTransactionKind.DiscoverDisplayFeature) + { + return DiscoveryResponse; + } + + layoutCount++; + return layoutCount == FailingLayoutNumber + ? new byte[64] + : ValidLayoutResponse(); + } + + public void Dispose() => + Disposed = true; + + private static byte[] ValidDiscoveryResponse() + { + byte[] response = new byte[64]; + response[0] = 0x12; + response[1] = 0xFF; + response[2] = 0x00; + response[3] = 0x0A; + response[4] = 0x12; + return response; + } + + private static byte[] ValidLayoutResponse() + { + byte[] response = new byte[64]; + response[0] = 0x12; + response[1] = 0xFF; + response[2] = 0x12; + response[3] = 0x3A; + return response; + } + } +} diff --git a/LogiDynamicDash.Tests/Rs50SharedHidppCoexistenceTests.cs b/LogiDynamicDash.Tests/Rs50SharedHidppCoexistenceTests.cs new file mode 100644 index 0000000..7230427 --- /dev/null +++ b/LogiDynamicDash.Tests/Rs50SharedHidppCoexistenceTests.cs @@ -0,0 +1,264 @@ +using System.Text; +using LogiDynamicDash.Hidpp; +using Rs50SharedHidppCoexistence; + +namespace LogiDynamicDash.Tests; + +public sealed class Rs50SharedHidppCoexistenceTests +{ + private static readonly string[] ValidArguments = + [ + "--arm-rs50-shared-hidpp-coexistence", + "--confirm-ghub-closed", + "--confirm-iracing-running", + "--confirm-car-stationary-in-pits", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running", + "--confirm-one-hz-five-fixed-frames" + ]; + + [Fact] + public void MissingArguments_RejectBeforeOpenOrDelay() + { + bool opened = false; + var delay = new RecordingDelay(); + + int exitCode = BuildICoexistenceProgram.Run( + [], + () => + { + opened = true; + throw new InvalidOperationException(); + }, + delay, + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(2, exitCode); + Assert.False(opened); + Assert.Equal(0, delay.WaitCount); + } + + [Theory] + [InlineData( + "--arm-rs50-shared-hidpp-coexistence", + "--confirm-ghub-closed", + "--confirm-iracing-running", + "--confirm-car-stationary-in-pits", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running")] + [InlineData( + "--confirm-ghub-closed", + "--arm-rs50-shared-hidpp-coexistence", + "--confirm-iracing-running", + "--confirm-car-stationary-in-pits", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running", + "--confirm-one-hz-five-fixed-frames")] + [InlineData( + "--arm-rs50-shared-hidpp-coexistence", + "--confirm-ghub-closed", + "--confirm-iracing-running", + "--confirm-car-stationary-in-pits", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running", + "--confirm-one-hz-five-fixed-frames", + "--extra")] + public void PartialReorderedOrExtraArguments_Reject( + params string[] arguments) + { + bool opened = false; + + int exitCode = BuildICoexistenceProgram.Run( + arguments, + () => + { + opened = true; + throw new InvalidOperationException(); + }, + new RecordingDelay(), + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(2, exitCode); + Assert.False(opened); + } + + [Fact] + public void ExactArguments_SendFiveFixedFramesAndDispose() + { + var exchange = new RecordingExchange(); + var delay = new RecordingDelay(); + + int exitCode = BuildICoexistenceProgram.Run( + ValidArguments, + () => exchange, + delay, + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(0, exitCode); + Assert.True(exchange.Disposed); + Assert.Equal(4, delay.WaitCount); + Assert.Equal(6, exchange.Transactions.Count); + + for (int index = 1; index <= 5; index++) + { + byte[] report = + exchange.Transactions[index].Request.ToArray(); + Assert.Equal("IRACING COEXIST", ReadText(report, 5, 19)); + Assert.Equal("BUILD I", ReadText(report, 24, 10)); + Assert.Equal( + $"FRAME {index} OF 5", + ReadText(report, 34, 19)); + Assert.Equal("1 HZ", ReadText(report, 53, 10)); + } + } + + [Fact] + public void ThirdFrameFailure_StopsAndDisposes() + { + var exchange = new RecordingExchange + { + FailingLayoutNumber = 3 + }; + var delay = new RecordingDelay(); + + int exitCode = BuildICoexistenceProgram.Run( + ValidArguments, + () => exchange, + delay, + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(1, exitCode); + Assert.True(exchange.Disposed); + Assert.Equal(4, exchange.Transactions.Count); + Assert.Equal(2, delay.WaitCount); + } + + [Fact] + public void DelayFailure_StopsBeforeSecondFrameAndDisposes() + { + var exchange = new RecordingExchange(); + var delay = new RecordingDelay + { + Failure = new IOException("delay rejected") + }; + + int exitCode = BuildICoexistenceProgram.Run( + ValidArguments, + () => exchange, + delay, + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(1, exitCode); + Assert.True(exchange.Disposed); + Assert.Equal(2, exchange.Transactions.Count); + Assert.Equal(1, delay.WaitCount); + } + + [Fact] + public void OpenFailure_IsReportedFailClosed() + { + using var error = new StringWriter(); + + int exitCode = BuildICoexistenceProgram.Run( + ValidArguments, + () => throw new IOException("open rejected"), + new RecordingDelay(), + TextWriter.Null, + error); + + Assert.Equal(1, exitCode); + Assert.Contains("failed closed", error.ToString()); + } + + private static string ReadText( + byte[] report, + int offset, + int length) + { + ReadOnlySpan field = report.AsSpan(offset, length); + int terminator = field.IndexOf((byte)0); + if (terminator >= 0) + { + field = field[..terminator]; + } + + return Encoding.ASCII.GetString(field); + } + + private sealed class RecordingDelay : IBuildIDelay + { + public Exception? Failure { get; init; } + + public int WaitCount { get; private set; } + + public void WaitOneSecond() + { + WaitCount++; + if (Failure is not null) + { + throw Failure; + } + } + } + + private sealed class RecordingExchange : IRs50HidppDisplayExchange + { + private int layoutCount; + + public int? FailingLayoutNumber { get; init; } + + public List Transactions { get; } = + []; + + public bool Disposed { get; private set; } + + public byte[] Exchange( + Rs50HidppDisplayTransaction transaction) + { + Transactions.Add(transaction); + if (transaction.Kind == + Rs50HidppDisplayTransactionKind.DiscoverDisplayFeature) + { + return ValidDiscoveryResponse(); + } + + layoutCount++; + return layoutCount == FailingLayoutNumber + ? new byte[64] + : ValidLayoutResponse(); + } + + public void Dispose() => + Disposed = true; + + private static byte[] ValidDiscoveryResponse() + { + byte[] response = new byte[64]; + response[0] = 0x12; + response[1] = 0xFF; + response[2] = 0x00; + response[3] = 0x0A; + response[4] = 0x12; + return response; + } + + private static byte[] ValidLayoutResponse() + { + byte[] response = new byte[64]; + response[0] = 0x12; + response[1] = 0xFF; + response[2] = 0x12; + response[3] = 0x3A; + return response; + } + } +} diff --git a/LogiDynamicDash.Tests/Rs50SharedHidppDisplaySessionTests.cs b/LogiDynamicDash.Tests/Rs50SharedHidppDisplaySessionTests.cs new file mode 100644 index 0000000..9dc9e39 --- /dev/null +++ b/LogiDynamicDash.Tests/Rs50SharedHidppDisplaySessionTests.cs @@ -0,0 +1,212 @@ +using LogiDynamicDash.Hidpp; +using LogiDynamicDash.Models; +using LogiDynamicDash.Native; + +namespace LogiDynamicDash.Tests; + +public sealed class Rs50SharedHidppDisplaySessionTests +{ + [Fact] + public void OpenSendClose_UsesOnlyDiscoveryAndLayoutJ() + { + RecordingExchange exchange = new(); + exchange.Responses.Enqueue(ValidDiscoveryResponse()); + exchange.Responses.Enqueue( + Rs50HidppDisplayProtocolTests + .ValidLayoutJAcknowledgement()); + Rs50SharedHidppDisplaySession session = new(exchange); + + session.Open(); + Rs50FrameOutcome outcome = + session.Send(new("SPEED", "0 KMH", "GEAR", "N")); + session.Close(); + + Assert.True(outcome.Transmitted); + Assert.Equal( + [ + Rs50HidppDisplayTransactionKind.DiscoverDisplayFeature, + Rs50HidppDisplayTransactionKind.SetLayoutJ + ], + exchange.Kinds); + Assert.Equal(2, exchange.Requests.Count); + } + + [Fact] + public void SendBeforeOpen_IsRejectedWithoutExchange() + { + RecordingExchange exchange = new(); + Rs50SharedHidppDisplaySession session = new(exchange); + + Assert.Throws( + () => session.Send(new("", "", "", ""))); + + Assert.Empty(exchange.Requests); + } + + [Fact] + public void RepeatedOpen_IsRejected() + { + RecordingExchange exchange = new(); + exchange.Responses.Enqueue(ValidDiscoveryResponse()); + Rs50SharedHidppDisplaySession session = new(exchange); + + session.Open(); + + Assert.Throws(session.Open); + Assert.Single(exchange.Requests); + } + + [Fact] + public void IdenticalFrame_IsSuppressed() + { + RecordingExchange exchange = OpenReadyExchange(); + Rs50SharedHidppDisplaySession session = new(exchange); + LayoutJFrame frame = new("SPEED", "0 KMH", "GEAR", "N"); + + session.Open(); + Rs50FrameOutcome first = session.Send(frame); + Rs50FrameOutcome second = session.Send(frame); + + Assert.True(first.Transmitted); + Assert.True(second.Unchanged); + Assert.Equal(2, exchange.Requests.Count); + } + + [Fact] + public void ChangedFrameInsideTwoHundredMilliseconds_IsRateLimited() + { + ManualTimeProvider time = new(); + RecordingExchange exchange = OpenReadyExchange(); + Rs50SharedHidppDisplaySession session = new(exchange, time); + + session.Open(); + session.Send(new("SPEED", "0 KMH", "GEAR", "N")); + time.Advance(TimeSpan.FromMilliseconds(199)); + + Rs50FrameOutcome outcome = + session.Send(new("SPEED", "1 KMH", "GEAR", "1")); + + Assert.True(outcome.RateLimited); + Assert.Equal(2, exchange.Requests.Count); + } + + [Fact] + public void ChangedFrameAtTwoHundredMilliseconds_IsTransmitted() + { + ManualTimeProvider time = new(); + RecordingExchange exchange = OpenReadyExchange(); + exchange.Responses.Enqueue( + Rs50HidppDisplayProtocolTests + .ValidLayoutJAcknowledgement()); + Rs50SharedHidppDisplaySession session = new(exchange, time); + + session.Open(); + session.Send(new("SPEED", "0 KMH", "GEAR", "N")); + time.Advance(TimeSpan.FromMilliseconds(200)); + + Rs50FrameOutcome outcome = + session.Send(new("SPEED", "1 KMH", "GEAR", "1")); + + Assert.True(outcome.Transmitted); + Assert.Equal(3, exchange.Requests.Count); + } + + [Fact] + public void ProtocolFailure_FaultsSessionAndBlocksFurtherExchange() + { + RecordingExchange exchange = new(); + exchange.Responses.Enqueue(ValidDiscoveryResponse()); + exchange.Responses.Enqueue(new byte[64]); + Rs50SharedHidppDisplaySession session = new(exchange); + + session.Open(); + Assert.Throws( + () => session.Send(new("SPEED", "0 KMH", "GEAR", "N"))); + + Assert.Throws( + () => session.Send(new("SPEED", "1 KMH", "GEAR", "1"))); + Assert.Equal(2, exchange.Requests.Count); + } + + [Fact] + public void DiscoveryFailure_FaultsSession() + { + RecordingExchange exchange = new(); + exchange.Responses.Enqueue(new byte[64]); + Rs50SharedHidppDisplaySession session = new(exchange); + + Assert.Throws(session.Open); + Assert.Throws(session.Open); + Assert.Single(exchange.Requests); + } + + [Fact] + public void Dispose_DisposesExchangeAndBlocksUse() + { + RecordingExchange exchange = new(); + Rs50SharedHidppDisplaySession session = new(exchange); + + session.Dispose(); + session.Dispose(); + + Assert.True(exchange.Disposed); + Assert.Throws(session.Open); + } + + private static RecordingExchange OpenReadyExchange() + { + RecordingExchange exchange = new(); + exchange.Responses.Enqueue(ValidDiscoveryResponse()); + exchange.Responses.Enqueue( + Rs50HidppDisplayProtocolTests + .ValidLayoutJAcknowledgement()); + return exchange; + } + + private static byte[] ValidDiscoveryResponse() + { + byte[] response = new byte[64]; + response[0] = 0x12; + response[1] = 0xFF; + response[2] = 0x00; + response[3] = 0x0A; + response[4] = 0x12; + return response; + } + + private sealed class RecordingExchange + : IRs50HidppDisplayExchange + { + public Queue Responses { get; } = []; + + public List Requests { get; } = []; + + public List Kinds { get; } = []; + + public bool Disposed { get; private set; } + + public byte[] Exchange(Rs50HidppDisplayTransaction transaction) + { + Requests.Add(transaction.Request.ToArray()); + Kinds.Add(transaction.Kind); + return Responses.Dequeue(); + } + + public void Dispose() => + Disposed = true; + } + + private sealed class ManualTimeProvider : TimeProvider + { + private long timestamp; + + public override long TimestampFrequency => + TimeSpan.TicksPerSecond; + + public override long GetTimestamp() => + timestamp; + + public void Advance(TimeSpan duration) => + timestamp += duration.Ticks; + } +} diff --git a/LogiDynamicDash.Tests/Rs50SharedHidppLayoutGalleryTests.cs b/LogiDynamicDash.Tests/Rs50SharedHidppLayoutGalleryTests.cs new file mode 100644 index 0000000..79c3934 --- /dev/null +++ b/LogiDynamicDash.Tests/Rs50SharedHidppLayoutGalleryTests.cs @@ -0,0 +1,287 @@ +using System.Text; +using LogiDynamicDash.Hidpp; +using Rs50SharedHidppLayoutGallery; + +namespace LogiDynamicDash.Tests; + +public sealed class Rs50SharedHidppLayoutGalleryTests +{ + private static readonly string[] ValidArguments = + [ + "--arm-rs50-shared-hidpp-layout-gallery", + "--confirm-ghub-closed", + "--confirm-iracing-closed", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running", + "--confirm-video-recording", + "--confirm-ten-layouts-three-seconds-each" + ]; + + [Fact] + public void ExactFactories_EncodeRecoveredAThroughJShapes() + { + Rs50HidppDisplayTransaction[] layouts = + [ + Rs50HidppDisplayProtocol.CreateLayoutA(0x12), + Rs50HidppDisplayProtocol.CreateLayoutB(0x12), + Rs50HidppDisplayProtocol.CreateLayoutC(0x12, 128), + Rs50HidppDisplayProtocol.CreateLayoutD( + 0x12, + 64, + 191, + "LAYOUT D"), + Rs50HidppDisplayProtocol.CreateLayoutE( + 0x12, + mainGaugeValue: 64, + thinIndicatorValue: 191, + rightText: "E1", + leftText: "LAYOUTE"), + Rs50HidppDisplayProtocol.CreateLayoutF( + 0x12, + "F", + "123"), + Rs50HidppDisplayProtocol.CreateLayoutG( + 0x12, + "G", + "456"), + Rs50HidppDisplayProtocol.CreateLayoutH( + 0x12, + "LAYOUT H WIDE TEST", + "H SECOND"), + Rs50HidppDisplayProtocol.CreateLayoutI( + 0x12, + "LAYOUT I TOP", + "I SECOND", + "LAYOUT I LOWER", + "I FOURTH"), + Rs50HidppDisplayProtocol.CreateLayoutJ( + 0x12, + "LAYOUT J TOP", + "J SECOND", + "LAYOUT J LOWER", + "J FOURTH") + ]; + + Assert.Equal(10, layouts.Length); + for (int index = 0; index < layouts.Length; index++) + { + byte[] request = layouts[index].Request.ToArray(); + Assert.Equal(64, request.Length); + Assert.Equal( + [0x12, 0xFF, 0x12, 0x3A, (byte)index], + request[..5]); + Assert.Equal( + (Rs50HidppDisplayTransactionKind)(index + 1), + layouts[index].Kind); + } + + Assert.All(layouts[0].Request.Span[5..].ToArray(), AssertZero); + Assert.All(layouts[1].Request.Span[5..].ToArray(), AssertZero); + Assert.Equal(128, layouts[2].Request.Span[5]); + + Assert.Equal(64, layouts[3].Request.Span[5]); + Assert.Equal(191, layouts[3].Request.Span[6]); + Assert.Equal("LAYOUT D", ReadText(layouts[3], 7, 11)); + + Assert.Equal("E1", ReadText(layouts[4], 7, 3)); + Assert.Equal("LAYOUTE", ReadText(layouts[4], 10, 7)); + Assert.Equal("F", ReadText(layouts[5], 5, 1)); + Assert.Equal("123", ReadText(layouts[5], 6, 3)); + Assert.Equal("G", ReadText(layouts[6], 5, 1)); + Assert.Equal("456", ReadText(layouts[6], 6, 3)); + Assert.Equal( + "LAYOUT H WIDE TEST", + ReadText(layouts[7], 5, 21)); + Assert.Equal("H SECOND", ReadText(layouts[7], 26, 10)); + Assert.Equal("LAYOUT I TOP", ReadText(layouts[8], 5, 19)); + Assert.Equal("I SECOND", ReadText(layouts[8], 24, 10)); + Assert.Equal("LAYOUT I LOWER", ReadText(layouts[8], 34, 19)); + Assert.Equal("I FOURTH", ReadText(layouts[8], 53, 10)); + Assert.Equal("LAYOUT J TOP", ReadText(layouts[9], 5, 19)); + Assert.Equal("J SECOND", ReadText(layouts[9], 24, 10)); + Assert.Equal("LAYOUT J LOWER", ReadText(layouts[9], 34, 19)); + Assert.Equal("J FOURTH", ReadText(layouts[9], 53, 10)); + } + + [Fact] + public void LayoutFactories_RejectOversizedOrInvalidText() + { + Assert.Throws( + () => Rs50HidppDisplayProtocol.CreateLayoutD( + 0x12, + 0, + 0, + new string('D', 12))); + Assert.Throws( + () => Rs50HidppDisplayProtocol.CreateLayoutE( + 0x12, + 0, + 0, + "1234", + "")); + Assert.Throws( + () => Rs50HidppDisplayProtocol.CreateLayoutF( + 0x12, + "FF", + "")); + Assert.Throws( + () => Rs50HidppDisplayProtocol.CreateLayoutH( + 0x12, + "", + "\u0080")); + Assert.Throws( + () => Rs50HidppDisplayProtocol.CreateLayoutI( + 0x12, + "", + "", + "", + new string('I', 11))); + } + + [Fact] + public void InvalidArguments_RejectBeforeOpenOrDelay() + { + bool opened = false; + var delay = new RecordingDelay(); + + int exitCode = BuildKLayoutGalleryProgram.Run( + [], + () => + { + opened = true; + throw new InvalidOperationException(); + }, + delay, + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(2, exitCode); + Assert.False(opened); + Assert.Equal(0, delay.WaitCount); + } + + [Fact] + public void ExactArguments_SendAThroughJWithTenDelays() + { + var exchange = new RecordingExchange(); + var delay = new RecordingDelay(); + using var output = new StringWriter(); + + int exitCode = BuildKLayoutGalleryProgram.Run( + ValidArguments, + () => exchange, + delay, + output, + TextWriter.Null); + + Assert.Equal(0, exitCode); + Assert.True(exchange.Disposed); + Assert.Equal(10, delay.WaitCount); + Assert.Equal(11, exchange.Transactions.Count); + Assert.Equal( + Rs50HidppDisplayTransactionKind.DiscoverDisplayFeature, + exchange.Transactions[0].Kind); + + for (int index = 0; index < 10; index++) + { + Assert.Equal( + (Rs50HidppDisplayTransactionKind)(index + 1), + exchange.Transactions[index + 1].Kind); + Assert.Contains( + $"showing Layout {(char)('A' + index)}", + output.ToString()); + } + } + + [Fact] + public void LayoutEFailure_StopsAndDisposes() + { + var exchange = new RecordingExchange + { + FailingKind = Rs50HidppDisplayTransactionKind.SetLayoutE + }; + var delay = new RecordingDelay(); + + int exitCode = BuildKLayoutGalleryProgram.Run( + ValidArguments, + () => exchange, + delay, + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(1, exitCode); + Assert.True(exchange.Disposed); + Assert.Equal(6, exchange.Transactions.Count); + Assert.Equal(4, delay.WaitCount); + } + + private static void AssertZero(byte value) => + Assert.Equal(0, value); + + private static string ReadText( + Rs50HidppDisplayTransaction transaction, + int offset, + int length) + { + ReadOnlySpan field = + transaction.Request.Span.Slice(offset, length); + int terminator = field.IndexOf((byte)0); + if (terminator >= 0) + { + field = field[..terminator]; + } + + return Encoding.ASCII.GetString(field); + } + + private sealed class RecordingDelay : IBuildKDelay + { + public int WaitCount { get; private set; } + + public void WaitThreeSeconds() => + WaitCount++; + } + + private sealed class RecordingExchange : IRs50HidppDisplayExchange + { + public Rs50HidppDisplayTransactionKind? FailingKind { get; init; } + + public List Transactions { get; } = + []; + + public bool Disposed { get; private set; } + + public byte[] Exchange( + Rs50HidppDisplayTransaction transaction) + { + Transactions.Add(transaction); + if (transaction.Kind == + Rs50HidppDisplayTransactionKind.DiscoverDisplayFeature) + { + byte[] discovery = new byte[64]; + discovery[0] = 0x12; + discovery[1] = 0xFF; + discovery[2] = 0x00; + discovery[3] = 0x0A; + discovery[4] = 0x12; + return discovery; + } + + if (transaction.Kind == FailingKind) + { + return new byte[64]; + } + + byte[] acknowledgement = new byte[64]; + acknowledgement[0] = 0x12; + acknowledgement[1] = 0xFF; + acknowledgement[2] = 0x12; + acknowledgement[3] = 0x3A; + return acknowledgement; + } + + public void Dispose() => + Disposed = true; + } +} diff --git a/LogiDynamicDash.Tests/Rs50SharedHidppOneShotTests.cs b/LogiDynamicDash.Tests/Rs50SharedHidppOneShotTests.cs new file mode 100644 index 0000000..a9f6eff --- /dev/null +++ b/LogiDynamicDash.Tests/Rs50SharedHidppOneShotTests.cs @@ -0,0 +1,227 @@ +using System.Text; +using LogiDynamicDash.Hidpp; +using Rs50SharedHidppOneShot; + +namespace LogiDynamicDash.Tests; + +public sealed class Rs50SharedHidppOneShotTests +{ + private static readonly string[] ValidArguments = + [ + "--arm-rs50-shared-hidpp", + "--confirm-ghub-closed", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running", + "--confirm-one-fixed-frame" + ]; + + [Fact] + public void MissingArguments_RejectsWithoutOpeningExchange() + { + bool opened = false; + + int exitCode = BuildGOneShotProgram.Run( + [], + () => + { + opened = true; + throw new InvalidOperationException(); + }, + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(2, exitCode); + Assert.False(opened); + } + + [Theory] + [InlineData( + "--arm-rs50-shared-hidpp", + "--confirm-ghub-closed", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running")] + [InlineData( + "--confirm-ghub-closed", + "--arm-rs50-shared-hidpp", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running", + "--confirm-one-fixed-frame")] + [InlineData( + "--arm-rs50-shared-hidpp", + "--confirm-ghub-closed", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running", + "--confirm-one-fixed-frame", + "--extra")] + public void PartialReorderedOrExtraArguments_RejectsWithoutOpening( + params string[] arguments) + { + bool opened = false; + + int exitCode = BuildGOneShotProgram.Run( + arguments, + () => + { + opened = true; + throw new InvalidOperationException(); + }, + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(2, exitCode); + Assert.False(opened); + } + + [Fact] + public void ExactArguments_SendOneFixedFrameAndDispose() + { + var exchange = new FakeExchange(); + + int exitCode = BuildGOneShotProgram.Run( + ValidArguments, + () => exchange, + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(0, exitCode); + Assert.True(exchange.Disposed); + Assert.Equal(2, exchange.Transactions.Count); + Assert.Equal( + Rs50HidppDisplayTransactionKind.DiscoverDisplayFeature, + exchange.Transactions[0].Kind); + + Rs50HidppDisplayTransaction layout = + exchange.Transactions[1]; + Assert.Equal( + Rs50HidppDisplayTransactionKind.SetLayoutJ, + layout.Kind); + + byte[] report = layout.Request.ToArray(); + Assert.Equal("RS50 SHARED HIDPP", ReadText(report, 5, 19)); + Assert.Equal("BUILD G", ReadText(report, 24, 10)); + Assert.Equal("ONE SHOT ONLY", ReadText(report, 34, 19)); + Assert.Equal("USBPCAP", ReadText(report, 53, 10)); + } + + [Fact] + public void DiscoveryFailure_FailsClosedWithoutLayoutAndDisposes() + { + var exchange = new FakeExchange + { + DiscoveryResponse = new byte[64] + }; + + int exitCode = BuildGOneShotProgram.Run( + ValidArguments, + () => exchange, + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(1, exitCode); + Assert.True(exchange.Disposed); + Assert.Single(exchange.Transactions); + } + + [Fact] + public void LayoutFailure_FailsClosedAndDisposes() + { + var exchange = new FakeExchange + { + LayoutResponse = new byte[64] + }; + + int exitCode = BuildGOneShotProgram.Run( + ValidArguments, + () => exchange, + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(1, exitCode); + Assert.True(exchange.Disposed); + Assert.Equal(2, exchange.Transactions.Count); + } + + [Fact] + public void OpenFailure_IsReportedAsFailClosed() + { + using var error = new StringWriter(); + + int exitCode = BuildGOneShotProgram.Run( + ValidArguments, + () => throw new IOException("open rejected"), + TextWriter.Null, + error); + + Assert.Equal(1, exitCode); + Assert.Contains("failed closed", error.ToString()); + Assert.Contains("open rejected", error.ToString()); + } + + private static string ReadText( + byte[] report, + int offset, + int length) + { + ReadOnlySpan field = + report.AsSpan(offset, length); + int terminator = field.IndexOf((byte)0); + if (terminator >= 0) + { + field = field[..terminator]; + } + + return Encoding.ASCII.GetString(field); + } + + private sealed class FakeExchange : IRs50HidppDisplayExchange + { + public byte[] DiscoveryResponse { get; init; } = + ValidDiscoveryResponse(); + + public byte[] LayoutResponse { get; init; } = + ValidLayoutResponse(); + + public List Transactions { get; } = + []; + + public bool Disposed { get; private set; } + + public byte[] Exchange( + Rs50HidppDisplayTransaction transaction) + { + Transactions.Add(transaction); + return transaction.Kind == + Rs50HidppDisplayTransactionKind.DiscoverDisplayFeature + ? DiscoveryResponse + : LayoutResponse; + } + + public void Dispose() => + Disposed = true; + + private static byte[] ValidDiscoveryResponse() + { + byte[] response = new byte[64]; + response[0] = 0x12; + response[1] = 0xFF; + response[2] = 0x00; + response[3] = 0x0A; + response[4] = 0x12; + return response; + } + + private static byte[] ValidLayoutResponse() + { + byte[] response = new byte[64]; + response[0] = 0x12; + response[1] = 0xFF; + response[2] = 0x12; + response[3] = 0x3A; + return response; + } + } +} diff --git a/LogiDynamicDash.Tests/Rs50SharedHidppTelemetryTrialTests.cs b/LogiDynamicDash.Tests/Rs50SharedHidppTelemetryTrialTests.cs new file mode 100644 index 0000000..67a32e9 --- /dev/null +++ b/LogiDynamicDash.Tests/Rs50SharedHidppTelemetryTrialTests.cs @@ -0,0 +1,324 @@ +using System.Text.Json; +using LogiDynamicDash.Hidpp; +using Rs50SharedHidppTelemetryTrial; + +namespace LogiDynamicDash.Tests; + +public sealed class Rs50SharedHidppTelemetryTrialTests +{ + private static readonly string[] ValidArguments = + [ + "--arm-rs50-shared-hidpp-telemetry", + "--confirm-ghub-closed", + "--confirm-iracing-running", + "--confirm-car-stationary-in-pits", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running", + "--confirm-video-recording", + "--confirm-10-second-telemetry-trial" + ]; + + [Fact] + public async Task MissingArguments_RejectBeforeOpeningAnything() + { + bool opened = false; + var source = new RecordingSource([]); + + int exitCode = await BuildJTelemetryTrialProgram.RunAsync( + [], + source, + () => + { + opened = true; + throw new InvalidOperationException(); + }, + TimeProvider.System, + new CancellationToken(canceled: true), + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(2, exitCode); + Assert.False(opened); + Assert.False(source.Started); + } + + [Fact] + public async Task NeutralStationaryTelemetry_SendsExpectedLayout() + { + using var cancellationSource = new CancellationTokenSource(); + var source = new RecordingSource( + [new(true, true, 0, 0.0f)], + cancellationSource); + var exchange = new RecordingExchange(); + + int exitCode = await BuildJTelemetryTrialProgram.RunAsync( + ValidArguments, + source, + () => exchange, + TimeProvider.System, + cancellationSource.Token, + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(0, exitCode); + Assert.True(exchange.Disposed); + Assert.Equal(2, exchange.Transactions.Count); + byte[] report = exchange.Transactions[1].Request.ToArray(); + Assert.Equal("SPEED", ReadText(report, 5, 19)); + Assert.Equal("0 KMH", ReadText(report, 24, 10)); + Assert.Equal("GEAR", ReadText(report, 34, 19)); + Assert.Equal("N", ReadText(report, 53, 10)); + } + + [Fact] + public async Task Movement_StopsBeforeSetterAndDisposes() + { + var source = new RecordingSource( + [new(true, true, 1, 0.51f)]); + var exchange = new RecordingExchange(); + + int exitCode = await BuildJTelemetryTrialProgram.RunAsync( + ValidArguments, + source, + () => exchange, + TimeProvider.System, + CancellationToken.None, + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(1, exitCode); + Assert.True(exchange.Disposed); + Assert.Single(exchange.Transactions); + } + + [Fact] + public async Task DisconnectAfterValidTelemetry_FailsWithoutAnotherSetter() + { + var source = new RecordingSource( + [ + new(true, true, 0, 0.0f), + new(false, null, null, null) + ]); + var exchange = new RecordingExchange(); + + int exitCode = await BuildJTelemetryTrialProgram.RunAsync( + ValidArguments, + source, + () => exchange, + TimeProvider.System, + CancellationToken.None, + TextWriter.Null, + TextWriter.Null); + + Assert.Equal(1, exitCode); + Assert.True(exchange.Disposed); + Assert.Equal(2, exchange.Transactions.Count); + } + + [Fact] + public void RecordedExchange_WritesExactRequestAndResponseEvents() + { + using var transcriptOutput = new StringWriter(); + using var transcript = new BuildJTransactionTranscript( + transcriptOutput, + ".tmp/test.jsonl"); + var inner = new RecordingExchange(); + using var exchange = new BuildJRecordedExchange( + inner, + transcript, + TimeProvider.System); + + Rs50HidppDisplayTransaction discovery = + Rs50HidppDisplayProtocol.CreateDiscovery(); + byte[] discoveryResponse = exchange.Exchange(discovery); + byte runtimeIndex = + Rs50HidppDisplayProtocol.ParseDiscoveryResponse( + discoveryResponse); + Rs50HidppDisplayTransaction layout = + Rs50HidppDisplayProtocol.CreateLayoutJ( + runtimeIndex, + "SPEED", + "0 KMH", + "GEAR", + "N"); + exchange.Exchange(layout); + + string[] lines = transcriptOutput + .ToString() + .Split( + Environment.NewLine, + StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(4, lines.Length); + + using JsonDocument discoveryRequest = + JsonDocument.Parse(lines[0]); + Assert.Equal( + "request", + discoveryRequest.RootElement + .GetProperty("event_type") + .GetString()); + Assert.Equal( + Convert.ToHexString(discovery.Request.Span), + discoveryRequest.RootElement + .GetProperty("report_hex") + .GetString()); + + using JsonDocument layoutResponse = + JsonDocument.Parse(lines[3]); + Assert.Equal( + "response", + layoutResponse.RootElement + .GetProperty("event_type") + .GetString()); + Assert.True( + layoutResponse.RootElement + .GetProperty("exact_header_match") + .GetBoolean()); + Assert.Equal( + "SetLayoutJ", + layoutResponse.RootElement + .GetProperty("transaction") + .GetString()); + } + + [Fact] + public void Transcript_StopsBeforeExceedingBoundedCapacity() + { + using var transcriptOutput = new StringWriter(); + using var transcript = new BuildJTransactionTranscript( + transcriptOutput, + ".tmp/test.jsonl"); + Rs50HidppDisplayTransaction discovery = + Rs50HidppDisplayProtocol.CreateDiscovery(); + + for (int index = 0; + index < BuildJTransactionTranscript.MaximumTransactions; + index++) + { + transcript.RecordRequest( + discovery, + DateTimeOffset.UnixEpoch); + } + + Assert.Throws( + () => transcript.RecordRequest( + discovery, + DateTimeOffset.UnixEpoch)); + } + + [Fact] + public void RecordedExchange_LogsFailureTypeWithoutExceptionMessage() + { + using var transcriptOutput = new StringWriter(); + using var transcript = new BuildJTransactionTranscript( + transcriptOutput, + ".tmp/test.jsonl"); + using var exchange = new BuildJRecordedExchange( + new FailingExchange(), + transcript, + TimeProvider.System); + + Assert.Throws( + () => exchange.Exchange( + Rs50HidppDisplayProtocol.CreateDiscovery())); + + string transcriptText = transcriptOutput.ToString(); + Assert.Contains( + "\"event_type\":\"failure\"", + transcriptText); + Assert.Contains( + typeof(IOException).FullName!, + transcriptText); + Assert.DoesNotContain( + FailingExchange.SensitiveMessage, + transcriptText); + } + + private static string ReadText( + byte[] report, + int offset, + int length) + { + ReadOnlySpan field = report.AsSpan(offset, length); + int terminator = field.IndexOf((byte)0); + if (terminator >= 0) + { + field = field[..terminator]; + } + + return System.Text.Encoding.ASCII.GetString(field); + } + + private sealed class RecordingSource( + IReadOnlyList snapshots, + CancellationTokenSource? cancellationSource = null) + : IBuildJTelemetrySource + { + public bool Started { get; private set; } + + public Task MonitorAsync( + Action onTelemetry, + CancellationToken cancellationToken) + { + Started = true; + foreach (BuildJTelemetrySnapshot snapshot in snapshots) + { + onTelemetry(snapshot); + } + + cancellationSource?.Cancel(); + cancellationToken.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + } + + private sealed class RecordingExchange : IRs50HidppDisplayExchange + { + public List Transactions { get; } = + []; + + public bool Disposed { get; private set; } + + public byte[] Exchange( + Rs50HidppDisplayTransaction transaction) + { + Transactions.Add(transaction); + if (transaction.Kind == + Rs50HidppDisplayTransactionKind.DiscoverDisplayFeature) + { + byte[] discovery = new byte[64]; + discovery[0] = 0x12; + discovery[1] = 0xFF; + discovery[2] = 0x00; + discovery[3] = 0x0A; + discovery[4] = 0x12; + return discovery; + } + + byte[] acknowledgement = new byte[64]; + acknowledgement[0] = 0x12; + acknowledgement[1] = 0xFF; + acknowledgement[2] = 0x12; + acknowledgement[3] = 0x3A; + return acknowledgement; + } + + public void Dispose() => + Disposed = true; + } + + private sealed class FailingExchange : IRs50HidppDisplayExchange + { + internal const string SensitiveMessage = + "Do not copy a local device path into the transcript."; + + public byte[] Exchange( + Rs50HidppDisplayTransaction transaction) => + throw new IOException(SensitiveMessage); + + public void Dispose() + { + } + } +} diff --git a/LogiDynamicDash.Tests/TelemetrySnapshotTests.cs b/LogiDynamicDash.Tests/TelemetrySnapshotTests.cs new file mode 100644 index 0000000..69dc91c --- /dev/null +++ b/LogiDynamicDash.Tests/TelemetrySnapshotTests.cs @@ -0,0 +1,28 @@ +using LogiDynamicDash.Models; + +namespace LogiDynamicDash.Tests; + +public sealed class TelemetrySnapshotTests +{ + [Fact] + public void WithUpdate_LeavesPublishedSnapshotUnchanged() + { + TelemetrySnapshot published = new() + { + ConnectionState = "CONNECTED", + Gear = 3, + SpeedMetersPerSecond = 40.0f + }; + + TelemetrySnapshot updated = published with + { + Gear = 4, + SpeedMetersPerSecond = 45.0f + }; + + Assert.Equal(3, published.Gear); + Assert.Equal(40.0f, published.SpeedMetersPerSecond); + Assert.Equal(4, updated.Gear); + Assert.Equal(45.0f, updated.SpeedMetersPerSecond); + } +} diff --git a/LogiDynamicDash.slnx b/LogiDynamicDash.slnx index 2d884e4..cc8e59e 100644 --- a/LogiDynamicDash.slnx +++ b/LogiDynamicDash.slnx @@ -1,5 +1,15 @@ + + + + + + + + + + diff --git a/LogiDynamicDash.vsconfig b/LogiDynamicDash.vsconfig new file mode 100644 index 0000000..5ceeb2b --- /dev/null +++ b/LogiDynamicDash.vsconfig @@ -0,0 +1,8 @@ +{ + "version": "1.0", + "components": [ + "Microsoft.VisualStudio.Workload.NativeDesktop", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "Microsoft.VisualStudio.Component.Windows11SDK.26100" + ] +} diff --git a/LogiDynamicDash/Controllers/DisplayController.cs b/LogiDynamicDash/Controllers/DisplayController.cs index 58f165c..b1a9455 100644 --- a/LogiDynamicDash/Controllers/DisplayController.cs +++ b/LogiDynamicDash/Controllers/DisplayController.cs @@ -2,7 +2,7 @@ namespace LogiDynamicDash.Controllers; -internal sealed class DisplayController +internal sealed class DisplayController(TimeProvider? timeProvider = null) { private static readonly TimeSpan BrakeBiasDuration = TimeSpan.FromSeconds(2); @@ -15,25 +15,28 @@ internal sealed class DisplayController private bool _lastLapInitialized; - private DateTime _brakeBiasExpiresAt = - DateTime.MinValue; + private readonly TimeProvider _timeProvider = + timeProvider ?? TimeProvider.System; - private DateTime _lastLapExpiresAt = - DateTime.MinValue; + private DateTimeOffset _brakeBiasExpiresAt = + DateTimeOffset.MinValue; + + private DateTimeOffset _lastLapExpiresAt = + DateTimeOffset.MinValue; public DisplayMode SelectMode( TelemetrySnapshot snapshot) { - DateTime now = DateTime.UtcNow; - - DetectCompletedLap(snapshot, now); - DetectBrakeBiasChange(snapshot, now); + DateTimeOffset now = _timeProvider.GetUtcNow(); if (!IsConnected(snapshot)) { return DisplayMode.ConnectionProblem; } + DetectCompletedLap(snapshot, now); + DetectBrakeBiasChange(snapshot, now); + if (now < _brakeBiasExpiresAt) { return DisplayMode.BrakeBias; @@ -49,9 +52,11 @@ public DisplayMode SelectMode( private void DetectBrakeBiasChange( TelemetrySnapshot snapshot, - DateTime now) + DateTimeOffset now) { - if (snapshot.BrakeBiasPercent is not float currentBrakeBias) + if (snapshot.BrakeBiasPercent is not float currentBrakeBias || + !float.IsFinite(currentBrakeBias) || + currentBrakeBias is < 0 or > 100) { return; } @@ -81,7 +86,7 @@ private void DetectBrakeBiasChange( private void DetectCompletedLap( TelemetrySnapshot snapshot, - DateTime now) + DateTimeOffset now) { if (!_lastLapInitialized) { @@ -94,6 +99,7 @@ private void DetectCompletedLap( } if (snapshot.LastLapTimeSeconds is not float currentLastLap || + !float.IsFinite(currentLastLap) || currentLastLap <= 0) { return; @@ -127,4 +133,4 @@ private static bool IsConnected( "CONNECTED", StringComparison.OrdinalIgnoreCase); } -} \ No newline at end of file +} diff --git a/LogiDynamicDash/Displays/CompositeDisplaySink.cs b/LogiDynamicDash/Displays/CompositeDisplaySink.cs new file mode 100644 index 0000000..f41ebb7 --- /dev/null +++ b/LogiDynamicDash/Displays/CompositeDisplaySink.cs @@ -0,0 +1,62 @@ +using LogiDynamicDash.Models; + +namespace LogiDynamicDash.Displays; + +internal sealed class CompositeDisplaySink( + params IDisplaySink[] sinks) : IDisplaySink +{ + private int initializedCount; + + public void Initialize() + { + try + { + foreach (IDisplaySink sink in sinks) + { + sink.Initialize(); + initializedCount++; + } + } + catch + { + Stop(); + throw; + } + } + + public void Render(LayoutJFrame frame) + { + ArgumentNullException.ThrowIfNull(frame); + + foreach (IDisplaySink sink in sinks) + { + sink.Render(frame); + } + } + + public void Stop() + { + List? failures = null; + + for (int index = initializedCount - 1; index >= 0; index--) + { + try + { + sinks[index].Stop(); + } + catch (Exception exception) + { + failures ??= []; + failures.Add(exception); + } + } + + initializedCount = 0; + if (failures is not null) + { + throw new AggregateException( + "One or more display sinks failed to stop.", + failures); + } + } +} diff --git a/LogiDynamicDash/Displays/ConsoleDashboard.cs b/LogiDynamicDash/Displays/ConsoleDashboard.cs index 780d438..b5d65b3 100644 --- a/LogiDynamicDash/Displays/ConsoleDashboard.cs +++ b/LogiDynamicDash/Displays/ConsoleDashboard.cs @@ -2,22 +2,30 @@ namespace LogiDynamicDash.Displays; -internal sealed class ConsoleDashboard +internal sealed class ConsoleDashboard : IDisplaySink { private const int DashboardWidth = 44; + private readonly bool _isInteractive = + !Console.IsOutputRedirected; public void Initialize() { Console.Title = "LogiDynamicDash"; - Console.CursorVisible = false; - Console.Clear(); + + if (_isInteractive) + { + Console.CursorVisible = false; + Console.Clear(); + } } - public void Render( - TelemetrySnapshot snapshot, - DisplayMode mode) + public void Render(LayoutJFrame frame) { - Console.SetCursorPosition(0, 0); + ArgumentNullException.ThrowIfNull(frame); + if (_isInteractive) + { + Console.SetCursorPosition(0, 0); + } WriteDashboardLine( new string('=', DashboardWidth)); @@ -30,24 +38,10 @@ public void Render( WriteDashboardLine(); - switch (mode) - { - case DisplayMode.BrakeBias: - RenderBrakeBias(snapshot); - break; - - case DisplayMode.LastLap: - RenderLastLap(snapshot); - break; - - case DisplayMode.ConnectionProblem: - RenderConnectionProblem(snapshot); - break; - - default: - RenderNormal(snapshot); - break; - } + WriteCentered(frame.Line1); + WriteCentered(frame.Line2); + WriteCentered(frame.Line3); + WriteCentered(frame.Line4); WriteDashboardLine( new string('-', DashboardWidth)); @@ -58,109 +52,14 @@ public void Render( public void Stop() { - Console.CursorVisible = true; - Console.Clear(); - - Console.WriteLine( - "Telemetry monitoring stopped."); - } - - private static void RenderNormal( - TelemetrySnapshot snapshot) - { - string gear = - FormatGear(snapshot.Gear); - - string speed = - snapshot.SpeedMetersPerSecond is float metersPerSecond - ? $"{metersPerSecond * 3.6f:F0} km/h" - : "N/A"; - - WriteCentered($"GEAR {gear}"); - WriteCentered(speed); - WriteDashboardLine(); - WriteDashboardLine(); - } - - private static void RenderBrakeBias( - TelemetrySnapshot snapshot) - { - string brakeBias = - snapshot.BrakeBiasPercent is float bias - ? $"{bias:F1}%" - : "N/A"; - - WriteDashboardLine(); - WriteCentered("BRAKE BIAS"); - WriteCentered(brakeBias); - WriteDashboardLine(); - } - - private static void RenderLastLap( - TelemetrySnapshot snapshot) - { - string lastLap = - FormatLapTime( - snapshot.LastLapTimeSeconds); - - WriteDashboardLine(); - WriteCentered("LAST LAP"); - WriteCentered(lastLap); - WriteDashboardLine(); - } - - private static void RenderConnectionProblem( - TelemetrySnapshot snapshot) - { - string message = - snapshot.ConnectionState switch - { - "WAITING" => - "WAITING FOR IRACING", - - "ERROR" => - "TELEMETRY ERROR", - - _ => - snapshot.ConnectionState - }; - - WriteDashboardLine(); - WriteCentered("IRACING"); - WriteCentered(message); - WriteDashboardLine(); - } - - private static string FormatLapTime( - float? totalSeconds) - { - if (totalSeconds is null || - totalSeconds <= 0) + if (_isInteractive) { - return "N/A"; + Console.CursorVisible = true; + Console.Clear(); } - TimeSpan time = - TimeSpan.FromSeconds( - totalSeconds.Value); - - int minutes = - (int)time.TotalMinutes; - - return - $"{minutes}:{time.Seconds:00}.{time.Milliseconds:000}"; - } - - private static string FormatGear( - int? gear) - { - return gear switch - { - -1 => "R", - 0 => "N", - int value => value.ToString(), - null => "N/A" - }; + Console.WriteLine( + "Telemetry monitoring stopped."); } private static void WriteCentered( @@ -193,4 +92,4 @@ private static void WriteDashboardLine( Console.WriteLine( text.PadRight(DashboardWidth)); } -} \ No newline at end of file +} diff --git a/LogiDynamicDash/Displays/DistinctDisplaySink.cs b/LogiDynamicDash/Displays/DistinctDisplaySink.cs new file mode 100644 index 0000000..00c7fd4 --- /dev/null +++ b/LogiDynamicDash/Displays/DistinctDisplaySink.cs @@ -0,0 +1,43 @@ +using LogiDynamicDash.Models; + +namespace LogiDynamicDash.Displays; + +/// +/// Prevents identical presentation frames from reaching a display transport. +/// The remembered frame advances only after the wrapped sink accepts it. +/// +internal sealed class DistinctDisplaySink(IDisplaySink inner) : IDisplaySink +{ + private LayoutJFrame? lastFrame; + + public void Initialize() + { + inner.Initialize(); + lastFrame = null; + } + + public void Render(LayoutJFrame frame) + { + ArgumentNullException.ThrowIfNull(frame); + + if (frame == lastFrame) + { + return; + } + + inner.Render(frame); + lastFrame = frame; + } + + public void Stop() + { + try + { + inner.Stop(); + } + finally + { + lastFrame = null; + } + } +} diff --git a/LogiDynamicDash/Displays/IDisplaySink.cs b/LogiDynamicDash/Displays/IDisplaySink.cs new file mode 100644 index 0000000..055cd4e --- /dev/null +++ b/LogiDynamicDash/Displays/IDisplaySink.cs @@ -0,0 +1,16 @@ +using LogiDynamicDash.Models; + +namespace LogiDynamicDash.Displays; + +/// +/// Consumes already validated Layout J presentation frames. +/// Implementations own rendering only; they do not interpret telemetry. +/// +internal interface IDisplaySink +{ + void Initialize(); + + void Render(LayoutJFrame frame); + + void Stop(); +} diff --git a/LogiDynamicDash/Displays/LayoutJTelemetryFormatter.cs b/LogiDynamicDash/Displays/LayoutJTelemetryFormatter.cs new file mode 100644 index 0000000..233be1d --- /dev/null +++ b/LogiDynamicDash/Displays/LayoutJTelemetryFormatter.cs @@ -0,0 +1,102 @@ +using System.Globalization; +using LogiDynamicDash.Models; + +namespace LogiDynamicDash.Displays; + +/// +/// Maps iRacing telemetry onto the recovered RS50 Layout J field limits. +/// This formatter does not encode or transmit a HID or DirectInput command. +/// +internal static class LayoutJTelemetryFormatter +{ + public static LayoutJFrame Format( + TelemetrySnapshot snapshot, + DisplayMode mode) + { + ArgumentNullException.ThrowIfNull(snapshot); + + return mode switch + { + DisplayMode.BrakeBias => new( + "BRAKE BIAS", + FormatBrakeBias(snapshot.BrakeBiasPercent), + string.Empty, + string.Empty), + DisplayMode.LastLap => new( + "LAST LAP", + FormatLapTime(snapshot.LastLapTimeSeconds), + string.Empty, + string.Empty), + DisplayMode.ConnectionProblem => new( + "IRACING", + FormatConnectionState(snapshot.ConnectionState), + string.Empty, + string.Empty), + _ => new( + "SPEED", + FormatSpeed(snapshot.SpeedMetersPerSecond), + "GEAR", + FormatGear(snapshot.Gear)) + }; + } + + private static string FormatSpeed(float? metersPerSecond) + { + if (metersPerSecond is not float value || + !float.IsFinite(value) || + value < 0) + { + return "N/A"; + } + + float kilometersPerHour = Math.Clamp(value * 3.6f, 0.0f, 999.0f); + return + kilometersPerHour.ToString("F0", CultureInfo.InvariantCulture) + + " KMH"; + } + + private static string FormatGear(int? gear) => gear switch + { + -1 => "R", + 0 => "N", + >= 1 and <= 9 => gear.Value.ToString(CultureInfo.InvariantCulture), + _ => "?" + }; + + private static string FormatBrakeBias(float? brakeBiasPercent) + { + if (brakeBiasPercent is not float value || + !float.IsFinite(value) || + value is < 0 or > 100) + { + return "N/A"; + } + + return value.ToString("F1", CultureInfo.InvariantCulture) + "%"; + } + + private static string FormatLapTime(float? totalSeconds) + { + if (totalSeconds is not float value || + !float.IsFinite(value) || + value <= 0 || + value >= 6000) + { + return "N/A"; + } + + TimeSpan time = TimeSpan.FromSeconds(value); + int minutes = (int)time.TotalMinutes; + return + $"{minutes.ToString(CultureInfo.InvariantCulture)}:" + + $"{time.Seconds:00}.{time.Milliseconds:000}"; + } + + private static string FormatConnectionState(string state) => + state.ToUpperInvariant() switch + { + "WAITING" => "WAITING", + "ERROR" => "ERROR", + _ => "OFFLINE" + }; +} diff --git a/LogiDynamicDash/Displays/Rs50DisplaySink.cs b/LogiDynamicDash/Displays/Rs50DisplaySink.cs new file mode 100644 index 0000000..11c8751 --- /dev/null +++ b/LogiDynamicDash/Displays/Rs50DisplaySink.cs @@ -0,0 +1,82 @@ +using LogiDynamicDash.Models; +using LogiDynamicDash.Native; + +namespace LogiDynamicDash.Displays; + +internal sealed class Rs50DisplaySink( + IRs50DisplayBridge bridge, + IRs50OwnerWindow ownerWindow, + Action? delay = null) : IDisplaySink +{ + private static readonly TimeSpan RetryDelay = + TimeSpan.FromMilliseconds(200); + + private readonly Action delayAction = + delay ?? Thread.Sleep; + + private bool initialized; + + public void Initialize() + { + if (initialized) + { + throw new InvalidOperationException( + "The RS50 display sink is already initialized."); + } + + try + { + ownerWindow.Create(); + bridge.Open(ownerWindow.Handle); + bridge.BeginLayoutJStream(); + initialized = true; + } + catch + { + bridge.Dispose(); + ownerWindow.Dispose(); + throw; + } + } + + public void Render(LayoutJFrame frame) + { + ArgumentNullException.ThrowIfNull(frame); + if (!initialized) + { + throw new InvalidOperationException( + "The RS50 display sink is not initialized."); + } + + Rs50FrameOutcome outcome = bridge.Send(frame); + if (outcome.RateLimited) + { + delayAction(RetryDelay); + outcome = bridge.Send(frame); + } + + if (outcome.RateLimited || + (!outcome.Transmitted && !outcome.Unchanged)) + { + throw new InvalidOperationException( + "The RS50 bridge did not accept the Layout J frame."); + } + } + + public void Stop() + { + try + { + if (initialized) + { + bridge.EndLayoutJStream(); + } + } + finally + { + initialized = false; + bridge.Dispose(); + ownerWindow.Dispose(); + } + } +} diff --git a/LogiDynamicDash/Hidpp/Rs50SharedHidppDisplaySession.cs b/LogiDynamicDash/Hidpp/Rs50SharedHidppDisplaySession.cs new file mode 100644 index 0000000..37eae4c --- /dev/null +++ b/LogiDynamicDash/Hidpp/Rs50SharedHidppDisplaySession.cs @@ -0,0 +1,141 @@ +using LogiDynamicDash.Models; +using LogiDynamicDash.Native; + +namespace LogiDynamicDash.Hidpp; + +/// +/// Fail-closed, deduplicated, 5 Hz session over an injected typed exchange. +/// Build E deliberately has no production exchange implementation. +/// +internal sealed class Rs50SharedHidppDisplaySession( + IRs50HidppDisplayExchange exchange, + TimeProvider? timeProvider = null) : IDisposable +{ + private static readonly TimeSpan MinimumInterval = + TimeSpan.FromMilliseconds(200); + + private readonly TimeProvider clock = + timeProvider ?? TimeProvider.System; + + private byte? runtimeIndex; + private LayoutJFrame? previousFrame; + private long previousTransmissionTimestamp; + private bool hasTransmitted; + private bool faulted; + private bool disposed; + + public void Open() + { + ThrowIfDisposed(); + + if (runtimeIndex is not null) + { + throw new InvalidOperationException( + "The shared HID++ display session is already open."); + } + + if (faulted) + { + throw new InvalidOperationException( + "The shared HID++ display session has failed."); + } + + try + { + byte[] response = + exchange.Exchange( + Rs50HidppDisplayProtocol.CreateDiscovery()); + + runtimeIndex = + Rs50HidppDisplayProtocol.ParseDiscoveryResponse(response); + } + catch + { + faulted = true; + throw; + } + } + + public Rs50FrameOutcome Send(LayoutJFrame frame) + { + ArgumentNullException.ThrowIfNull(frame); + ThrowIfDisposed(); + + if (faulted) + { + throw new InvalidOperationException( + "The shared HID++ display session has failed."); + } + + if (runtimeIndex is not byte featureIndex) + { + throw new InvalidOperationException( + "The shared HID++ display session is not open."); + } + + if (frame == previousFrame) + { + return new(false, true, false); + } + + long timestamp = clock.GetTimestamp(); + if (hasTransmitted && + clock.GetElapsedTime( + previousTransmissionTimestamp, + timestamp) < MinimumInterval) + { + return new(false, false, true); + } + + try + { + byte[] response = + exchange.Exchange( + Rs50HidppDisplayProtocol.CreateLayoutJ( + featureIndex, + frame.Line1, + frame.Line2, + frame.Line3, + frame.Line4)); + + Rs50HidppDisplayProtocol.ParseLayoutJAcknowledgement( + featureIndex, + response); + + previousFrame = frame; + previousTransmissionTimestamp = timestamp; + hasTransmitted = true; + return new(true, false, false); + } + catch + { + faulted = true; + throw; + } + } + + public void Close() + { + ThrowIfDisposed(); + runtimeIndex = null; + previousFrame = null; + hasTransmitted = false; + } + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + runtimeIndex = null; + previousFrame = null; + hasTransmitted = false; + exchange.Dispose(); + } + + private void ThrowIfDisposed() => + ObjectDisposedException.ThrowIf(disposed, this); +} diff --git a/LogiDynamicDash/LogiDynamicDash.csproj b/LogiDynamicDash/LogiDynamicDash.csproj index eed76c0..45eb453 100644 --- a/LogiDynamicDash/LogiDynamicDash.csproj +++ b/LogiDynamicDash/LogiDynamicDash.csproj @@ -11,4 +11,21 @@ + + + + + + + + + + + + diff --git a/LogiDynamicDash/Models/LayoutJFrame.cs b/LogiDynamicDash/Models/LayoutJFrame.cs new file mode 100644 index 0000000..0dd2b6d --- /dev/null +++ b/LogiDynamicDash/Models/LayoutJFrame.cs @@ -0,0 +1,59 @@ +namespace LogiDynamicDash.Models; + +/// +/// Four presentation lines that fit the recovered RS50 Layout J fields. +/// This is telemetry presentation data only and has no device transport. +/// +internal sealed record LayoutJFrame +{ + public const int Line1MaximumLength = 19; + public const int Line2MaximumLength = 10; + public const int Line3MaximumLength = 19; + public const int Line4MaximumLength = 10; + + public LayoutJFrame( + string line1, + string line2, + string line3, + string line4) + { + Line1 = Validate(line1, Line1MaximumLength, nameof(line1)); + Line2 = Validate(line2, Line2MaximumLength, nameof(line2)); + Line3 = Validate(line3, Line3MaximumLength, nameof(line3)); + Line4 = Validate(line4, Line4MaximumLength, nameof(line4)); + } + + public string Line1 { get; } + + public string Line2 { get; } + + public string Line3 { get; } + + public string Line4 { get; } + + private static string Validate( + string line, + int maximumLength, + string parameterName) + { + ArgumentNullException.ThrowIfNull(line, parameterName); + + if (line.Length > maximumLength) + { + throw new ArgumentException( + $"Text exceeds the Layout J limit of {maximumLength} " + + "characters.", + parameterName); + } + + if (line.Any(character => character is < (char)0x20 or > (char)0x7F)) + { + throw new ArgumentException( + "Text contains a character outside the firmware's " + + "recovered display range.", + parameterName); + } + + return line; + } +} diff --git a/LogiDynamicDash/Models/TelemetrySnapshot.cs b/LogiDynamicDash/Models/TelemetrySnapshot.cs index 9949dfa..1898c37 100644 --- a/LogiDynamicDash/Models/TelemetrySnapshot.cs +++ b/LogiDynamicDash/Models/TelemetrySnapshot.cs @@ -1,17 +1,17 @@ namespace LogiDynamicDash.Models; -internal sealed class TelemetrySnapshot +internal sealed record TelemetrySnapshot { - public string ConnectionState { get; set; } = "WAITING"; + public string ConnectionState { get; init; } = "WAITING"; - public bool? IsOnTrack { get; set; } + public bool? IsOnTrack { get; init; } - public int? Gear { get; set; } + public int? Gear { get; init; } - public float? Rpm { get; set; } + public float? Rpm { get; init; } - public float? SpeedMetersPerSecond { get; set; } - public float? BrakeBiasPercent { get; set; } + public float? SpeedMetersPerSecond { get; init; } + public float? BrakeBiasPercent { get; init; } - public float? LastLapTimeSeconds { get; set; } -} \ No newline at end of file + public float? LastLapTimeSeconds { get; init; } +} diff --git a/LogiDynamicDash/Native/IRs50DisplayBridge.cs b/LogiDynamicDash/Native/IRs50DisplayBridge.cs new file mode 100644 index 0000000..c9e3973 --- /dev/null +++ b/LogiDynamicDash/Native/IRs50DisplayBridge.cs @@ -0,0 +1,19 @@ +using LogiDynamicDash.Models; + +namespace LogiDynamicDash.Native; + +internal interface IRs50DisplayBridge : IDisposable +{ + void Open(nint ownerWindow); + + void BeginLayoutJStream(); + + Rs50FrameOutcome Send(LayoutJFrame frame); + + void EndLayoutJStream(); +} + +internal readonly record struct Rs50FrameOutcome( + bool Transmitted, + bool Unchanged, + bool RateLimited); diff --git a/LogiDynamicDash/Native/IRs50OwnerWindow.cs b/LogiDynamicDash/Native/IRs50OwnerWindow.cs new file mode 100644 index 0000000..b3225e6 --- /dev/null +++ b/LogiDynamicDash/Native/IRs50OwnerWindow.cs @@ -0,0 +1,8 @@ +namespace LogiDynamicDash.Native; + +internal interface IRs50OwnerWindow : IDisposable +{ + nint Handle { get; } + + void Create(); +} diff --git a/LogiDynamicDash/Native/Rs50NativeDisplayBridge.cs b/LogiDynamicDash/Native/Rs50NativeDisplayBridge.cs new file mode 100644 index 0000000..c8e9879 --- /dev/null +++ b/LogiDynamicDash/Native/Rs50NativeDisplayBridge.cs @@ -0,0 +1,259 @@ +using System.Runtime.InteropServices; +using System.Text; +using LogiDynamicDash.Models; + +namespace LogiDynamicDash.Native; + +internal sealed class Rs50NativeDisplayBridge : IRs50DisplayBridge +{ + private nint handle; + private bool streamStarted; + + public void Open(nint ownerWindow) + { + if (ownerWindow == 0) + { + throw new ArgumentException( + "A valid owner window is required.", + nameof(ownerWindow)); + } + + EnsureStatus( + NativeOpen((nuint)ownerWindow, out handle), + "open"); + + if (handle == 0) + { + throw new InvalidOperationException( + "The native RS50 bridge returned a null handle."); + } + } + + public void BeginLayoutJStream() + { + EnsureOpen(); + + NativeStreamResult result = NativeStreamResult.Create(); + EnsureStatus( + NativeBeginLayoutJStream(handle, ref result), + "begin Layout J stream"); + streamStarted = true; + } + + public Rs50FrameOutcome Send(LayoutJFrame frame) + { + ArgumentNullException.ThrowIfNull(frame); + if (!streamStarted) + { + throw new InvalidOperationException( + "The Layout J stream has not started."); + } + + NativeLayoutJFrame nativeFrame = NativeLayoutJFrame.From(frame); + NativeStreamResult result = NativeStreamResult.Create(); + EnsureStatus( + NativeSetLayoutJFrame(handle, in nativeFrame, ref result), + "set Layout J frame"); + + return new Rs50FrameOutcome( + result.Transmitted != 0, + result.Unchanged != 0, + result.RateLimited != 0); + } + + public void EndLayoutJStream() + { + if (!streamStarted || handle == 0) + { + return; + } + + NativeStreamResult result = NativeStreamResult.Create(); + try + { + EnsureStatus( + NativeEndLayoutJStream(handle, ref result), + "end Layout J stream"); + } + finally + { + streamStarted = false; + } + } + + public void Dispose() + { + try + { + EndLayoutJStream(); + } + finally + { + if (handle != 0) + { + NativeClose(handle); + handle = 0; + } + } + } + + private void EnsureOpen() + { + if (handle == 0) + { + throw new InvalidOperationException( + "The native RS50 bridge is not open."); + } + } + + private static void EnsureStatus( + Rs50DisplayStatus status, + string operation) + { + if (status == Rs50DisplayStatus.Ok) + { + return; + } + + nint messagePointer = NativeStatusMessage(status); + string message = + Marshal.PtrToStringUni(messagePointer) ?? + "Unknown native bridge error"; + + throw new InvalidOperationException( + $"Could not {operation}: {message} ({(int)status})."); + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + internal struct NativeLayoutJFrame + { + public uint StructSize; + public byte Row1Length; + public byte Row2Length; + public byte Row3Length; + public byte Row4Length; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 19)] + public byte[] Row1; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)] + public byte[] Row2; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 19)] + public byte[] Row3; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)] + public byte[] Row4; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 2)] + public byte[] Reserved; + + public static NativeLayoutJFrame From(LayoutJFrame frame) + { + return new NativeLayoutJFrame + { + StructSize = checked((uint)Marshal.SizeOf()), + Row1Length = checked((byte)frame.Line1.Length), + Row2Length = checked((byte)frame.Line2.Length), + Row3Length = checked((byte)frame.Line3.Length), + Row4Length = checked((byte)frame.Line4.Length), + Row1 = Encode(frame.Line1, 19), + Row2 = Encode(frame.Line2, 10), + Row3 = Encode(frame.Line3, 19), + Row4 = Encode(frame.Line4, 10), + Reserved = new byte[2] + }; + } + + private static byte[] Encode(string value, int capacity) + { + byte[] destination = new byte[capacity]; + Encoding.ASCII.GetBytes(value, destination); + return destination; + } + } + + [StructLayout( + LayoutKind.Sequential, + Pack = 4, + CharSet = CharSet.Unicode)] + internal struct NativeStreamResult + { + public uint StructSize; + public uint VendorId; + public uint ProductId; + public int CooperativeLevelHResult; + public int DataFormatHResult; + public int AcquireHResult; + public int EscapeHResult; + public int UnacquireHResult; + public byte Acquired; + public byte InnerCommand; + public byte Transmitted; + public byte Unchanged; + public byte RateLimited; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 3)] + public byte[] Reserved; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] + public string ProductName; + + public static NativeStreamResult Create() => new() + { + StructSize = checked((uint)Marshal.SizeOf()), + Reserved = new byte[3], + ProductName = string.Empty + }; + } + + private enum Rs50DisplayStatus + { + Ok = 0 + } + + [DllImport( + "Rs50DirectInputBridge", + EntryPoint = "rs50_display_open", + CallingConvention = CallingConvention.Cdecl)] + private static extern Rs50DisplayStatus NativeOpen( + nuint ownerWindow, + out nint handle); + + [DllImport( + "Rs50DirectInputBridge", + EntryPoint = "rs50_display_begin_layout_j_stream", + CallingConvention = CallingConvention.Cdecl)] + private static extern Rs50DisplayStatus NativeBeginLayoutJStream( + nint handle, + ref NativeStreamResult result); + + [DllImport( + "Rs50DirectInputBridge", + EntryPoint = "rs50_display_set_layout_j_frame", + CallingConvention = CallingConvention.Cdecl)] + private static extern Rs50DisplayStatus NativeSetLayoutJFrame( + nint handle, + in NativeLayoutJFrame frame, + ref NativeStreamResult result); + + [DllImport( + "Rs50DirectInputBridge", + EntryPoint = "rs50_display_end_layout_j_stream", + CallingConvention = CallingConvention.Cdecl)] + private static extern Rs50DisplayStatus NativeEndLayoutJStream( + nint handle, + ref NativeStreamResult result); + + [DllImport( + "Rs50DirectInputBridge", + EntryPoint = "rs50_display_status_message", + CallingConvention = CallingConvention.Cdecl)] + private static extern nint NativeStatusMessage(Rs50DisplayStatus status); + + [DllImport( + "Rs50DirectInputBridge", + EntryPoint = "rs50_display_close", + CallingConvention = CallingConvention.Cdecl)] + private static extern void NativeClose(nint handle); +} diff --git a/LogiDynamicDash/Native/Rs50OwnerWindow.cs b/LogiDynamicDash/Native/Rs50OwnerWindow.cs new file mode 100644 index 0000000..b4ba7a4 --- /dev/null +++ b/LogiDynamicDash/Native/Rs50OwnerWindow.cs @@ -0,0 +1,97 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace LogiDynamicDash.Native; + +internal sealed class Rs50OwnerWindow : IRs50OwnerWindow +{ + private const uint WsOverlapped = 0x00000000; + private const uint WsCaption = 0x00C00000; + private const uint WsSysMenu = 0x00080000; + private const int SwShowNormal = 1; + + public nint Handle { get; private set; } + + public void Create() + { + if (Handle != 0) + { + throw new InvalidOperationException( + "The RS50 owner window already exists."); + } + + Handle = CreateWindowEx( + 0, + "STATIC", + "LogiDynamicDash RS50 OLED", + WsOverlapped | WsCaption | WsSysMenu, + 100, + 100, + 360, + 120, + 0, + 0, + 0, + 0); + + if (Handle == 0) + { + throw new Win32Exception( + Marshal.GetLastWin32Error(), + "Could not create the RS50 DirectInput owner window."); + } + + ShowWindow(Handle, SwShowNormal); + if (!SetForegroundWindow(Handle) || GetForegroundWindow() != Handle) + { + Dispose(); + throw new InvalidOperationException( + "Could not make the RS50 owner window foreground."); + } + } + + public void Dispose() + { + if (Handle == 0) + { + return; + } + + DestroyWindow(Handle); + Handle = 0; + } + + [DllImport( + "user32.dll", + EntryPoint = "CreateWindowExW", + SetLastError = true, + CharSet = CharSet.Unicode)] + private static extern nint CreateWindowEx( + uint extendedStyle, + string className, + string windowName, + uint style, + int x, + int y, + int width, + int height, + nint parent, + nint menu, + nint instance, + nint parameter); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DestroyWindow(nint window); + + [DllImport("user32.dll")] + private static extern nint GetForegroundWindow(); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetForegroundWindow(nint window); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShowWindow(nint window, int command); +} diff --git a/LogiDynamicDash/Program.cs b/LogiDynamicDash/Program.cs index ba009ef..120a6ac 100644 --- a/LogiDynamicDash/Program.cs +++ b/LogiDynamicDash/Program.cs @@ -2,6 +2,7 @@ using LogiDynamicDash.Controllers; using LogiDynamicDash.Displays; using LogiDynamicDash.Models; +using LogiDynamicDash.Native; using LogiDynamicDash.Services; using SVappsLAB.iRacingTelemetrySDK; @@ -17,14 +18,24 @@ namespace LogiDynamicDash; ])] internal class Program { + private const int DisplayRefreshIntervalMilliseconds = 200; + + private static readonly string[] Rs50ArmingArguments = + [ + "--enable-rs50-oled", + "--confirm-exclusive-layout-j-stream", + "--confirm-telemetry-transmission", + "--confirm-10-second-trial" + ]; + private static readonly Stopwatch RefreshTimer = Stopwatch.StartNew(); private static readonly TelemetrySnapshot Snapshot = new(); - private static readonly ConsoleDashboard Dashboard = - new(); + private static IDisplaySink Dashboard = + new DistinctDisplaySink(new ConsoleDashboard()); private static readonly DisplayController Controller = new(); @@ -32,22 +43,37 @@ internal class Program private static readonly IRacingTelemetryService TelemetryService = new(); - private static async Task Main() + private static async Task Main(string[] arguments) { - Dashboard.Initialize(); - RenderCurrentDisplay(Snapshot); + if (!TryCreateDashboard(arguments, out IDisplaySink dashboard)) + { + PrintUsage(); + return 2; + } + + Dashboard = dashboard; using var cancellationSource = new CancellationTokenSource(); + if (arguments.Length != 0) + { + cancellationSource.CancelAfter(TimeSpan.FromSeconds(10)); + } + Console.CancelKeyPress += (_, eventArgs) => { eventArgs.Cancel = true; cancellationSource.Cancel(); }; + bool initialized = false; try { + Dashboard.Initialize(); + initialized = true; + RenderCurrentDisplay(Snapshot); + await TelemetryService.MonitorAsync( Snapshot, HandleTelemetryUpdated, @@ -60,14 +86,20 @@ await TelemetryService.MonitorAsync( } finally { - Dashboard.Stop(); + if (initialized) + { + Dashboard.Stop(); + } } + + return 0; } private static void HandleTelemetryUpdated( TelemetrySnapshot snapshot) { - if (RefreshTimer.ElapsedMilliseconds < 100) + if (RefreshTimer.ElapsedMilliseconds < + DisplayRefreshIntervalMilliseconds) { return; } @@ -88,8 +120,46 @@ private static void RenderCurrentDisplay( DisplayMode mode = Controller.SelectMode(snapshot); - Dashboard.Render( - snapshot, - mode); + LayoutJFrame frame = LayoutJTelemetryFormatter.Format(snapshot, mode); + Dashboard.Render(frame); + } + + internal static bool TryCreateDashboard( + string[] arguments, + out IDisplaySink dashboard) + { + if (arguments.Length == 0) + { + dashboard = + new DistinctDisplaySink(new ConsoleDashboard()); + return true; + } + + if (!arguments.SequenceEqual( + Rs50ArmingArguments, + StringComparer.Ordinal)) + { + dashboard = null!; + return false; + } + + dashboard = new DistinctDisplaySink( + new CompositeDisplaySink( + new ConsoleDashboard(), + new Rs50DisplaySink( + new Rs50NativeDisplayBridge(), + new Rs50OwnerWindow()))); + return true; + } + + private static void PrintUsage() + { + Console.Error.WriteLine( + "Safe console preview:\n" + + " LogiDynamicDash.exe\n\n" + + "Physical RS50 OLED mode requires a separately approved " + + "captured 10-second test and all four arguments, in this order:\n" + + " LogiDynamicDash.exe " + + string.Join(' ', Rs50ArmingArguments)); } -} \ No newline at end of file +} diff --git a/LogiDynamicDash/Services/IRacingTelemetryService.cs b/LogiDynamicDash/Services/IRacingTelemetryService.cs index 9b7ce48..a94abdf 100644 --- a/LogiDynamicDash/Services/IRacingTelemetryService.cs +++ b/LogiDynamicDash/Services/IRacingTelemetryService.cs @@ -12,6 +12,9 @@ public async Task MonitorAsync( Action onStatusChanged, CancellationToken cancellationToken) { + object snapshotGate = new(); + TelemetrySnapshot currentSnapshot = snapshot; + await using var client = TelemetryClient.Create( NullLogger.Instance); @@ -21,47 +24,52 @@ public async Task MonitorAsync( { OnConnectStateChanged = state => { - snapshot.ConnectionState = - state - .ToString() - .ToUpperInvariant(); - - onStatusChanged(snapshot); + lock (snapshotGate) + { + currentSnapshot = currentSnapshot with + { + ConnectionState = state + .ToString() + .ToUpperInvariant() + }; + + onStatusChanged(currentSnapshot); + } return Task.CompletedTask; }, OnTelemetryUpdate = data => { - snapshot.IsOnTrack = - data.IsOnTrackCar; - - snapshot.Gear = - data.Gear; - - snapshot.Rpm = - data.RPM; - - snapshot.SpeedMetersPerSecond = - data.Speed; - - snapshot.BrakeBiasPercent = - data.dcBrakeBias; - - snapshot.LastLapTimeSeconds = - data.LapLastLapTime; - - onTelemetryUpdated(snapshot); + lock (snapshotGate) + { + currentSnapshot = currentSnapshot with + { + IsOnTrack = data.IsOnTrackCar, + Gear = data.Gear, + Rpm = data.RPM, + SpeedMetersPerSecond = data.Speed, + BrakeBiasPercent = data.dcBrakeBias, + LastLapTimeSeconds = data.LapLastLapTime + }; + + onTelemetryUpdated(currentSnapshot); + } return Task.CompletedTask; }, OnError = _ => { - snapshot.ConnectionState = - "ERROR"; + lock (snapshotGate) + { + currentSnapshot = currentSnapshot with + { + ConnectionState = "ERROR" + }; - onStatusChanged(snapshot); + onStatusChanged(currentSnapshot); + } return Task.CompletedTask; } @@ -71,4 +79,4 @@ await client.Monitor( handlers, cancellationToken); } -} \ No newline at end of file +} diff --git a/LogiDynamicDash/docs/OLED_DISPLAY_DESIGN.md b/LogiDynamicDash/docs/OLED_DISPLAY_DESIGN.md index 8bca656..f053ec0 100644 --- a/LogiDynamicDash/docs/OLED_DISPLAY_DESIGN.md +++ b/LogiDynamicDash/docs/OLED_DISPLAY_DESIGN.md @@ -4,6 +4,11 @@ This document defines the intended behavior of the Dynamic OLED display in LogiD The goal is not to show every available telemetry value. The display should present only information that can be understood with a quick glance while driving. +Protocol research has replaced the original framebuffer assumption. The first +implementation targets firmware-rendered Layout J: four centered text fields +with maximum lengths `19/10/19/10`. The application now produces those exact +four presentation lines offline; physical output is still unverified. + ## Design principles - Keep the normal driving screen simple. @@ -14,7 +19,7 @@ The goal is not to show every available telemetry value. The display should pres - Use overlays for brief driving-assistance interventions. - Use full-screen alerts only for important conditions. - Do not depend on color because the OLED is monochrome. -- Do not assume the display resolution until it has been verified. +- Respect the recovered Layout J text limits rather than assuming pixel access. - Allow users to disable optional alerts and overlays. ## Normal driving screen @@ -22,8 +27,10 @@ The goal is not to show every available telemetry value. The display should pres The first universal layout should display: ```text - 4 - 186 km/h +SPEED +186 KMH +GEAR +4 ``` Primary value: @@ -34,7 +41,8 @@ Secondary value: - Speed -The user should be able to choose between km/h and mph. Both units should not be shown at the same time. +The initial formatter uses km/h. A later setting may select mph, but both units +must never be shown at the same time. Numeric RPM should not be displayed permanently because the wheel LEDs can already represent engine speed more effectively. @@ -45,8 +53,10 @@ When the driver changes an adjustable setting, the OLED should temporarily repla Example: ```text - BRAKE BIAS - 52.3% +BRAKE BIAS +52.3% + + ``` Initial behavior: @@ -74,8 +84,10 @@ When the driver completes a lap, show the last lap time temporarily. Example: ```text - LAST LAP - 1:32.481 +LAST LAP +1:32.481 + + ``` Initial behavior: @@ -127,14 +139,10 @@ These indicators should describe active intervention, not merely that the system ### Visual behavior -Because the OLED is monochrome, an intervention may be represented by: - -- Flashing the indicator text -- Inverting a small area -- Showing and hiding a border -- Alternating between normal and inverted text - -Only a small area should flash. The complete display should not flash continuously. +The recovered game-data protocol selects typed firmware layouts; it does not +expose a pixel framebuffer or partial-region inversion. The first version may +show a bounded text label in Layout J. Flashing, borders, and inverted regions +remain unsupported ideas unless another verified layout provides them. ### Minimum visibility time @@ -261,11 +269,9 @@ The development console and the OLED have different purposes. ### Development console -- Diagnostic information -- Connection state -- Raw or formatted telemetry -- Multiple values at the same time -- Debugging unavailable telemetry +- Exact Layout J four-line preview +- Diagnostic framing around that preview +- Connection state and unavailable telemetry represented with bounded text ### OLED @@ -284,24 +290,22 @@ The first OLED implementation should aim for: 1. Normal gear and speed screen 2. Temporary brake-bias screen 3. Temporary last-lap screen -4. ABS intervention indicator, if a reliable signal is confirmed -5. Disconnection alert -6. Configurable km/h or mph +4. Connection/waiting alert +5. ABS intervention indicator, only if a reliable signal is confirmed +6. Configurable km/h or mph after physical text output is stable ## Open technical questions -The following must be verified before implementing pixel-perfect layouts: - -- Exact OLED resolution -- Supported image or text format -- Display refresh rate -- Maximum safe update frequency -- Whether partial screen updates are supported -- Whether G HUB must be running -- How Dynamic mode receives display data -- Whether Logitech provides a public or partner SDK -- Differences between Logitech PRO and RS50 -- Behavior when another game or application controls the display -- Whether the display supports inverted regions or only complete frames - -These questions should be answered through official documentation, SDK access, controlled testing, or protocol research. \ No newline at end of file +The following still require controlled validation: + +- Whether the physically confirmed DirectInput command remains stable during + a bounded 5 Hz telemetry stream +- Whether ten seconds of updates receive clean matched responses without + display artifacts or acquisition loss +- The Logitech PRO Wheel VID/PID, capability response, and compatibility +- Behavior when another game or application owns the display +- Visible font sizing and readability for all four Layout J rows +- The semantic intent of Logitech's other typed layouts C-I +- Four-minute fallback timing after the last successful update + +These questions should be answered through official documentation, SDK access, controlled testing, or protocol research. diff --git a/LogiDynamicDash/docs/RS50_DIRECTINPUT_BRIDGE_DESIGN.md b/LogiDynamicDash/docs/RS50_DIRECTINPUT_BRIDGE_DESIGN.md new file mode 100644 index 0000000..6f7d864 --- /dev/null +++ b/LogiDynamicDash/docs/RS50_DIRECTINPUT_BRIDGE_DESIGN.md @@ -0,0 +1,435 @@ +# RS50 DirectInput Display Bridge Design + +## Purpose + +Define the safest implementation route from .NET telemetry to the RS50 +Dynamic OLED without constructing undocumented raw HID reports. + +The recovered Logitech driver ABI is an in-process C++ interface reached by +`IDirectInputDevice8::Escape`. Text layouts contain live x64 MSVC +`std::string` objects, so a managed process must not imitate those objects with +byte arrays or pointers. A native x64 bridge should own every C++ object and +expose only plain C-compatible functions to the .NET application. + +This document is a design and validation gate. The guarded x64 bridge is +compiled and covered by non-hardware safety tests. It exposes the two proven +queries plus one fixed Layout J setter; the setter has not been physically +invoked. + +## Build Prerequisites + +Run the read-only +[`Test-Rs50DirectInputBridgePrerequisites.ps1`](../../scripts/Test-Rs50DirectInputBridgePrerequisites.ps1) +before adding a native project. It checks the x64 process, MSVC tools, Windows +SDK `dinput.h`, and the registered Logitech force-feedback COM server's hash +and Authenticode signer without enumerating or opening hardware. + +The first 2026-07-22 workstation check was not ready because MSVC and the +Windows SDK headers were absent. After the user approved and installed the +minimal configuration, the checker confirms x64 MSVC, SDK `dinput.h`, the +registered Logitech driver, audited hash, and Authenticode signature. The +guarded bridge now compiles with warnings treated as errors. + +The repository root includes `LogiDynamicDash.vsconfig` with only the three +required selections: Desktop development with C++, the latest stable x64/x86 +MSVC tools, and Windows 11 SDK 10.0.26100. Import that file from Visual Studio +Installer when automated modification is unavailable; unrelated optional +workloads are intentionally excluded. + +## Proven ABI + +The public `DIEFFESCAPE` wrapper follows the normal Windows ABI. On x64 it is +40 bytes: `dwSize` at `0x00`, `dwCommand` at `0x04`, `lpvInBuffer` at `0x08`, +`cbInBuffer` at `0x10`, `lpvOutBuffer` at `0x18`, and `cbOutBuffer` at `0x20`. +The x86 form is 24 bytes with those fields at `0x00/0x04/0x08/0x0C/0x10/0x14`. +The native bridge should include `dinput.h`, set `dwSize` to +`sizeof(DIEFFESCAPE)`, and let the compiler provide this outer layout. The +explorer contains non-marshallable size models and tests for both forms solely +to detect documentation or offset regressions. + +The installed x64 force-feedback driver validates this input envelope: + +```text +offset 0x00: uint32 structure size +offset 0x04: uint32 version = 1 +offset 0x08: uint8 inner command +offset 0x09: three padding/reserved bytes +offset 0x0C: command payload +``` + +The structures use four-byte packing. The evidence is exact rather than a +compiler guess: + +| Layout input | Payload | Size | Key offsets | +|---|---|---:|---| +| A/B | none | 12 | command `0x08` | +| C | one float | 16 | value `0x0C` | +| D | two floats, one string | 52 | floats `0x0C/0x10`, string `0x14` | +| E | two floats, two strings | 84 | strings `0x14/0x34` | +| F/G/H | two strings | 76 | strings `0x0C/0x2C` | +| I/J | four strings | 140 | strings `0x0C/0x2C/0x4C/0x6C` | + +An x64 MSVC `std::string` occupies 32 bytes. The explorer models it as an +opaque 32-byte field only to test the recovered offsets. Managed code must +never populate or marshal that field. + +## Implemented Query-Only Boundary + +The managed side now has the intended presentation boundary: + +```text +iRacing snapshot -> display mode -> LayoutJTelemetryFormatter + -> validated LayoutJFrame -> IDisplaySink +``` + +`LayoutJFrame` enforces the firmware-derived `19/10/19/10` lengths and byte +range `0x20..0x7F` before a sink receives it. The console already consumes +this interface; the future RS50 sink will forward the same validated frame to +the native bridge and will not reinterpret telemetry. + +The native bridge exports a deliberately small C ABI: + +```cpp +extern "C" { + uint32_t rs50_display_abi_version(); + int rs50_display_open( + uintptr_t owner_window, + rs50_display_handle** handle); + int rs50_display_query_support( + rs50_display_handle* handle, + rs50_display_capabilities* capabilities); + int rs50_display_query_layout_j_support( + rs50_display_handle* handle, + rs50_display_capabilities* capabilities); + const wchar_t* rs50_display_status_message(int status); + void rs50_display_close(rs50_display_handle* handle); +} +``` + +`owner_window` is a process-owned top-level `HWND` represented without leaking +a Windows header into the C ABI. Zero and message-only windows are rejected. +The implemented validation build exposes only ABI metadata, `open`, the +general support query, the Layout J capability query, status text, and `close`. +No setter or generic command surface is compiled into it. The guarded CLI +requires the query selector plus `--confirm-standard-data-format`, +`--confirm-exclusive-acquire`, and a query-specific transmission confirmation +before it creates the owner window. + +The bridge owns: + +- `IDirectInput8` and `IDirectInputDevice8` lifetimes +- device enumeration and identity checks +- validation and use of the caller's process-owned top-level window handle +- all packed request structures +- all live `std::string` objects +- conversion of driver `HRESULT` values into stable bridge error codes +- serialized calls and cleanup + +The .NET application owns telemetry, presentation mapping, and update cadence. +It passes bounded ASCII text to the bridge and never sees a DirectInput or HID +pointer. Its current display path is capped at 5 Hz and wraps the sink with +value-based frame deduplication. A failed sink call is not remembered, so the +same frame remains eligible for a controlled retry instead of being silently +dropped. The iRacing adapter publishes immutable snapshot records and +serializes status and telemetry callbacks, preventing a native call +from observing a partially updated snapshot or running concurrently with a +second callback. + +## Native Structure Rules + +The implementation must use the same x64 MSVC runtime ABI as the installed +driver and assert every recovered boundary at compile time: + +```cpp +#pragma pack(push, 4) +struct DisplayHeader { + std::uint32_t size; + std::uint32_t version; + std::uint8_t command; + std::uint8_t reserved[3]; +}; + +struct LayoutJInput { + DisplayHeader header; + std::string line1; + std::string line2; + std::string line3; + std::string line4; +}; +#pragma pack(pop) + +static_assert(sizeof(DisplayHeader) == 12); +static_assert(sizeof(std::string) == 32); +static_assert(offsetof(LayoutJInput, line1) == 12); +static_assert(offsetof(LayoutJInput, line4) == 108); +static_assert(sizeof(LayoutJInput) == 140); +``` + +Do not use `memcpy`, manual pointer patching, a C# representation, or a +captured process buffer as a substitute for real constructed strings. + +## Device Selection Gates + +Before any Escape call, the bridge must require all of the following: + +1. Windows x64 process. +2. DirectInput game-controller device with Logitech VID `0x046D` and the + explicitly supported RS50 PID `0xC276`. +3. Force-feedback driver GUID corresponding to the installed Logitech HID++ + driver, not a generic or compatibility-mode device. +4. Registered COM server with a valid Logitech Inc Authenticode signature and + the statically audited SHA-256. A changed driver hash requires a new static + audit before use. +5. Exactly one matching device. Ambiguity is an error. +6. A visible process-owned foreground top-level window. Build A established + that `DISCL_NONEXCLUSIVE | DISCL_BACKGROUND` cannot reach Escape: + DirectInput returned `DIERR_NOTEXCLUSIVEACQUIRED`. Build A2 therefore uses + `DISCL_EXCLUSIVE | DISCL_FOREGROUND` for the bounded query lifecycle. + +PRO Wheel PIDs must not be guessed or accepted until observed and documented. + +Read VID/PID through `DIPROP_VIDPID` (`LOWORD` vendor, `HIWORD` product), as +the official Logitech sample does for product identity. Compare +`DIDEVICEINSTANCE::guidFFDriver` before calling `Escape`, as Microsoft +explicitly requires for vendor-specific commands. Do not rely on product-name +text or enumeration order. + +Logitech's official independent console sample creates the device and calls +`Escape` without setting a data format or acquiring it, but explicitly warns +that its LED call will fail because it has no active window handle. Microsoft +documents acquisition as mandatory for input-state reads, not for +[`Escape`](https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ee417891%28v%3Dvs.85%29). +Build A recorded both results and returned `DIERR_NOTEXCLUSIVEACQUIRED` +(`0x80040205`) while leaving its output sentinel unchanged. That runtime result +supersedes the earlier assumption. Build A2 records cooperative, Acquire, +Escape, and Unacquire results, acquires only immediately before the one support +query, and releases immediately afterward. + +## Validation Sequence + +### Build A: Query Only + +The historical Build A variant compiled no setters. Its explicitly armed mode performed +exactly one outer command `4`, inner command `2`, +with version `1`, a 12-byte input buffer, and a one-byte output buffer. + +The safe build command is: + +```powershell +.\scripts\Build-Rs50DirectInputBridge.ps1 -Configuration Release +``` + +It runs native argument/gate tests and CLI `--describe` only. It never supplies +a valid owner window and therefore cannot create DirectInput or enumerate the +RS50. The transmitting CLI mode remains a separate physical validation step. +Follow +[`RS50_QUERY_ONLY_OPERATOR_CHECKLIST.md`](RS50_QUERY_ONLY_OPERATOR_CHECKLIST.md) +for the exact preconditions, capture, one-shot command, observations, and stop +conditions. + +Build A used nonexclusive/background cooperation and no acquisition. Its first +physical call on 2026-07-27 returned `DIERR_NOTEXCLUSIVEACQUIRED`; the RS50 was +also asleep, so the run is not a valid OLED observation. Build A is retired. +See +[`evidence/RS50_DIRECTINPUT_QUERY_BUILD_A_2026-07-27.md`](evidence/RS50_DIRECTINPUT_QUERY_BUILD_A_2026-07-27.md). + +### Build A2: Exclusive Query Only + +Build A2 preserves the same sole outer/inner query and adds only the DirectInput +precondition proven by Build A: + +1. show and foreground a process-owned top-level window; +2. set `DISCL_EXCLUSIVE | DISCL_FOREGROUND`; +3. call `Acquire`; +4. send the one general-support query; +5. call `Unacquire` before interpreting the result; +6. defensively release again during close only if the first release failed. + +Its CLI additionally requires `--confirm-exclusive-acquire`. It still compiles +no layout query, setter, effect, raw HID path, or retry. + +The first physical Build A2 call returned `DIERR_INVALIDPARAM` from `Acquire`. +Its capture contained no HOST HID++ reports. Microsoft documents that a data +format must be set before acquisition, even when no input state will be read. +Build A2 is retired. See +[`evidence/RS50_DIRECTINPUT_QUERY_BUILD_A2_2026-07-27.md`](evidence/RS50_DIRECTINPUT_QUERY_BUILD_A2_2026-07-27.md). + +### Build A3: Standard Format, Exclusive Query Only + +Build A3 adds the one missing DirectInput precondition: + +1. call `SetDataFormat(&c_dfDIJoystick2)`; +2. record its HRESULT and stop before Acquire if it fails; +3. otherwise retain the Build A2 Acquire/query/Unacquire lifecycle. + +Its CLI also requires `--confirm-standard-data-format`. It does not poll or +read device state, configure axes, create effects, issue a layout query, or +compile a display setter. + +Record: + +- selected VID/PID and sanitized product name +- window/cooperative-level setup and whether the device was acquired +- DirectInput `HRESULT` +- returned output byte and post-call `cbOutBuffer` +- device-scoped USB traffic +- whether the OLED, torque, or wheel position changed + +Stop after the call. Any display change, force feedback, or wheel movement is +an immediate failure. + +Build A3 succeeded physically. Its capture matched 15 requests to 15 responses, +discovered public `0x8130` at runtime `0x12`, and showed no operational request +to that runtime. The support result was `1` and the operator observed no +physical change. + +### Build B: Layout Capability Queries + +Only after Build A3 is confirmed non-mutating and explicitly approved, query +layouts A-J one at a time. Build B currently exposes only Layout J query `12`; +there is no generic command export. Allocate the exact minimum output capacity recovered +from the driver: `1,1,1,1,4,6,6,6,6,10,10` bytes for commands 2-12. +Prefill every buffer with a sentinel. Static disassembly shows that every +query writes only byte `0`; larger minimum capacities are validation gates, +not defined capability fields. Treat any changed trailing byte as an ABI +mismatch and stop. + +Run Layout J query `12` first, alone, with its exact ten-byte output buffer. +This avoids using Layout A query `3` as the cache trigger; command `3` has a +driver null-dereference path for malformed output buffers. No negative or +undersized-buffer tests are permitted. The first valid layout query lazily +loads capabilities by sending function `0` once and function `1` once for +every returned layout. The known RS50 count predicts eleven feature +transactions, after which the other layout queries should use the same +in-memory cache. A different transaction count is diagnostic evidence and +must not be followed by a setter. + +The implemented CLI requires the distinct +`--confirm-transmit-layout-query` token. It reports all ten output bytes and +rejects success unless bytes 1-9 remain at sentinel `0xA5`. Follow +[`RS50_LAYOUT_J_QUERY_OPERATOR_CHECKLIST.md`](RS50_LAYOUT_J_QUERY_OPERATOR_CHECKLIST.md) +for its isolated physical validation. + +The driver's initialization flag is set before the requests and is not reset +on its visible failure paths. If the first load times out, returns a different +count, or yields an invalid descriptor, close and recreate the DirectInput +device before any retry. Do not treat subsequent false results on the same +handle as independent evidence that a layout is unsupported. + +Build B physically completed one Layout J query on 2026-07-27. DirectInput +returned `Supported: 1`, only byte zero changed in the exact ten-byte output, +and USBPcap matched all 11 predicted `0x8130` exchanges with no unmatched +reports. The returned Layout J descriptor was ID `10`, capacities +`19/10/19/10`. Build C remains gated on the operator's explicit physical +observation, which subsequently confirmed no physical change. See +[`evidence/RS50_LAYOUT_J_QUERY_SUCCESS_2026-07-27.md`](evidence/RS50_LAYOUT_J_QUERY_SUCCESS_2026-07-27.md). + +### Build C: Single Setter + +Only after query results and USB traffic match the static model, compile one +setter in a separate build. Layout J is the preferred first text validation +because firmware renders four neutral centered text rows. Use static text, +not live telemetry, and stop immediately after one call. + +The first payload is fixed so the observation is unambiguous: + +```text +line 1: LOGIDYNAMICDASH +line 2: RS50 +line 3: OLED LINK +line 4: TEST 1 +``` + +Build C implements only this payload. The exported function accepts a handle +and a result structure but no text parameters. It uses command `22`, the +verified 140-byte x64 MSVC input ABI, the shared one-shot +Acquire/Escape/Unacquire lifecycle, and no output buffer. ABI size and all +four `std::string` offsets are compile-time assertions. + +The one physical Build C call succeeded. The OLED displayed +`RS50 / LOGIDYNAMI / TEST 1 / OLED LINK`, while USBPcap recorded exactly one +matched `0x8130` function-`3` setter with those same wire strings. This proves +that the DirectInput adapter maps game-facing inputs `1/2/3/4` to visual rows +`2/1/4/3`. A future caller-controlled native API must accept visual rows, +enforce `19/10/19/10`, and permute its internal MSVC strings to +`row2/row1/row4/row3`. See +[`evidence/RS50_STATIC_LAYOUT_J_SUCCESS_2026-07-27.md`](evidence/RS50_STATIC_LAYOUT_J_SUCCESS_2026-07-27.md). + +Before the call, select Dynamic on the wheel and confirm that it shows the Test +fallback. After the single call, touch neither G HUB nor the wheel for ten +seconds while recording the display and USB capture. Do not send SetIdle to +clean up; stop the process and use the wheel's normal HomeScreen selector if a +manual screen change is needed. + +### Build D: Telemetry + +After a visible static setter is confirmed: + +1. Start at 5 Hz, coalesce telemetry, and skip identical frames. Increase only + after a scoped capture shows clean acknowledgements and stable OLED output. +2. Map speed/unit and gear/value into Layout J. +3. Enforce the firmware limits `19/10/19/10` before crossing the native ABI. +4. Stop updates on device loss or any failed Escape call. +5. Observe the nominal four-minute firmware fallback after stopping. + +The Build D implementation now provides an ABI-6 session surface: + +- `rs50_display_begin_layout_j_stream` +- `rs50_display_set_layout_j_frame` +- `rs50_display_end_layout_j_stream` + +The public frame is 68 bytes and contains explicit lengths plus fixed visual +row capacities `19/10/19/10`. Every unused byte and both reserved bytes must +be zero. Only printable ASCII `0x20..0x7F` is accepted. The native bridge +constructs the live x64 MSVC strings in the physically proven order +`row2/row1/row4/row3`. + +The first successful frame starts a monotonic 200 ms gate. Identical frames +are suppressed before that gate; changed frames arriving sooner are reported +as rate-limited without calling Escape. Any Escape failure marks the session +failed and prevents further writes. Explicit end and handle close both release +exclusive acquisition. + +The managed application duplicates the safety boundary: its normal +no-argument mode remains console-only, while RS50 output requires four exact +ordered arguments and automatically cancels after ten seconds. Its concrete +native transport is behind an injected interface, so unit tests never load +the DLL or create a window. + +Build D completed its first separately authorized ten-second physical trial on +2026-07-27. It displayed live iRacing speed and gear, stopped automatically, +and passed offline USB request/response checks, but subsequently proved to +disrupt iRacing's LED updates and normal FFB centering. See +[`evidence/RS50_DYNAMIC_TELEMETRY_SUCCESS_2026-07-27.md`](evidence/RS50_DYNAMIC_TELEMETRY_SUCCESS_2026-07-27.md). +An on-track trial is prohibited until the installed driver's exclusive +foreground requirement is removed, safely coordinated, or replaced by a +transport that coexists with the simulator. + +Post-trial decoding established that the three runtime-`0x10` operations were +HID++ `0x8123` `RESET_ALL`, `SET_GLOBAL_GAINS(0xFFFF)`, and `RESET_ALL`. +These are emitted by the DirectInput driver's exclusive lifecycle, not by the +OLED request, and destroy the simulator's active force-effect state. + +The open RS50 Linux-driver protocol reference independently separates joystick +input (interface `0`), HID++ configuration (interface `1`), and real-time FFB +(interface `2`, endpoint `0x03`). Future research may therefore replace this +bridge with a narrowly typed feature-`0x8130` HID++ transport on interface `1`. +Such a transport must discover the runtime index, require the exact RS50 +identity, serialize one matched request at a time, reject every feature and +function except the proven display query/set surface, and never expose generic +raw reports. No physical test is authorized by this design note. + +Build E now implements that closed protocol surface and session state machine +offline, with no physical exchange implementation and no CLI route. See +[`RS50_SHARED_HIDPP_BUILD_E_DESIGN.md`](RS50_SHARED_HIDPP_BUILD_E_DESIGN.md). +The DirectInput bridge remains retained as research evidence but must not be +used for moving-car or endurance telemetry. + +## Explicit Non-Goals + +- no raw HID++ sender +- no HID report replay +- no unknown command fuzzing +- no firmware writes +- no compatibility-mode PID guessing +- no merge to `main` before physical output and cleanup behavior are verified diff --git a/LogiDynamicDash/docs/RS50_DYNAMIC_TELEMETRY_OPERATOR_CHECKLIST.md b/LogiDynamicDash/docs/RS50_DYNAMIC_TELEMETRY_OPERATOR_CHECKLIST.md new file mode 100644 index 0000000..0593921 --- /dev/null +++ b/LogiDynamicDash/docs/RS50_DYNAMIC_TELEMETRY_OPERATOR_CHECKLIST.md @@ -0,0 +1,90 @@ +# RS50 Bounded Dynamic Telemetry Checklist + +This checklist governed the first Build D physical trial. It sends formatted +iRacing telemetry for at most ten seconds and then releases DirectInput +acquisition automatically. It is not an endurance test or authorization for +an unlimited session. + +The first trial completed successfully on 2026-07-27. See +[`evidence/RS50_DYNAMIC_TELEMETRY_SUCCESS_2026-07-27.md`](evidence/RS50_DYNAMIC_TELEMETRY_SUCCESS_2026-07-27.md). + +## 1. Preconditions + +1. Obtain separate explicit authorization for one ten-second Build D trial. +2. Keep G HUB and its updater/agent processes closed. +3. Wake the RS50 and set `Settings -> HomeScreen -> Dynamic`. +4. Confirm the OLED shows either Test or the previous static Build C text. +5. Start iRacing in a replay or safe stationary session with changing speed + and gear telemetry. +6. Keep hands clear of the wheel and make the stop/power control accessible. + +## 2. Baseline + +Capture the current RS50 USB controller for five seconds without running +LogiDynamicDash. Save: + +```text +2026-07-27_rs50_dynamic_telemetry_baseline.pcapng +``` + +## 3. Ten-Second Trial + +Start a fresh USBPcap capture on the same controller. From the repository +root, run exactly: + +```powershell +dotnet run ` + --project .\LogiDynamicDash\LogiDynamicDash.csproj ` + --configuration Release ` + -- ` + --enable-rs50-oled ` + --confirm-exclusive-layout-j-stream ` + --confirm-telemetry-transmission ` + --confirm-10-second-trial +``` + +All four application arguments and their order are mandatory. Missing, +partial, extra, or reordered arguments exit before creating a native window +or opening DirectInput. + +The trial must stop itself after ten seconds. Do not repeat it, start G HUB, +or send SetIdle afterward. + +## 4. Observe and Save + +Record: + +- whether speed and gear change on the OLED; +- whether brake bias and last-lap temporary screens appear if their telemetry + changes; +- any display flicker, partial row, stale value, or unexpected fallback; +- any torque, LED, or wheel-movement change; +- the console exit result. + +Save the trial capture as: + +```text +2026-07-27_rs50_dynamic_telemetry_attempt_1.pcapng +``` + +## 5. Offline Acceptance + +Do not authorize another run until offline analysis confirms: + +- all feature-`0x8130` function-`3` requests have matched responses; +- no HID++ error response occurred; +- the observed rate never exceeds five function-`3` requests per second; +- consecutive identical payloads are absent; +- the first wire payload uses Layout J and matches the console frame; +- acquisition and release completed successfully. + +Any failed Escape, unmatched request, physical side effect, excessive update +rate, or malformed display is a stop condition. Only after this bounded trial +passes may an unlimited user-facing session be designed. + +The first trial passed the USB protocol checks but failed physical coexistence +acceptance: afterward, iRacing's shift LEDs stopped updating and normal FFB +centering was absent until the simulator returned to its main menu and loaded +the circuit again. No moving-car, input-continuity, endurance, or full-lap +trial is authorized. The required exclusive lifecycle must be redesigned or +replaced before further physical streaming. diff --git a/LogiDynamicDash/docs/RS50_HARDWARE_VALIDATION.md b/LogiDynamicDash/docs/RS50_HARDWARE_VALIDATION.md index ac0ee7f..9b7b3b4 100644 --- a/LogiDynamicDash/docs/RS50_HARDWARE_VALIDATION.md +++ b/LogiDynamicDash/docs/RS50_HARDWARE_VALIDATION.md @@ -157,6 +157,121 @@ Use one row per controlled observation: | 4B-COL02 | Closed | Settings/HomeScreen | MI_01 COL02 | Three Settings-button presses | Zero reports; unlike G HUB-open captures, Settings closes caused no `0`, `2`, `1` groups | Confirmed | | 5A | Starting | Settings | MI_01 COL02 | Start G HUB normally | 167 reports; dev `0x01` FeatureSet catalog observed on `0x11` | Confirmed | | 6A | Starting | HomeScreen | All RS50 interfaces | Start G HUB normally under device-scoped USB capture | G HUB enumerated `0x18A2`, `0x8091`, and `0x8093` but did not invoke their runtime indices; no 64-byte host output or sustained display stream appeared | Confirmed | +| 6B | Starting | HomeScreen | All RS50 interfaces | Re-analyze 6A with FeatureSet reconstruction | Base runtime `0x12` maps to public feature `0x8130`; G HUB static name is `DisplayGameData`; zero operational calls during startup | Confirmed | +| 6C | Open | Unknown | All RS50 interfaces | Capture a game/G HUB session with visibly changing RPM LEDs | Runtime `0x0B` (`RPM Indicator`) streamed states 0-10; runtime `0x12` (`DisplayGameData`) received zero host reports | Confirmed negative evidence | +| 7A | Open | Dynamic | All RS50 interfaces | Run a legitimate telemetry-capable producer; do not inject HID reports | Pending: look specifically for host calls to dev `0xFF`, runtime `0x12`, especially function `3` | Unknown | +| 7B | Open | Dynamic | None | After a successful 7A, stop the legitimate producer and time the fallback | Static expectation: pending Dynamic data expires after approximately 240 seconds | Unknown | +| J1 | Closed | Dynamic | MI_01 COL01/COL03 | One authorized 10-second stationary iRacing telemetry trial with video and USBPcap | Video: `SPEED / 0 KMH / GEAR / N`; centering, RPM LEDs, inputs, and connection normal; PCAP retained discovery but not setter/ACK | Physical confirmed; USB inconclusive | +| J2 | Closed | Dynamic | MI_01 COL01/COL03 | One authorized repeat of the same bounded stationary trial with direct USBPcapCMD and host transcript | `SPEED / 0 KMH / GEAR / N`; transcript and PCAP contain byte-identical discovery/setter/ACK pairs; normal LEDs and FFB; no destructive reset/gain/reset lifecycle | Confirmed | +| K1 | Closed | Dynamic | MI_01 COL01/COL03 | One separately authorized fixed A-J gallery with video and USBPcap | All layouts rendered in order; 1 discovery + 10 setters + 11 exact ACKs; no unrelated host operation or observed physical side effect | Confirmed | + +## Phase 5: Legitimate Dynamic Producer Capture + +Feature `0x8130` is now the primary target. This phase must observe software +that legitimately activates the feature; it must not imitate the statically +inferred function-`3` writes. + +### Test 7A: Activation Capture + +1. Put the wheel on the Dynamic HomeScreen and verify the Test fallback. +2. Start a USB capture scoped to the physical RS50 address. +3. Start G HUB normally, then start exactly one candidate game or official + telemetry producer. +4. Generate a short, controlled sequence: neutral, first gear, one speed + change, then stop. +5. Stop the producer and capture without changing wheel settings. +6. Analyze host reports to device `0xFF`, runtime index `0x12`. + +Decisive evidence would be function-`3` updates whose payload changes correlate +with the controlled gear or speed sequence. Optional function-`0`/`1` +capability reads may precede them, but static firmware analysis shows they are +not a required setter handshake. If no `0x12` calls appear, record the candidate +as a negative result; do not broaden the capture or replay unrelated reports. + +### Test 7B: Passive Expiry Timing + +Run this only after Test 7A has legitimately populated the OLED: + +1. Stop the producer immediately after a visible, stable Dynamic update. +2. Do not close G HUB, change screens, press wheel controls, or send traffic. +3. Measure elapsed wall-clock time until the OLED leaves the received layout + or returns to its fallback. +4. Record the observed duration and resulting screen. + +Static firmware analysis predicts approximately 240 seconds because function +`3` loads `240000` and the counter is decremented by the 1 ms SysTick-driven +display task. This passive observation tests that prediction without replaying +or injecting a report. + +## Phase 6: Future DirectInput Support Probe + +This phase is **not** part of the read-only session above. Run it only after +the user explicitly approves transmitting documented support-query commands. +Do not combine it with a layout setter. + +Static analysis of both installed x86 and x64 force-feedback drivers shows +that outer DirectInput Escape command `4` accepts version `1` and these +non-setter inner commands: + +| Inner command | Query | Minimum input | Minimum output | +|---:|---|---:|---:| +| 2 | General display support | 12 | 1 | +| 3-5 | Layout A-C support | 12 | 1 | +| 6 | Layout D support | 12 | 4 | +| 7-10 | Layout E-H support | 12 | 6 | +| 11-12 | Layout I-J support | 12 | 10 | + +The driver writes the boolean support result only to output byte zero. Prefill +the complete buffer with a sentinel and require all trailing bytes to remain +unchanged. Build A created a process-owned top-level window, requested +nonexclusive background cooperation, and sent command `2` exactly once. +DirectInput returned `DIERR_NOTEXCLUSIVEACQUIRED` and left the sentinel +unchanged. Build A2 used a visible foreground window plus bounded exclusive +acquisition, but `Acquire` returned `DIERR_INVALIDPARAM` because no data format +had been set; its capture contained no HOST HID++ report. Build A3 first calls +`SetDataFormat(&c_dfDIJoystick2)`, then retains the same bounded acquisition and +release. Record cooperative, data-format, Acquire, Escape, and Unacquire +results, returned bytes, and scoped USB traffic, then stop. Do not proceed to +layout queries in the same run unless the command is confirmed non-mutating +and the user approves the next step. +Setter commands `1` and `13-22` remain out of scope. + +Static tracing confirms that command `2` is not merely a cached capability +check: the driver submits an asynchronous feature request and waits up to +200 ms for its result. Treat it as transmitted device traffic and capture it; +do not describe this phase as passive or read-only USB monitoring. + +Build A3 physically completed this phase on 2026-07-27. All DirectInput +HRESULTs were successful, output byte zero was `1`, and the operator observed +no physical change. USBPcap matched 15 HOST requests to 15 DEVICE responses, +including Root discovery of public feature `0x8130` at runtime `0x12`; there +were zero operational requests to that runtime. See +[`evidence/RS50_DIRECTINPUT_QUERY_BUILD_A3_SUCCESS_2026-07-27.md`](evidence/RS50_DIRECTINPUT_QUERY_BUILD_A3_SUCCESS_2026-07-27.md). + +The first later A-J layout query has a larger expected footprint. It lazily +sends feature function `0` once, then function `1` for every reported layout +and caches the descriptors. The RS50's known count of ten predicts eleven +transactions in that first layout-query capture and no equivalent refresh for +the remaining layout queries in the same feature-object lifetime. +Because the driver marks this cache initialized before the exchange, any +timeout or partial result requires closing and recreating the DirectInput +device before a retry. + +Use Layout J query `12` with its exact ten-byte output as that first query. +Never test a null or undersized output: Layout A query `3` contains an unsafe +malformed-buffer path in the installed driver. Boundary fuzzing is explicitly +out of scope. + +Build B executed that single Layout J query on 2026-07-27. All DirectInput +HRESULTs succeeded, output byte zero was `1`, and bytes 1-9 retained sentinel +`0xA5`. USBPcap reconstructed exactly the predicted eleven operational +`0x8130` exchanges: one layout-count query and ten descriptor queries. The +RS50 reported Layout J ID `10` with capacities `19/10/19/10`. The operator's +explicit physical observation confirmed no OLED, LED, torque, or +wheel-position change. This permits Build C to be compiled and audited, but +its physical execution still requires separate approval and capture. +See +[`evidence/RS50_LAYOUT_J_QUERY_SUCCESS_2026-07-27.md`](evidence/RS50_LAYOUT_J_QUERY_SUCCESS_2026-07-27.md). ## Evidence Levels @@ -164,6 +279,13 @@ Use one row per controlled observation: - **Likely:** supported by multiple observations but not independently proven. - **Unknown:** insufficient or conflicting evidence. -No result from this plan confirms OLED write support. A separate, explicitly -approved phase will be required before the project sends any report to the -RS50. +At the end of the query phase, no result yet confirmed OLED write support; a +separate, explicitly approved setter phase was required. + +Build C subsequently completed that separately approved phase. One fixed +Layout J setter produced a matched `0x8130` function-`3` exchange and visible +OLED text. The capture decoded `RS50 / LOGIDYNAMI / TEST 1 / OLED LINK`, +exactly matching the supplied photograph. The operator observed no torque, +LED, or wheel-movement change. Static output is therefore confirmed; live or +repeated telemetry remains a separate gate. See +[`evidence/RS50_STATIC_LAYOUT_J_SUCCESS_2026-07-27.md`](evidence/RS50_STATIC_LAYOUT_J_SUCCESS_2026-07-27.md). diff --git a/LogiDynamicDash/docs/RS50_LAYOUT_J_QUERY_OPERATOR_CHECKLIST.md b/LogiDynamicDash/docs/RS50_LAYOUT_J_QUERY_OPERATOR_CHECKLIST.md new file mode 100644 index 0000000..c3c4bdb --- /dev/null +++ b/LogiDynamicDash/docs/RS50_LAYOUT_J_QUERY_OPERATOR_CHECKLIST.md @@ -0,0 +1,96 @@ +# RS50 Layout J Capability Query Checklist + +Use this checklist only after the successful general-support evidence in +`RS50_DIRECTINPUT_QUERY_BUILD_A3_SUCCESS_2026-07-27.md`. + +This stage queries Layout J capability with documented inner command `12`. It +does not compile a display setter, layout payload, force-feedback effect, +input-state read, raw HID write, or retry. + +## 1. Verify the guarded build + +From the repository root: + +```powershell +.\scripts\Build-Rs50DirectInputBridge.ps1 -Configuration Release +``` + +Require: + +- prerequisite checker `READY`; +- ABI `4`; +- exact six-export query-only surface; +- one `SetDataFormat`, one `Acquire`, one `Escape`, and two guarded + `Unacquire` source paths; +- no setter is selected by this command, and there is no generic setter, + polling, device-state read, or raw HID import; +- all incomplete and wrong confirmation sequences refused. + +The build and its automated tests do not open the RS50. + +## 2. Prepare the physical state + +1. Keep `LGHUBUpdaterService` stopped and G HUB closed. +2. Wake the RS50. +3. Select `Settings -> HomeScreen -> Dynamic`. +4. Confirm Dynamic shows the normal Test fallback. +5. Center the rim, keep hands clear, and record normal torque/LED state. + +Stop if any starting condition differs. + +## 3. Capture baseline and query + +1. Capture the RS50 USBPcap interface for five seconds without touching the + wheel. +2. Stop and save the baseline. +3. Immediately start a second capture on the same interface. +4. Confirm the USB device address did not change. +5. Leave the second capture running. + +## 4. Arm exactly one Layout J query + +Only after the user explicitly authorizes the standard format, exclusive +acquisition, and one Layout J capability query, execute: + +```powershell +.\artifacts\native\Release\Rs50DirectInputQuery.exe ` + --query-layout-j-support ` + --confirm-standard-data-format ` + --confirm-exclusive-acquire ` + --confirm-transmit-layout-query +``` + +The guarded call uses: + +- outer Escape command `4`; +- inner query command `12`; +- 12-byte version-1 input; +- exact 10-byte output capacity; +- sentinel `0xA5` in every output byte; +- `c_dfDIJoystick2`; +- exclusive foreground Acquire; +- immediate Unacquire. + +Do not repeat the command, even if it fails. + +## 5. Validate and stop + +Require all of the following before interpreting support: + +- cooperative, data-format, Acquire, Escape, and Unacquire HRESULTs are zero; +- inner command is `12`; +- capacity remains `10 -> 10`; +- output byte zero is boolean `0` or `1`; +- output bytes 1-9 remain `A5`; +- no OLED, torque, LED, or wheel-position change occurs. + +Observe for ten seconds, stop the capture, and save it. Any failed release, +changed trailing byte, physical change, unexpected transaction count, or +identity mismatch is a stop condition. + +Static recovery predicts one feature-`0x8130` function-`0` request followed by +ten function-`1` descriptor requests when the layout cache is first populated. +Treat a different count as evidence to investigate, not permission to retry or +send a setter. + +Analyze the saved capture offline before designing Build C. diff --git a/LogiDynamicDash/docs/RS50_OLED_VISUAL_CAPABILITIES_RESEARCH.md b/LogiDynamicDash/docs/RS50_OLED_VISUAL_CAPABILITIES_RESEARCH.md new file mode 100644 index 0000000..67f426c --- /dev/null +++ b/LogiDynamicDash/docs/RS50_OLED_VISUAL_CAPABILITIES_RESEARCH.md @@ -0,0 +1,214 @@ +# RS50/PRO OLED Visual Capabilities Research + +## Confirmed Conclusion + +The confirmed Dynamic protocol is a typed firmware renderer, not a host +framebuffer. Firmware analysis, the recovered G HUB protocol, exact USB +traffic, and the accepted physical A-J gallery now agree on that model. + +Public feature `0x8130` exposes exactly four functions: + +1. return the layout count; +2. return one fixed layout descriptor; +3. clear pending Dynamic data; +4. select one layout and supply its bounded fields. + +No `0x8130` request contains a font identifier, font size, pixel coordinates, +bitmap, image format, drawing opcode, color, or arbitrary-length data stream. +The largest payload is 59 bytes of layout index plus fixed text fields. + +Therefore: + +- bars and fixed graphics are available through selected firmware layouts; +- font size/style and placement can change indirectly with the layout; +- the host can change the values/text inside a layout; +- the host cannot choose an arbitrary font, size, position, or graphic through + the confirmed protocol. + +This conclusion applies to the confirmed public Dynamic interface. It cannot +mathematically prove that no separate undocumented interface exists anywhere, +but no installed firmware, SDK, G HUB implementation, USB descriptor, startup +capture, or physical K1 result exposes one. + +## Recovered Visual Primitives + +Static analysis of RS50 firmware +`U165.04_B0039-gf6375b51` identifies ten renderers: + +| Layout | Host-controlled fields | Physically observed composition | +|---|---|---| +| A | none | blank/black OLED; panel power state unknown | +| B | none | firmware Test screen with vertical `C/B/G/H` bars | +| C | one normalized byte | one horizontal solid/striped gauge | +| D | two normalized bytes, one text | main gauge, thin indicator, text | +| E | two normalized bytes, two texts | same indicators, split left/right text | +| F | 1-char and 3-char text | medium left, separator, very large right | +| G | 1-char and 3-char text | very large left, separator, medium right | +| H | 21-char and 10-char text | small centered row, larger right-aligned row | +| I | four texts | alternating small/large rows with mixed alignment | +| J | four texts | alternating small/large centered rows | + +The numeric conversion is: + +```text +wire byte = round(clamp(game value, 0.0, 1.0) * 255) +drawn extent = wire byte * 118 / 255 pixels +``` + +The recovered placement confirms the intended visual roles: + +- C draws one 118 x 10 horizontal gauge at `(5, 27)`; +- D/E draw the same 118 x 10 gauge at `(5, 38)` plus a second 118 x 2 + indicator at `(5, 50)`; +- F/G place a one-character and three-character value on opposite sides of a + fixed 1 x 28 separator, swapping which side receives the 37 px font; +- H combines two text regions with a fixed 23 x 18 left-side field; +- I repeats that decorated left-field/text composition twice; +- J contains only four centered text rows, at vertical positions 2, 12, 35, + and 45. + +Firmware text rendering measures strings using fixed font descriptors and then +places them at hard-coded coordinates. Different renderers reference different +font descriptors, confirming multiple firmware font sizes/styles. There is no +field that lets the host select those descriptors. + +The descriptors expose five exact raster heights: + +| Firmware descriptor | Raster height | Glyph widths | Confirmed layout use | +|---|---:|---:|---| +| `0x08044631` | 9 px | 3-9 px | H/I/J first and third text regions | +| `0x08044636` | 16 px | 5-17 px | D/E text regions | +| `0x08043032` | 18 px | 4-18 px | H/I/J second and fourth text regions | +| `0x08043037` | 27 px | 6-27 px | F second text; G first text | +| `0x0804303C` | 37 px | 8-37 px | F first text; G second text | + +This establishes that font size really can change, but only by choosing a +layout whose renderer references the desired built-in descriptor. The wire +request still has no font-selector field. Layouts F and G deliberately swap +the 27 px and 37 px fonts between their one-character and three-character +fields. + +Each font is a proportional one-bit bitmap font. A glyph record contains its +pixel width, bitmap byte count, and a pointer to row-major raster data; it +contains no family/style name. Inspection of representative glyphs shows the +same squared, sans-serif racing-display design at five raster sizes rather +than five host-selectable TrueType/OpenType faces. Lowercase input is converted +to uppercase by the protocol handler, non-printable/non-ASCII input becomes +`?`, and the usable host repertoire is therefore the firmware's printable +single-byte uppercase-oriented glyph set rather than Unicode. + +The clear phase passes exactly `0x400` bytes to the display buffer routine. +Together with the renderers' 0-127 horizontal and 0-63 vertical bounds, this +identifies an internal 128 x 64 one-bit framebuffer (`128 * 64 / 8 = 1024` +bytes). That framebuffer exists inside firmware, but no recovered host command +accepts or exposes its bytes. + +## Reproducible Offline Font Inspection + +[`scripts/Inspect-Rs50OledFirmwareVisuals.py`](../../scripts/Inspect-Rs50OledFirmwareVisuals.py) +verifies the official DFU SHA-256 before reading anything, validates all 96 +glyph records in each of the five fonts, reports their dimensions as JSON, +and can render a local representative BMP. It imports no HID/device library +and never opens the RS50. + +```powershell +python .\scripts\Inspect-Rs50OledFirmwareVisuals.py ` + --firmware "C:\ProgramData\LGHUB\depots\794591\rs50_racing_wheel_dfu\rs50_main_v165_4_39.dfu" ` + --bmp-output ".tmp\rs50_oled_font_samples.bmp" +``` + +The generated BMP is intentionally local and uncommitted because its pixels +are extracted from Logitech firmware. + +## Negative Evidence for a Framebuffer + +- Complete G HUB startup captures contain no sustained large host payloads. +- Normal startup enumerates Display Game Data but does not transmit a Dynamic + frame. +- The installed public Wheel SDK and TRUEFORCE SDK expose no OLED, image, + font, or layout API. +- G HUB's `DisplayGameData` implementation serializes only layouts A-J into + feature `0x8130` function 3. +- The base firmware registry gives `0x8130` exactly four handlers; none is a + bitmap upload or arbitrary draw command. +- The protocol descriptors report only fixed byte/text capacities. +- The installed G HUB depot contains one RS50 main firmware and one drive + firmware. Its DFU manifests expose no separate rim/display firmware image + or OLED update endpoint. + +## Unknown Rim-Module Features + +The attached display/rim subdevice (`dev_idx 0x01`) advertises public features +`0x18A2`, `0x8091`, and `0x8093`, plus hidden/engineering feature `0x9315`. +`0x8091` is associated with the button/LED matrix. No semantics have been +established for `0x18A2`, `0x8093`, or `0x9315`. + +Important boundaries: + +- G HUB enumerated `0x18A2` and `0x8093` but made no operational request to + either during the captured startup. +- The installed G HUB agent exposes no named implementation class for those + IDs, unlike `Feature8130DisplayGameData`. +- No captured traffic resembles a framebuffer upload. +- `0x9315` advertises hidden, engineering, and + manufacturing-deactivatable flags. + +Unknown functions will not be probed by guessing. A safe next lead requires +one of: + +- a firmware image for the rim/display module; +- a legitimate G HUB/game capture that actually uses the feature; +- a named implementation or protocol description; +- read-only metadata that establishes function semantics. + +## Physical A-J Confirmation + +Build K ran once on 2026-07-28 with video and USBPcap. The OLED visibly +advanced through A-J, all ten setters received exact acknowledgements, and the +operator observed no movement, torque, unexpected resistance, LED change, +input loss, or disconnect. + +The video confirmed the gauge geometry, fixed Test graphic, five font sizes, +and alignment differences listed above. H/I's reserved left-side fields were +blank in this stationary session, so they are not claimed as usable host +graphics. Complete evidence is in +[`evidence/RS50_BUILD_K_LAYOUT_GALLERY_SUCCESS_2026-07-28.md`](evidence/RS50_BUILD_K_LAYOUT_GALLERY_SUCCESS_2026-07-28.md). + +## Practical Mapping for LogiDynamicDash + +| Dashboard goal | Best layout | Trade-off | +|---|---|---| +| very large gear + 3-digit speed | F or G | no bar and no unit field | +| gear/speed plus RPM and secondary bar | E | smaller 16 px text, but two split text fields and two indicators | +| one label/value plus two indicators | D | one shared 11-character text field | +| standalone RPM/progress display | C | no text | +| two-row status or lap page | H | fixed 9/18 px hierarchy | +| compact four-field telemetry page | I | mixed center/right alignment | +| generic four-row telemetry page | J | no bars, but least semantic constraint | + +For a useful racing dashboard, Layout E is the strongest all-in-one candidate: +its 7-character field appears on the left and can carry speed/unit, while its +3-character field appears on the right and can carry gear. This visual order +is the reverse of their order in the function-3 payload. The main gauge can +carry normalized RPM and the thin lower indicator can carry a second +normalized value. F/G are better when maximum gear/speed legibility matters +more than an RPM bar. J remains the safest general-purpose text fallback. + +## Decision Rule + +The project will claim arbitrary graphics or font control only if a bounded, +documented command is found and independently observed in legitimate traffic +or firmware. The production design must therefore treat A-J as the entire +supported visual vocabulary unless future bounded evidence proves another +interface. It may expose layout selection and semantic telemetry mapping, but +must not advertise custom graphics, custom fonts, free positioning, Unicode, +or color control. + +References: + +- Logitech RS50 setup guide: + +- mescon protocol specification: + +- local evidence: + `evidence/RS50_FEATURE_8130_DISPLAY_GAME_DATA_2026-07-22.md` diff --git a/LogiDynamicDash/docs/RS50_PROTOCOL_RESEARCH.md b/LogiDynamicDash/docs/RS50_PROTOCOL_RESEARCH.md index 7227577..413bc7e 100644 --- a/LogiDynamicDash/docs/RS50_PROTOCOL_RESEARCH.md +++ b/LogiDynamicDash/docs/RS50_PROTOCOL_RESEARCH.md @@ -38,7 +38,9 @@ evidence to assign them names or complete semantics: ## Dynamic OLED Status -No public Dynamic OLED API has been identified. +No public Dynamic OLED API has been identified. However, HID++ feature +`0x8130` is now a strongly identified internal **Display Game Data** transport +candidate. Passive HID monitoring and G HUB USB captures did not reveal a framebuffer, text stream, or documented OLED feature. @@ -46,16 +48,177 @@ framebuffer, text stream, or documented OLED feature. The Dynamic screen currently falls back to the Test screen because no known application is supplying Dynamic display data. -### Working Hypothesis: Firmware-Rendered Settings UI +This fallback matches Logitech's own PRO Racing Wheel setup guide. It describes +Dynamic as an extension point for potential future screen updates and states +that it defaults to Test. That official wording sharply narrows the producer +search: the firmware and current DirectInput driver contain the implementation, +but a public game-facing release was not guaranteed. It also makes a missing +legitimate producer an expected result rather than evidence that the recovered +feature is unrelated. + +### Strongest Candidate: Feature 0x8130 Display Game Data + +The complete G HUB startup capture showed the base device (`0xFF`) advertising +public feature `0x8130` at runtime index `0x12`, flags `0x00`, version 0. Static +inspection of the installed G HUB agent independently found the exact class +name `devio::Feature8130DisplayGameData` and its two associated interfaces. + +Static analysis of the official RS50 base firmware then confirmed the feature +registry entry and all four handlers. Function `0` returns ten layouts; +function `1` returns one six-byte descriptor for layout A through J; function +`2` clears pending Dynamic data; and function `3` installs bounded byte/text +fields for the selected layout. G HUB's DirectInput/FFB manager independently +names layouts C through J. + +Function `1` takes a zero-based layout index and returns six bytes: +`[zero-based index, one-based layout ID, four capability bytes]`. The driver's +one-time capability loader dispatches on the one-based ID, stores the +descriptor, and sets a separate validity flag. DirectInput's layout-support +boolean is that validity flag, not response byte `0`. + +The largest layouts contain four text fields with maxima 19, 10, 19, and 10 +bytes. Firmware uppercases printable ASCII, replaces invalid characters, and +NUL-terminates fields. This confirms a typed, firmware-rendered telemetry +layout protocol rather than a raw framebuffer. + +The firmware draw routines consume the two numeric state bytes as normalized +8-bit values: each is converted to floating point and scaled by `118 / 255` +before being passed to a display primitive. Thus the wire bytes represent +0-255 gauge/progress extents across a 118-pixel span, not decimal text values. +Which game telemetry concepts occupy those gauges remains unassigned. + +The render-dispatch table maps all ten layout indices to concrete firmware +routines. Layout J is especially useful for a first text-only dashboard: it +draws its four independent fields as centered rows at vertical positions 2, +12, 35, and 45. Layout I uses the same four stored fields but adds left-side +decoration and right-aligns fields two and four. This makes J a strong +engineering candidate for labels such as speed/unit and gear/value without +claiming that Logitech assigned those semantics. Physical output remains +unverified. + +Normal G HUB startup enumerated `0x8130` but sent zero operational requests to +runtime index `0x12`. The field-to-screen meanings and activation/session rules +are therefore still unknown, and no function-`3` payload is yet approved for +physical transmission. Full sanitized evidence, exact layout bounds, firmware +hash, and inference boundaries are recorded in +[`evidence/RS50_FEATURE_8130_DISPLAY_GAME_DATA_2026-07-22.md`](evidence/RS50_FEATURE_8130_DISPLAY_GAME_DATA_2026-07-22.md). + +A guarded DirectInput general-support query later reproduced that discovery +with G HUB closed. The RS50 returned support byte `1`, USBPcap assigned +`0x8130` to runtime `0x12`, and the OLED did not change. The capture contained +no operational request to runtime `0x12`, confirming that support discovery is +not itself a layout update. See +[`evidence/RS50_DIRECTINPUT_QUERY_BUILD_A3_SUCCESS_2026-07-27.md`](evidence/RS50_DIRECTINPUT_QUERY_BUILD_A3_SUCCESS_2026-07-27.md). + +The subsequent single Layout J capability query also matched the recovered +model. DirectInput returned support byte `1` with an intact ten-byte sentinel +contract, while USBPcap captured one layout-count request plus ten descriptor +requests to runtime `0x12`. The physical RS50 reported Layout J ID `10` and +four string capacities `19/10/19/10`. Explicit operator confirmation of the +physical observation recorded no OLED, LED, torque, or wheel-position change. +This closes the query gate but does not itself demonstrate display output. See +[`evidence/RS50_LAYOUT_J_QUERY_SUCCESS_2026-07-27.md`](evidence/RS50_LAYOUT_J_QUERY_SUCCESS_2026-07-27.md). + +Build C then confirmed display output. One DirectInput Layout J setter yielded +one matched feature-`0x8130` function-`3` request/response and visibly replaced +the Test fallback. The captured wire strings and photographed rows were +`RS50 / LOGIDYNAMI / TEST 1 / OLED LINK`. Comparing them with the four +game-facing inputs proves the driver's pairwise permutation `2/1/4/3`; the +ten-character second row also physically confirms the recovered capacity. No +torque, LED, or wheel-movement change was observed. +See +[`evidence/RS50_STATIC_LAYOUT_J_SUCCESS_2026-07-27.md`](evidence/RS50_STATIC_LAYOUT_J_SUCCESS_2026-07-27.md). + +The feature name occurs in the installed G HUB depot only inside the agent and +DirectInput/FFB manager binaries, not the Electron front-end archive. The +manager does contain internal `Display::Message::SetLayout` commands, so the +transport sits below the visible G HUB UI. It remains outside the exported +legacy Wheel SDK and TRUEFORCE API surface inspected earlier. + +The manager embeds separate 32-bit and 64-bit DirectInput force-feedback +drivers. Static extraction of the 64-bit `hidpp_forcefeedback` DLL confirmed +the complete game-to-device path: typed `EscapeCommands::Wheel::LayoutC` +through `LayoutJ` callbacks create internal messages 29 through 36, and the +driver's `Feature8130DisplayGameData` implementation serializes them as +function-`3` HID++ payloads. At the game-facing layer, C contains one 32-bit +floating value; D contains two 32-bit values plus one string; E contains two +32-bit values plus two strings; F/G/H contain two strings; and I/J contain +four strings. Before USB transmission, each numeric value is clamped to +`0.0..1.0`, multiplied by `255.0`, rounded, and reduced to the byte expected +by firmware. This is an internal DirectInput Escape surface, not an exported +function in the installed public Wheel SDK. + +The Layout C callback at virtual address `0x18001BF30` forwards the incoming +32-bit value unchanged to the message constructor at `0x1800115D0`. That +constructor creates message ID `29` and copies the original float bits into +the message object at offset `0x14`. The message-29 dispatch case then calls +the Layout C adapter at `0x180015A20`, which implements +`round(clamp(value, 0, 1) * 255)`. The Layout D and E adapters apply the same +formula independently to both of their numeric values. Thus the official +game-facing range is normalized `0.0..1.0`, while the firmware wire range is +`0..255`. + +`LogiDynamicExplorer.Protocol.Rs50DisplayGameDataPayloadEncoder` implements +that recovered conversion and the A-J field bounds as an offline-only +artifact. It returns only function-`3` parameter bytes. It deliberately does +not construct a HID++ report header, select a device, resolve a runtime feature +index, open a HID handle, or call a write API. Text is restricted to the +firmware's safe ASCII domain, lower-case ASCII is uppercased, unsupported +characters become `?`, embedded NUL is rejected, and oversized fields fail +instead of being silently truncated. + +The exact DirectInput envelope is also statically recovered. The driver's +`IDirectInputEffectDriver::Escape` accepts outer `DIEFFESCAPE.dwCommand = 4`. +Its input buffer uses version `1` at offset 4 and an inner command byte at +offset 8. Inner commands 13/14 select data-free layouts A/B; commands 15-22 +select layouts C-J. Their minimum input sizes are respectively 12, 12, 16, +52, 84, 76, 76, 76, 140, and 140 bytes. Text-bearing buffers contain MSVC +`std::string` objects, not a portable packed-wire format. This proves the +internal producer ABI, but it is not yet a safe or public integration surface. +The x86 driver independently reproduces the same outer mapping: its entry `4` +calls a version-1, 22-command display dispatcher, while entry `5` handles the +distinct subtype-and-double family. This eliminates architecture-specific +table interpretation as a source of the earlier off-by-one error. + +The complete inner command family is coherent: command `1` is `SetIdle`, +command `2` queries general display support, commands `3` through `12` query +support for layouts A through J, and commands `13` through `22` set layouts A +through J. On the firmware side, function `3` immediately marks Dynamic data +pending, stores the selected layout, and reloads an expiry counter with +`240000` (`0x0003A980`). It does not require a preceding function-`0` or +function-`1` handshake. The decrement routine runs once per main-loop wakeup +from a flag set by the firmware's `SysTick` handler. That handler also calls +the matching STM32 HAL tick increment routine; ST documents its default time +base as 1 ms. The nominal Dynamic expiry is therefore `240000 ms`, or 240 +seconds (four minutes). A physical elapsed-time observation can independently +confirm that static result. + +The installed RS50 joystick registration selects force-feedback CLSID +`{62B43F0E-E7DB-4329-8C13-A966D84A289F}`. Its 64-bit COM server is +`Direct Input Force Feedback\1_1_13\hidpp_forcefeedback_x64.dll`; its hash is +identical to the DLL extracted from `di_ffb_manager.exe`. DirectInput can +therefore reach the recovered Escape dispatcher through the RS50's normal +`guidFFDriver`, without a separate RS50 kernel filter. + +A later game/RPM capture provided useful negative evidence: G HUB sent live +updates only to runtime `0x0B`, which the startup catalog maps to feature +`0x807A` (RPM Indicator). Its changing value covered all 11 states from 0 to +10, while runtime `0x12` (`0x8130`) received zero host reports. Details are in +[`evidence/RS50_RPM_LIVE_FEED_2026-07-22.md`](evidence/RS50_RPM_LIVE_FEED_2026-07-22.md). + +### Firmware-Rendered Settings UI The current evidence is consistent with the OLED settings screens being rendered by the wheel firmware. Under this hypothesis, G HUB sends individual configuration values, and the firmware presents those values using its own menus and graphics. -This hypothesis is not yet confirmed. It is supported by the configuration -reports observed on MI_01 COL03 and by the absence of an observed framebuffer, -text stream, or sustained display update stream during passive monitoring. +The official base firmware contains the `TORQUE`, `PROFILE`, `TEST`, and +`DYNAMIC` HomeScreen strings, a four-entry pointer table for them, and code +that selects and draws those entries. Together with the physical G HUB-open / +G HUB-closed observations, this confirms that the HomeScreen/settings UI is +firmware-side. The base may still relay drawing operations to the rim module; +the result does not imply that OLED pixels originate in G HUB. The Dynamic display mode may use a separate producer or protocol. No game or public application is currently known to supply live Dynamic OLED data. @@ -175,6 +338,11 @@ assigning runtime indices `0x09`, `0x0E`, and `0x0F`. It did not send any operational request to those runtime indices after enumeration. Subsequent device `0x01` requests used only runtime indices `0x02`, `0x03`, and `0x05`. +The same capture also enumerated `0x8130` on the base device (`0xFF`) at +runtime index `0x12`. Later static analysis identified its G HUB class as +`Feature8130DisplayGameData`. G HUB made zero operational calls to that runtime +index during startup. + The startup traffic contained 378 short 7-byte host HID++ requests and three isolated 20-byte host HID++ requests. It contained no 64-byte host output and no sustained large-payload stream consistent with a framebuffer update. @@ -190,6 +358,16 @@ The complete PCAP remains local and must not be committed. ### Installed Wheel SDK Boundary +Logitech's downloadable Steering Wheel SDK 8.75.30 adds a useful historical +control. Its official standalone C++ sample implements live RPM LEDs by +constructing `DIEFFESCAPE` and calling `IDirectInputDevice8::Escape` directly. +The manual documents that this DInput helper can work without initializing the +SDK. This validates DirectInput Escape as an established Logitech producer +boundary, while the absence of OLED/layout functions confirms that the +Display Game Data extension is not part of that public 2018 API. Reproducible +details are in +[`evidence/RS50_OFFICIAL_WHEEL_SDK_DIRECTINPUT_2026-07-22.md`](evidence/RS50_OFFICIAL_WHEEL_SDK_DIRECTINPUT_2026-07-22.md). + An offline inspection of the G HUB depot found a separately installed `wheel_sdk` package at version `9.1.1.0`. Its manager installs the legacy 32-bit and 64-bit steering-wheel SDK DLLs. The available exports cover wheel @@ -233,9 +411,26 @@ LogiDynamicExplorer --analyze-reports reports.tsv Each non-comment line may contain only hexadecimal bytes, or tab-separated `HOST`/`DEVICE`, optional metadata columns, and hexadecimal bytes in the final -column. The summary groups HID++ reports by header and counts distinct parameter -signatures. Request/response matches require the same device, runtime feature, -function, and software ID; they are structural correlations only. +column. The summary groups HID++ reports by header, counts distinct parameter +signatures, reconstructs FeatureSet ordinal-to-feature mappings from matched +requests and responses (including long `0x12` responses), and reports the +number of later host requests to each runtime index. Request/response matches +require the same device, runtime feature, function, and software ID; they are +structural correlations only. + +On Windows, a USBPcap/Wireshark capture can be exported and analyzed without +creating an intermediate report file. The physical USB address is mandatory +so unrelated devices are excluded: + +```powershell +scripts\Export-Rs50HidReports.ps1 ` + -PcapPath C:\captures\rs50.pcapng ` + -DeviceAddress 4 | + dotnet run --project LogiDynamicExplorer -- --analyze-reports - +``` + +The extractor reads an existing capture only. It does not open the RS50 or +transmit HID reports. Safe validation steps include: @@ -245,6 +440,57 @@ Safe validation steps include: 4. Keep firmware-rendered settings traffic separate from any future Dynamic display evidence. +### iRacing Rev-Light Protocol Validation — 2026-07-27 + +Four controlled USB captures were recorded with iRacing to validate the RS50 +rev-light protocol and improve the methodology used for the ongoing Dynamic +OLED investigation. + +The sanitized findings are preserved in: + +[`evidence/RS50_IRACING_REV_LIGHT_2026-07-27.md`](evidence/RS50_IRACING_REV_LIGHT_2026-07-27.md) + +The captures confirmed that feature `0x807A` is used for the live RPM strip and +that the steady-state feed runs at approximately 60 Hz. + +A startup capture taken before launching iRacing exposed the one-time arm +sequence: + +```text +10ff0b0c000000 fn0 +10ff0b1c000000 fn1 +10ff0b2c000000 fn2 +10ff0b0c000000 fn0 +11ff0b6c... then the fn2 + fn6 live stream +This first-party evidence showed that an extra fn3 command previously used by +an external Linux driver implementation was not part of the normal arm +sequence. That command was identified as SET_EFFECT and could overwrite the +user's active LIGHTSYNC effect. The incorrect command and its associated +lighting restore workaround were subsequently removed from that implementation. +A dedicated request/response capture also confirmed a 0x12 device response +for every tested host write in the rev-light stream. +The redline capture confirmed that no special flash command is used. Redline is +represented by the normal live level reaching: +LL = 10 +The iRacing pit-limiter capture showed that the full-strip flash is implemented +using the same live level mechanism, alternating: +LL = 10 +LL = 0 +at approximately 1.2 Hz. +These findings do not identify the Dynamic OLED transport. Their main value to +LogiDynamicDash is methodological: they demonstrate a repeatable workflow for +proprietary RS50 feature research: +feature discovery +→ runtime feature index +→ one-time initialization +→ live command stream +→ device responses +→ controlled physical-state comparison +Future Dynamic OLED work should use the same structure while remaining +read-only until the relevant feature and command semantics are understood. + + + ## Safety The explorer must not send unknown HID output reports. diff --git a/LogiDynamicDash/docs/RS50_QUERY_ONLY_OPERATOR_CHECKLIST.md b/LogiDynamicDash/docs/RS50_QUERY_ONLY_OPERATOR_CHECKLIST.md new file mode 100644 index 0000000..085ae0b --- /dev/null +++ b/LogiDynamicDash/docs/RS50_QUERY_ONLY_OPERATOR_CHECKLIST.md @@ -0,0 +1,129 @@ +# RS50 Query-Only Operator Checklist + +Use this checklist for the first physical DirectInput validation. The test +transmits one documented support query. It does not contain a display setter, +raw HID write, layout query, or force-feedback effect. Build A3 sets the +standard joystick data format, acquires the RS50 exclusively only around the +single Escape call, and then releases it. + +Do not combine this test with a layout query or text update. + +## 1. Prepare the wheel + +1. Connect and power the RS50 normally. +2. Close G HUB and confirm no G HUB update or agent process is restarting. +3. On the wheel, open `Settings -> HomeScreen -> Dynamic`. +4. Confirm Dynamic visibly shows the normal Test fallback. +5. Center the wheel and keep hands clear of the rim. +6. Record whether torque, LEDs, and wheel position are normal. + +Stop if the visible starting state is different. + +## 2. Verify the guarded build + +From the repository root: + +```powershell +.\scripts\Build-Rs50DirectInputBridge.ps1 -Configuration Release +``` + +Require all of these results: + +- prerequisite checker reports `READY`; +- native export/import audit passes; +- safety tests say no DirectInput or HID device was opened; +- unarmed invocations are refused; +- `--describe` reports ABI `4`. + +The build command is non-transmitting. + +## 3. Start the evidence capture + +1. Start a short baseline Wireshark/USBPcap capture on the interface containing + VID `046D`, PID `C276`. +2. Do not start G HUB. +3. Wait five seconds without pressing wheel controls, then stop and save the + baseline capture. +4. Start a second capture on the same interface. This is the query capture. +5. Note the RS50 USB device address for later device-scoped export and confirm + it did not change between captures. + +Raw captures remain local and are ignored by Git. + +## 4. Arm exactly one support query + +Only after the user explicitly says that the capture is running and Dynamic +still shows Test, execute: + +```powershell +.\artifacts\native\Release\Rs50DirectInputQuery.exe ` + --query-display-support ` + --confirm-standard-data-format ` + --confirm-exclusive-acquire ` + --confirm-transmit-query +``` + +Do not repeat the command, even if it fails. Preserve the console output. The +guarded CLI prints UTC timestamps immediately before and after the DirectInput +call. The extractor preserves each USB frame's epoch time for correlation. + +## 5. Observe and stop + +1. For ten seconds, do not touch G HUB or any wheel control. +2. Record whether the OLED, torque, LEDs, or wheel position changed. +3. Stop and save the capture. +4. Record: + - sanitized product name and VID/PID; + - cooperative-level, data-format, Acquire, Escape, and Unacquire HRESULTs; + - output capacity before/after; + - raw output byte and interpreted support boolean; + - RS50-scoped host/device traffic; + - visible OLED result. + +Any OLED change, force, wheel movement, failed data-format or Unacquire call, +non-boolean output byte, changed output capacity, unexpected transaction count, +or identity mismatch is a stop condition. Do not proceed to Layout J query in +the same session. + +## 6. Inspect the saved capture offline + +```powershell +.\scripts\Inspect-Rs50DirectInputQueryCapture.ps1 ` + -PcapPath .\capture.pcapng ` + -DeviceAddress ` + -FromUtc "" ` + -ToUtc "" +``` + +This command only reads the saved capture. It extracts HOST/DEVICE HID reports +scoped to the selected USB address and feeds them to the offline batch +analyzer. It discovers feature/runtime mappings from the capture when present; +it does not assume runtime index `0x12` is universal. Record the native query +result and physical OLED observation separately. + +Compare the query capture with the five-second baseline: + +```powershell +.\scripts\Compare-Rs50DirectInputQueryCaptures.ps1 ` + -BaselinePcapPath .\baseline.pcapng ` + -QueryPcapPath .\query.pcapng ` + -DeviceAddress ` + -QueryFromUtc "" ` + -QueryToUtc "" +``` + +The comparison ignores frame numbers and timestamps and reports positive exact +HID++ report-count deltas. A delta is only a difference: background traffic and +unequal capture durations can also produce one. Do not treat the comparison +alone as proof of OLED support or as authorization for a setter. + +Use the two UTC values printed by the guarded CLI. Both bounds are required +when filtering; supplying only one or reversing them is rejected. + +## 7. Decide the next build + +- If the call is non-mutating and the traffic matches the documented support + request, review and commit the evidence before separately approving Layout J + capability query `12`. +- If anything differs, close the process and investigate statically. Do not + add a setter or retry on the same DirectInput object. diff --git a/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_E_DESIGN.md b/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_E_DESIGN.md new file mode 100644 index 0000000..1c9e922 --- /dev/null +++ b/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_E_DESIGN.md @@ -0,0 +1,131 @@ +# RS50 Shared HID++ Display Build E + +## Status + +Build E is an offline-only protocol and session implementation for the RS50 +Dynamic OLED. It does not enumerate, open, read, or write a physical HID +collection and is not reachable from the application command line. + +Its purpose is to replace the unsafe DirectInput lifecycle with a future +transport confined to the RS50's separate HID++ interface. The first +DirectInput telemetry trial proved OLED output but emitted Force Feedback +`RESET_ALL`, `SET_GLOBAL_GAINS(0xFFFF)`, and `RESET_ALL`, disrupting iRacing's +LED and FFB state. Build E contains none of those operations. + +## Evidence Boundary + +The successful USBPcap established these base-device transactions: + +```text +Discover 0x8130: +HOST 10 FF 00 0B 81 30 00 +DEVICE 12 FF 00 0B 12 00 00 ... zero padding + +Set Layout J: +HOST 12 FF 12 3B 09 [19] [10] [19] [10] 00 +DEVICE 12 FF 12 3B 00 ... zero body +``` + +Build E assigns software ID `0xA`, so its offline frames use `0x0A` and +`0x3A` instead of the captured driver's `0x0B` and `0x3B`. The feature runtime +index is never hardcoded: Root discovery must return public version-0 feature +`0x8130` before a Layout J transaction can be created. + +## Closed Surface + +Production code exposes exactly two transaction kinds: + +1. Root `GetFeature(0x8130)`. +2. Feature-`0x8130` function-`3`, Layout J index `9`. + +Callers cannot choose another: + +- device index; +- feature ID or runtime index; +- function ID; +- software ID; +- layout index; +- report ID; +- arbitrary parameter buffer. + +Every Layout J row first passes the existing canonical printable-ASCII and +`19/10/19/10` validation. + +The exchange boundary accepts only the closed transaction object. The Build E +protocol and dashboard session provide no class that implements this boundary +against hardware. Build F now compiles such an adapter in a separate library +that the application does not reference. `Program` still has no shared-HID++ +arming argument or construction path. + +## Response Validation + +Discovery and setter responses must: + +- be exactly 64 bytes; +- use report ID `0x12`; +- address base device `0xFF`; +- echo the exact runtime/function/software header; +- not be a HID++ error response; +- preserve the expected public flags and version; +- contain zero in every reserved or acknowledgement byte. + +Any exchange or validation failure permanently faults the session. No retry +or subsequent setter is allowed on that session. + +## Stream Guards + +The session: + +- discovers the feature once per open; +- suppresses equal consecutive frames; +- limits changed frames to five per second; +- transmits no clear, idle, LED, FFB, profile, firmware, or unknown command; +- sends nothing when closed or disposed; +- performs no device operation during local close. + +## Offline Validation + +`Rs50HidppDisplayProtocolTests` and +`Rs50SharedHidppDisplaySessionTests` cover: + +- exact discovery and captured Layout J framing; +- runtime/flags/version/header/body validation; +- HID++ Busy/Unsupported-style error rejection; +- invalid runtime rejection; +- lifecycle ordering; +- deduplication and the exact 200 ms boundary; +- fail-closed behavior; +- disposal. + +Run the source-surface audit with: + +```powershell +.\scripts\Test-Rs50SharedHidppSurface.ps1 +``` + +It fails if the Build E source gains physical HID APIs, DirectInput, +Acquire/Unacquire, native imports, Force Feedback `0x8123`, RPM/LIGHTSYNC +features, other `0x813x` features, a production exchange implementation, a +public protocol type, or a CLI route. + +## Gate Before Any Physical Use + +Build F added one disconnected physical exchange implementation after: + +1. read-only descriptor evidence identified COL01 and COL03; +2. VID `0x046D`, PID `0xC276`, interface, usage, report lengths, and exactly + one matching collection became fail-closed gates; +3. a new surface audit proved the adapter cannot target any feature except + Build E's closed `0x8130` transactions. + +Windows control-transfer behavior remains unverified. G HUB must remain closed +to avoid two HID++ producers, and the user must separately authorize a +captured, stationary, one-frame test through a future one-shot route. + +Acceptance for that future test requires USBPcap to show only feature +discovery and one matched Layout J setter, with zero `0x8123`, `0x807A`, +`0x807B`, interface-2, or endpoint-`0x03` host output. LEDs and FFB must remain +normal both during and after the test. A full lap remains prohibited until a +subsequent bounded stream and stationary input-continuity test also pass. + +See [`RS50_SHARED_HIDPP_BUILD_F_DESIGN.md`](RS50_SHARED_HIDPP_BUILD_F_DESIGN.md). diff --git a/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_F_DESIGN.md b/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_F_DESIGN.md new file mode 100644 index 0000000..a7ba9d3 --- /dev/null +++ b/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_F_DESIGN.md @@ -0,0 +1,151 @@ +# RS50 Shared HID++ Physical Adapter Build F + +## Status + +Build F compiles a physical HID++ exchange adapter but does not connect it to +LogiDynamicDash. The application has no project reference, CLI argument, +factory, reflection path, or copied transport DLL. + +Build G exercised the adapter once on 2026-07-28. USBPcap confirmed exactly one +Root discovery and one Layout J setter over endpoint-0 HID `SET_REPORT`, with +exact responses and no FFB, LED, or MI_02 output. The operator confirmed the +fixed OLED text and observed no physical side effect. + +Build F exists in a separate `Rs50SharedHidppTransport` library. Both it and +the dashboard depend on the neutral `Rs50SharedHidppProtocol` library, so +there is no dependency cycle and the physical library cannot become active +merely by building or running the dashboard. + +## Collection Gates + +Read-only inventory on the physical RS50 established: + +```text +MI_01 COL01 usage FF43:0701 input/output 0x10, 7 bytes +MI_01 COL03 usage FF43:0704 input/output 0x12, 64 bytes +``` + +The adapter requires exactly one of each and rejects before opening a stream +unless all of these match: + +- VID `0x046D`; +- PID `0xC276`; +- path marker `mi_01&col01` or `mi_01&col03`; +- exactly one expected top-level usage; +- exact maximum input and output report lengths. + +Missing, duplicate, or malformed collections fail closed. If more than one +RS50 is connected, its additional COL01/COL03 instances make the uniqueness +gate reject before opening either stream. MI_00, COL02, and MI_02 are never +selected. + +The first Build G preflight proved that Windows assigns different device-path +instance segments to the RS50's top-level collections. Textual equality between +the COL01 and COL03 paths is therefore not a valid sibling-device test. The +gate relies instead on the exact VID/PID, MI_01 collection marker, usage, +length, and system-wide uniqueness observed in the catalog. + +## Exchange Routing + +The adapter accepts only Build E's closed transaction object: + +| Transaction | Output collection | Response collection | +|---|---|---| +| Root discovery of `0x8130` | COL01 | COL03 | +| Layout J function `3` | COL03 | COL03 | + +Before every write it reconstructs or checks the complete canonical envelope. +It cannot accept a caller-selected feature, function, report ID, layout, or +raw byte array. + +COL03 input may also carry unrelated HID++ notifications. After one write, +the adapter reads at most 16 reports and returns only: + +- the exact response header for that transaction; or +- a HID++ error naming that same feature/function/software ID. + +Short reads, timeouts, open failures, or 16 unrelated reports fail the +session. The Build E layer then performs its stricter response-body validation +and permanently faults after any failure. + +## Physical API Boundary + +Only the separate transport assembly references HidSharp. Its concrete wrapper +uses: + +- VID/PID-scoped `GetHidDevices`; +- descriptor reads; +- one `TryOpen` call site; +- bounded `Read` and `Write`; +- one-second read/write timeouts; +- deterministic disposal of both streams. + +`HidStream.Write` follows the Windows user-mode output-report model documented +for `WriteFile`, which Microsoft recommends for continuously sending output +reports to a HID collection. Build F deliberately does not call +`HidD_SetOutputReport`, an API Microsoft recommends only for setting device +state and notes may be unsupported by some devices. + +These API semantics do not establish the bus-level transfer selected by the +Windows HID stack. In particular, they do not prove that this RS50 write will +appear as an endpoint-0 control `SET_REPORT` rather than another transfer. That +remains a required USBPcap observation before accepting the physical route. + +It contains no: + +- DirectInput or Acquire/Unacquire; +- MI_00 or MI_02 route; +- force-feedback, RPM, LIGHTSYNC, profile, firmware, or settings feature; +- Win32/PInvoke HID API; +- `SetFeature`/`GetFeature`; +- public type; +- application reference. + +Run: + +```powershell +.\scripts\Test-Rs50SharedHidppTransportSurface.ps1 +``` + +## Test Coverage + +Build F tests use fake catalogs and streams only. They verify: + +- discovery goes only to COL01; +- Layout J goes only to COL03; +- responses come only from COL03; +- unrelated notifications are skipped with a 16-report bound; +- matching HID++ errors are returned for Build E to reject; +- truncated responses fail; +- both streams are disposed; +- partial-open failure disposes the first stream; +- every identity, usage, length, path-marker, and uniqueness gate. + +## Gate Before First Physical Use + +No physical test is authorized by this build. The next stage must add a +separate one-shot executable or arming route that: + +1. cannot stream or loop; +2. sends one fixed, visually obvious Layout J frame; +3. requires G HUB closed, RS50 awake, Dynamic selected, USBPcap running, and + separate explicit authorization; +4. automatically disposes both HID streams after the acknowledgement; +5. does not run iRacing during the first shared-transport setter. + +Offline acceptance of that capture requires: + +- one Root discovery request/response; +- one Layout J setter/acknowledgement; +- endpoint-0 HID `SET_REPORT` for host HID++ output; +- zero `0x8123`, `0x807A`, or `0x807B` operations; +- zero MI_02 / endpoint-`0x03` host output; +- no LED, FFB, torque, input, or wheel-position side effect. + +A live telemetry stream and a full lap remain prohibited until separate +stationary coexistence tests pass. + +## Primary References + +- [Microsoft: Sending HID Reports](https://learn.microsoft.com/en-us/windows-hardware/drivers/hid/sending-hid-reports) +- [Microsoft: HidD_SetOutputReport](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/hidsdi/nf-hidsdi-hidd_setoutputreport) diff --git a/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_G_DESIGN.md b/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_G_DESIGN.md new file mode 100644 index 0000000..355cc3c --- /dev/null +++ b/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_G_DESIGN.md @@ -0,0 +1,137 @@ +# RS50 Shared HID++ One-Shot Build G + +## Status + +Build G creates a separate physical one-shot executable. Its corrected physical +trial succeeded on 2026-07-28. The RS50 rendered all four fixed lines exactly, +the device acknowledged both transactions, and the operator observed no OLED +side effect beyond the requested frame, LED change, FFB/torque change, or wheel +movement. + +`LogiDynamicDash` does not reference Build G, the physical transport, or +HidSharp. Building or running the dashboard cannot activate this route. + +## Closed Operation + +After six exact ordered arming arguments, Build G: + +1. opens only the Build F validated COL01 and COL03 streams; +2. sends one Root discovery request for feature `0x8130`; +3. validates the exact public version-0 discovery response; +4. sends one fixed Layout J frame; +5. validates its exact acknowledgement; +6. disposes both streams immediately. + +The fixed OLED text is: + +```text +RS50 SHARED HIDPP +BUILD G +ONE SHOT ONLY +USBPCAP +``` + +The executable accepts no user text, runtime index, feature, function, layout, +report ID, raw bytes, repeat count, interval, simulator input, or telemetry. +It contains no loop, timer, task, retry, DirectInput, FFB, LED, or raw HID API. +Any missing, extra, misspelled, or reordered argument exits before the physical +exchange factory is called. + +## Arming Contract + +All arguments must appear exactly in this order: + +```text +--arm-rs50-shared-hidpp +--confirm-ghub-closed +--confirm-rs50-awake +--confirm-dynamic-selected +--confirm-usbpcap-running +--confirm-one-fixed-frame +``` + +Run the source audit with: + +```powershell +.\scripts\Test-Rs50SharedHidppOneShotSurface.ps1 +``` + +## Physical Trial Contract + +Each physical execution requires a separate explicit authorization after +confirming: + +- G HUB is fully closed; +- iRacing and other wheel-using applications are closed; +- the RS50 is awake; +- the OLED is in Dynamic and shows the Test fallback; +- USBPcap is already capturing the RS50; +- the user is ready to observe OLED, LEDs, wheel position, and FFB behavior. + +Suggested capture filename: + +```text +2026-07-28_rs50_build_g_shared_hidpp_oneshot.pcapng +``` + +Only after those conditions are confirmed may the Release executable be run +with all six arguments. The expected visible result is the fixed four-line +message above. The process exits immediately after the acknowledgement. + +The successful attempt-2 capture met every offline acceptance condition: + +- exactly one Root discovery request/response; +- exactly one Layout J setter/acknowledgement; +- expected endpoint-0 HID output semantics; +- zero `0x8123`, `0x807A`, or `0x807B` operations; +- zero MI_02 / endpoint-`0x03` host output; +- no LED, FFB, torque, input, or wheel-position side effect. + +If any side effect appears, no second physical run is authorized. Preserve the +capture, return the wheel to a known safe state, and analyze the trace offline. +Live telemetry and a full lap remain prohibited until a separately designed, +bounded stationary coexistence stage proves repeated shared writes do not +interfere with simulator ownership. + +## First Preflight Evidence + +Capture: + +```text +2026-07-28_rs50_build_g_shared_hidpp_oneshot.pcapng +``` + +Offline extraction found device address `1.3`, 10,441 device-to-host reports, +and zero host-to-device reports carrying HID data. Therefore the failed +preflight contained no discovery, Layout J, `0x8123`, `0x807A`, or `0x807B` +operation. + +The failure established that Windows top-level HID collections have distinct +device-path instance segments. Build F now accepts those distinct paths while +still requiring exactly one collection with every expected VID/PID, MI_01 +marker, usage, and report-length property. Connecting multiple RS50 devices +would create duplicate matches and fail closed. + +## Successful Attempt-2 Evidence + +Capture: + +```text +2026-07-28_rs50_build_g_shared_hidpp_oneshot_attempt2.pcapng +``` + +USBPcap decoded exactly: + +| Frame | Direction | Transfer | Operation | +|---|---|---|---| +| 14077 | host → endpoint 0 | HID control `SET_REPORT` `0x10` | Root discovery of `0x8130` | +| 14079 | COL03 → host | interrupt input `0x12` | runtime index `0x12`, public version 0 | +| 14081 | host → endpoint 0 | HID control `SET_REPORT` `0x12` | fixed Layout J setter | +| 14083 | COL03 → host | interrupt input `0x12` | exact zero-body acknowledgement | + +The discovery response arrived in 2.660 ms, the setter acknowledgement in +2.214 ms, and the complete request/response sequence took 6.618 ms. + +Those were the only two host reports carrying HID data. Consequently the +capture contains zero `0x8123`, `0x807A`, or `0x807B` operations and zero +MI_02 / endpoint-`0x03` host output. diff --git a/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_H_DESIGN.md b/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_H_DESIGN.md new file mode 100644 index 0000000..461ae40 --- /dev/null +++ b/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_H_DESIGN.md @@ -0,0 +1,92 @@ +# RS50 Shared HID++ Bounded Stream Build H + +## Status + +Build H compiles a separate bounded-stream executable. Its H1 physical +repetition baseline succeeded on 2026-07-28: all five counters rendered in +order, every request was acknowledged, and the operator observed no LED, FFB, +torque, or wheel-position change. + +`LogiDynamicDash` does not reference Build H, the physical transport, or +HidSharp. Building or running the dashboard cannot activate this route. + +## Fixed Sequence + +After seven exact ordered arming arguments, Build H: + +1. opens only the validated Build F COL01/COL03 pair; +2. performs one Root discovery of `0x8130`; +3. sends exactly five fixed Layout J frames at 1 Hz; +4. validates every acknowledgement before continuing; +5. disposes both streams immediately after frame 5 or any failure. + +Each frame contains: + +```text +RS50 SHARED HIDPP +BUILD H +FRAME n OF 5 +1 HZ +``` + +where `n` is fixed in source as 1 through 5. The implementation spells out all +five sends and four one-second delays. It has no execution loop, retry, timer, +task, simulator input, telemetry, caller text, duration, rate, count, feature, +function, layout, report ID, or raw-byte argument. + +## Arming Contract + +All arguments must appear exactly in this order: + +```text +--arm-rs50-shared-hidpp-bounded-stream +--confirm-ghub-closed +--confirm-iracing-closed +--confirm-rs50-awake +--confirm-dynamic-selected +--confirm-usbpcap-running +--confirm-one-hz-five-fixed-frames +``` + +Run the source audit with: + +```powershell +.\scripts\Test-Rs50SharedHidppBoundedStreamSurface.ps1 +``` + +## Physical Stages + +### H1: Repetition Baseline — Passed + +H1 ran with G HUB and iRacing closed, RS50 awake, Dynamic selected, and +USBPcap recording. It met every acceptance condition: + +- one discovery request/response; +- exactly five setters and five exact acknowledgements; +- setter spacing of at least 1 second; +- endpoint-0 HID output only; +- zero `0x8123`, `0x807A`, `0x807B`, or MI_02 host output; +- all five counters visibly rendered in order; +- no LED, FFB, torque, input, or wheel-position side effect. + +Capture: + +```text +2026-07-28_rs50_build_h_1hz_five_frames.pcapng +``` + +The four setter intervals were 1.004841, 1.003597, 1.002992, and 1.003694 +seconds. USBPcap found exactly six host reports carrying HID data: one +discovery and five setters. Each used interface 1, endpoint 0, +`URB_FUNCTION_CLASS_INTERFACE`, `URB_CONTROL`, and HID `SET_REPORT`. + +COL03 returned one exact discovery response and five exact zero-body +acknowledgements. There were no other host reports, proving zero `0x8123`, +`0x807A`, `0x807B`, or MI_02 host operations. + +### Later Simulator Coexistence + +Build H explicitly confirms iRacing is closed and must not be reused for a +simulator test. H1 now permits designing a separate, newly audited Build I for +a stationary car in the pits. Live telemetry, moving-car use, and a full lap +remain prohibited until that new stage is implemented and validated. diff --git a/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_I_DESIGN.md b/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_I_DESIGN.md new file mode 100644 index 0000000..70c0468 --- /dev/null +++ b/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_I_DESIGN.md @@ -0,0 +1,140 @@ +# RS50 Shared HID++ iRacing Coexistence Build I + +## Status + +Build I compiles a separate stationary-coexistence executable. Its corrected, +high-buffer I1 capture succeeded on 2026-07-28: all five frames rendered, +iRacing centering and shift LEDs worked before and after, and the operator +observed no movement, torque impulse, input loss, or other side effect. + +Build I does not reference the iRacing SDK and reads no telemetry. It only +changes the test precondition from H1: iRacing is running with the car stopped +in the pits. + +`LogiDynamicDash` does not reference Build I, the physical transport, or +HidSharp. Building or running the dashboard cannot activate this route. + +## Fixed Sequence + +After eight exact ordered arming arguments, Build I performs one discovery and +exactly five fixed Layout J setters at 1 Hz: + +```text +IRACING COEXIST +BUILD I +FRAME n OF 5 +1 HZ +``` + +All five sends and four delays are spelled out in source. There is no execution +loop, retry, timer, task, telemetry, caller text, or caller-controlled rate, +count, duration, feature, function, layout, report ID, or raw data. + +Any discovery, setter, acknowledgement, or delay failure closes both streams +and prevents every later frame. + +## Arming Contract + +All arguments must appear exactly in this order: + +```text +--arm-rs50-shared-hidpp-coexistence +--confirm-ghub-closed +--confirm-iracing-running +--confirm-car-stationary-in-pits +--confirm-rs50-awake +--confirm-dynamic-selected +--confirm-usbpcap-running +--confirm-one-hz-five-fixed-frames +``` + +Run the source audit with: + +```powershell +.\scripts\Test-Rs50SharedHidppCoexistenceSurface.ps1 +``` + +## Offline Validation + +The standard xUnit project compiles with the Build I tests, but local Windows +Code Integrity policy ID `{0283ac0f-fff1-49ae-ada1-8a933130cad6}` blocked +`testhost.exe` from dynamically loading the newly built unsigned test DLL +(events 3033/3077). The policy was not disabled or modified. + +A separate fake-only verifier references the Build I runner and neutral +protocol, but not the physical transport. Direct execution passed: + +- exact arming rejection before factory use; +- one discovery and five fixed frames; +- exactly four delays; +- fixed text and counters; +- stop and disposal on third-frame failure; +- stop and disposal on delay failure. + +Run it with: + +```powershell +dotnet run --project ` + .\Rs50SharedHidppCoexistence.Verification\Rs50SharedHidppCoexistence.Verification.csproj ` + -c Release --no-restore +``` + +## Physical I1 Trial — Passed + +The validated procedure was: + +1. close G HUB; +2. open iRacing and enter the car; +3. remain stopped in the pits, select neutral, and hold the brake; +4. confirm normal centering with one small, gentle steering displacement; +5. briefly rev in neutral and confirm the shift LEDs respond; +6. return to idle, keep the car stationary, and select Dynamic. + +Start USBPcap and record at least 10 seconds of baseline traffic before the +single Build I execution. Keep the engine idling during the five OLED frames. +After Build I closes: + +1. continue capturing; +2. repeat the same small centering check; +3. briefly rev in neutral and confirm the shift LEDs still respond; +4. capture at least 10 more seconds; +5. stop and save the capture without driving. + +Every physical and offline acceptance condition passed: + +- all five counters visibly render in order; +- centering and LED response remain equivalent before and after; +- one discovery plus exactly five setters and acknowledgements; +- display writes use only interface 1 endpoint-0 HID `SET_REPORT`; +- no `RESET_ALL → SET_GLOBAL_GAINS → RESET_ALL` lifecycle around Build I; +- no discontinuity attributable to the display sequence in legitimate + simulator FFB/LED traffic; +- no wheel movement, torque impulse, input loss, or simulator disconnect. + +Capture: + +```text +2026-07-28_rs50_build_i_iracing_stationary_coexistence_attempt2.pcapng +``` + +The first Wireshark-managed capture visibly passed but dropped three of the +five setters amid dense TRUEFORCE traffic. It was not accepted as protocol +evidence. Attempt 2 used USBPcapCMD filtered to RS50 address 3 with a 128 MB +buffer and captured the complete sequence. + +Attempt 2 contains exactly one discovery and five setters, with six exact +responses. Setter intervals were 1.013111, 1.010585, 1.004519, and 1.007545 +seconds. Acknowledgements arrived within 2.358–12.096 ms. + +The entire capture contains zero endpoint-0 control reports to runtime index +`0x10`, previously mapped to feature `0x8123` Force Feedback. During a +25-second window around Build I, host endpoint-`0x03` TRUEFORCE submissions +continued every second at 990–1000 transfers/s. + +Runtime-`0x0B` rev-light operations appear during both the pre-check and +post-check, but not during the five idle OLED frames. There is no +`RESET_ALL → SET_GLOBAL_GAINS → RESET_ALL` lifecycle or simulator traffic +discontinuity attributable to Build I. + +Moving-car use, live telemetry, and a full lap remain prohibited until a +separate bounded telemetry stage is implemented and validated offline. diff --git a/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_J_DESIGN.md b/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_J_DESIGN.md new file mode 100644 index 0000000..1d8cf74 --- /dev/null +++ b/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_J_DESIGN.md @@ -0,0 +1,272 @@ +# RS50 Shared HID++ Stationary Telemetry Build J + +## Status + +Build J compiles as a separate, offline-validated executable for the first +shared-HID++ telemetry trial. J1 ran exactly once on 2026-07-29. Its native +process and physical/video observations passed, but its PCAP did not retain +the Layout J setter/ACK pair. J2 repeated the same bounded stationary trial +exactly once using the reviewed direct-capture method. Its local transcript +and direct PCAP contain byte-identical discovery and Layout J request/response +pairs, closing J1's evidence gap. + +The trial reads only iRacing `IsOnTrackCar`, `Gear`, and `Speed`. While iRacing +reports the car on track and stationary, it renders: + +```text +SPEED +0 KMH +GEAR +N +``` + +The numeric rows come only from live telemetry. Identical frames are +suppressed, changed frames are limited to 5 Hz, and execution ends after ten +seconds. Missing telemetry, leaving the car, invalid gear, negative/non-finite +speed, speed above 0.5 m/s, protocol failure, or an early telemetry shutdown +fails closed and disposes the HID++ exchange. +After the first connected telemetry update, a simulator telemetry disconnect +also fails closed immediately. + +This is not authorization for moving-car use or a full lap. + +## Isolation + +Build J references only: + +- `SVappsLAB.iRacingTelemetrySDK` 2.1.0; +- the typed shared-HID++ protocol; +- the validated shared-HID++ transport. + +It does not reference the `LogiDynamicDash` application or native DirectInput +bridge. It contains no DirectInput acquisition, FFB, LED, raw HID, retry, or +caller-controlled display operation. The main dashboard does not reference +Build J or copy its physical transport. + +Run the source audit with: + +```powershell +.\scripts\Test-Rs50SharedHidppTelemetryTrialSurface.ps1 +``` + +## Arming Contract + +All arguments must appear exactly in this order: + +```text +--arm-rs50-shared-hidpp-telemetry +--confirm-ghub-closed +--confirm-iracing-running +--confirm-car-stationary-in-pits +--confirm-rs50-awake +--confirm-dynamic-selected +--confirm-usbpcap-running +--confirm-video-recording +--confirm-10-second-telemetry-trial +``` + +No physical command should be run until the operator supplies a fresh, +explicit authorization after completing every preparation below. + +## Offline Validation + +The fake-only verifier has no direct transport reference and cannot open the +RS50. It covers: + +- rejection before device or telemetry access unless all arguments match; +- exact speed and gear formatting; +- disconnected-frame suppression; +- identical-frame suppression and the 200 ms minimum interval; +- immediate stop before a setter when speed exceeds 0.5 m/s; +- immediate failure after a connected telemetry stream disconnects; +- failure on an early telemetry-source return; +- failure and disposal on an invalid acknowledgement. + +Run it with: + +```powershell +dotnet run --project ` + .\Rs50SharedHidppTelemetryTrial.Verification\Rs50SharedHidppTelemetryTrial.Verification.csproj ` + -c Release --no-restore +``` + +The standard xUnit tests also compile. Local execution may be blocked by +Windows Code Integrity policy +`{0283ac0f-fff1-49ae-ada1-8a933130cad6}`; that policy must not be disabled or +modified. + +## Local Transaction Transcript + +After J1's USBPcap failed to retain the physically successful setter/ACK +exchange, Build J gained a local write-through JSONL transcript. J2 exercised +this instrumentation on hardware and independently matched every recorded +request and response to the direct USB capture. + +For a physically armed run, it creates an automatically named file under: + +```text +.tmp/rs50-build-j-transcripts/ +``` + +Each exchange writes and flushes a request event before transmission, then +writes either the exact response and elapsed microseconds or only the +exception type. It never records an exception message, device path, general +telemetry stream, FFB, LED traffic, or unrelated HID reports. The path cannot +be selected by a caller and the file is ignored by Git. + +The transcript is bounded to 52 transactions: one discovery plus the +mathematical maximum of 51 telemetry frames in a ten-second trial whose first +frame may be immediate and whose subsequent frames are limited to 5 Hz. +Reaching the bound fails closed before another request is transmitted. +Logging adds no device operation and does not replace independent USBPcap and +video evidence. + +## Executed Physical J1 Trial + +Use a fresh capture: + +```text +2026-07-29_rs50_build_j_iracing_stationary_telemetry_10s.pcapng +``` + +Preparation: + +1. close G HUB and its updater; +2. connect and wake the RS50; +3. start iRacing, enter the car, and remain stopped in the pits; +4. select neutral, hold the brake, and leave the engine idling; +5. confirm Dynamic currently shows the firmware Test screen; +6. confirm normal centering with one small, gentle steering displacement; +7. briefly rev in neutral and confirm the shift LEDs respond; +8. return to idle and reconfirm `Speed 0 km/h`, `Gear N`; +9. start USBPcapCMD filtered to the RS50 device address with a 128 MB buffer; +10. start a video recording that clearly shows the OLED; +11. capture at least ten seconds of untouched baseline traffic. + +After a separate authorization, run Build J exactly once. Do not move the car, +change gear, rev the engine, steer, or touch the OLED settings during its ten +seconds. + +Run the already validated Release executable with this exact command: + +```powershell +& .\Rs50SharedHidppTelemetryTrial\bin\Release\net10.0\Rs50SharedHidppTelemetryTrial.exe ` + --arm-rs50-shared-hidpp-telemetry ` + --confirm-ghub-closed ` + --confirm-iracing-running ` + --confirm-car-stationary-in-pits ` + --confirm-rs50-awake ` + --confirm-dynamic-selected ` + --confirm-usbpcap-running ` + --confirm-video-recording ` + --confirm-10-second-telemetry-trial +``` + +Continue capturing after the process closes: + +1. verify the OLED displayed `SPEED / 0 KMH / GEAR / N`; +2. repeat the same small centering check; +3. briefly rev in neutral and confirm the shift LEDs still respond; +4. confirm inputs and simulator connection remain normal; +5. capture at least ten more seconds; +6. stop and save USBPcap without driving; +7. stop and save the video. + +## Acceptance Conditions + +Accept J1 only if all conditions pass: + +- the process exits successfully after the bounded trial; +- at least one stationary telemetry frame is acknowledged; +- OLED text matches the live stopped state; +- display traffic uses only the shared endpoint-0 HID++ route; +- no `0x8123 RESET_ALL -> SET_GLOBAL_GAINS -> RESET_ALL` lifecycle appears; +- legitimate TRUEFORCE and rev-light traffic remains continuous/equivalent; +- centering, rev LEDs, inputs, and simulator connection work afterward; +- there is no movement, torque impulse, unexpected resistance, or disconnect; +- the high-buffer capture contains the complete discovery/setter/ACK evidence. + +Any failure keeps moving-car telemetry and full-lap testing prohibited. + +J1 met the native, OLED, centering, LED, input, and physical-safety +conditions. It did not meet the complete-capture condition: offline analysis +found the `0x8130` discovery but no setter/ACK pair. Complete evidence and the +decision boundary are recorded in +[`evidence/RS50_BUILD_J_STATIONARY_TELEMETRY_RESULT_2026-07-29.md`](evidence/RS50_BUILD_J_STATIONARY_TELEMETRY_RESULT_2026-07-29.md). +Do not repeat J1 or advance to moving-car testing without a new reviewed +capture plan and fresh authorization. + +## Executed Physical J2 Evidence Trial + +J2 repeated the same stationary ten-second Build J execution only to close +the evidence gap. It did not expand the allowed telemetry, duration, rate, +layout, or physical state. + +J1 used a Wireshark-managed pcapng capture. This matches the previously +observed first Build I attempt, where dense TRUEFORCE traffic left +multi-second capture gaps. Build I's accepted attempt used USBPcapCMD +directly, a device-address filter, a 128 MiB buffer, and full snap length. +J2 must reuse that independently successful method. + +As long as the wheel has not been reconnected, the current RS50 USB device +address is `7` on `\\.\USBPcap1`. If it has been reconnected or Windows has +renumbered it, stop and rediscover the address before capturing. + +From a separate elevated PowerShell window at the repository root, start the +direct capture: + +```powershell +& "C:\Program Files\USBPcap\USBPcapCMD.exe" ` + -d "\\.\USBPcap1" ` + --devices 7 ` + --inject-descriptors ` + -b 134217728 ` + -s 65535 ` + -o ".\2026-07-29_rs50_build_j2_stationary_telemetry_direct.pcap" +``` + +Do not route the capture through the Wireshark GUI. Leave the USBPcapCMD +window running, record video, and collect at least ten seconds of stationary +baseline. Then obtain a new explicit authorization and run the exact +nine-argument Build J command once. + +The process must print the relative local transcript path under: + +```text +.tmp/rs50-build-j-transcripts/ +``` + +Continue the direct capture and video through the post-check, then stop +USBPcapCMD with `Ctrl+C`. Do not open or resave the raw `.pcap` in Wireshark +before offline analysis. + +J2 acceptance additionally requires: + +- transcript request/response pairs for discovery and Layout J; +- transcript response headers marked as exact matches; +- the direct PCAP contains the same request and response bytes; +- the direct PCAP contains no display transaction absent from the transcript; +- video again shows Test changing to `SPEED / 0 KMH / GEAR / N`; +- normal centering, RPM LEDs, inputs, and simulator connection afterward; +- no `0x8123` reset/gain/reset lifecycle or adverse physical effect. + +J2 met every additional acceptance condition: + +- the transcript contains exactly discovery and Layout J request/response + pairs, both marked as exact header matches; +- the direct PCAP contains the same request and response bytes; +- no display transaction is absent from the transcript; +- the OLED displayed and retained `SPEED / 0 KMH / GEAR / N`; +- the operator reported normal LEDs and FFB with no physical adverse effect; +- the PCAP contains no `RESET_ALL -> SET_GLOBAL_GAINS -> RESET_ALL` + lifecycle. + +One matched `0x8123 RESET_ALL` from SW-ID `0xE` occurred 212.265 seconds +after Build J's SW-ID-`0xA` setter. It was not accompanied by +`SET_GLOBAL_GAINS` or a second reset and is not attributable to Build J. + +The accepted evidence, hashes, exact frames, timing, and capture-stop note are +recorded in +[`evidence/RS50_BUILD_J2_STATIONARY_TELEMETRY_SUCCESS_2026-07-29.md`](evidence/RS50_BUILD_J2_STATIONARY_TELEMETRY_SUCCESS_2026-07-29.md). +The stationary telemetry gate is complete. A separately designed and +authorized bounded moving-car stage is still required before a full lap. diff --git a/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_K_DESIGN.md b/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_K_DESIGN.md new file mode 100644 index 0000000..29ff85a --- /dev/null +++ b/LogiDynamicDash/docs/RS50_SHARED_HIDPP_BUILD_K_DESIGN.md @@ -0,0 +1,123 @@ +# RS50 Shared HID++ Visual Layout Gallery Build K + +## Purpose and Status + +Build K is an offline-validated and physically accepted gallery for all ten +firmware-rendered Display Game Data layouts. K1 ran exactly once on +2026-07-28 and answered: + +- which layouts contain bars, gauges, fixed graphics, or decoration; +- which text rows use visibly different font sizes or styles; +- how the two normalized values in layouts C-E affect the graphics; +- whether A and B expose useful fixed telemetry presentations. + +It does not test an arbitrary framebuffer or unknown rim-module feature. + +## Fixed Sequence + +After one discovery, Build K shows each layout for exactly three seconds: + +| Layout | Fixed fields | +|---|---| +| A | no data | +| B | no data | +| C | value `128/255` | +| D | values `64/255`, `191/255`; text `LAYOUT D` | +| E | values `64/255`, `191/255`; texts `E1`, `LAYOUTE` | +| F | texts `F`, `123` | +| G | texts `G`, `456` | +| H | texts `LAYOUT H WIDE TEST`, `H SECOND` | +| I | four rows identifying Layout I | +| J | four rows identifying Layout J | + +All ten calls and all ten waits are spelled out in source. There is no loop, +retry, telemetry, caller-selected layout, caller text, caller value, raw HID, +DirectInput, FFB, LED operation, or subdevice request. + +## Arming Contract + +All arguments must appear exactly in this order: + +```text +--arm-rs50-shared-hidpp-layout-gallery +--confirm-ghub-closed +--confirm-iracing-closed +--confirm-rs50-awake +--confirm-dynamic-selected +--confirm-usbpcap-running +--confirm-video-recording +--confirm-ten-layouts-three-seconds-each +``` + +Run the offline source audit with: + +```powershell +.\scripts\Test-Rs50SharedHidppLayoutGallerySurface.ps1 +``` + +## Proposed K1 Physical Procedure + +K1 completed under a fresh operator authorization. This procedure is retained +as the exact historical protocol; do not repeat it without a new reason, +design review, and authorization. + +Files: + +```text +2026-07-28_rs50_build_k_layout_gallery.pcapng +2026-07-28_rs50_build_k_layout_gallery.mp4 +``` + +Preparation: + +1. close G HUB and iRacing; +2. connect and wake the RS50; +3. select Dynamic and confirm it shows the firmware Test fallback; +4. start a stable video recording focused tightly on the OLED; +5. start USBPcapCMD filtered to the RS50 address with a 128 MB buffer; +6. record at least ten seconds of baseline; +7. place the terminal where its `showing Layout X` lines are visible or read + each label aloud for the video. + +Run Build K exactly once. Do not touch the wheel, OLED settings, USB cable, or +other controls during the 30-second sequence. + +After completion: + +1. continue video and USB capture for at least ten seconds; +2. stop and save both files; +3. confirm there was no wheel movement, torque impulse, LED change, input loss, + disconnect, or unexpected resistance; +4. return to Test and Dynamic once to confirm normal firmware navigation; +5. report each layout using the observation table below. + +## Observation Table + +| Layout | Visible result | Bars/graphics | Text/font observations | +|---|---|---|---| +| A | blank OLED | none | none | +| B | firmware Test screen | four striped vertical bars `C/B/G/H` | fixed small labels | +| C | one horizontal gauge | 50% solid, remainder diagonally striped | none | +| D | text plus two indicators | 25% main gauge; 75% thin lower indicator | one 16 px text region | +| E | split text plus two indicators | same 25%/75% indicators | left/right 16 px regions | +| F | split numeric composition | fixed vertical separator | 27 px left; 37 px right | +| G | reversed numeric composition | fixed vertical separator | 37 px left; 27 px right | +| H | two-row composition | reserved left field blank in K1 | 9 px centered; 18 px right-aligned | +| I | four-row composition | two reserved left fields blank in K1 | alternating 9/18 px; mixed center/right alignment | +| J | four-row composition | none | alternating 9/18 px; all rows centered | + +## Acceptance + +Accept K1 only if: + +- exactly one discovery and ten function-3 setters receive exact ACKs; +- visible layouts progress A-J in order for three seconds each; +- the video is clear enough to compare graphics and typography; +- USBPcap contains no `0x8123`, `0x807A`, `0x807B`, MI_02, or subdevice host + operation attributable to Build K; +- the wheel remains physically normal throughout and after the trial. + +Any unexpected response or physical effect stops further testing. + +K1 met every acceptance condition. Complete evidence is in +[`evidence/RS50_BUILD_K_LAYOUT_GALLERY_SUCCESS_2026-07-28.md`](evidence/RS50_BUILD_K_LAYOUT_GALLERY_SUCCESS_2026-07-28.md). diff --git a/LogiDynamicDash/docs/RS50_STATIC_LAYOUT_J_OPERATOR_CHECKLIST.md b/LogiDynamicDash/docs/RS50_STATIC_LAYOUT_J_OPERATOR_CHECKLIST.md new file mode 100644 index 0000000..41fd206 --- /dev/null +++ b/LogiDynamicDash/docs/RS50_STATIC_LAYOUT_J_OPERATOR_CHECKLIST.md @@ -0,0 +1,86 @@ +# RS50 Static Layout J Setter Checklist + +This checklist governs exactly one Build C call. The payload is fixed: + +```text +LOGIDYNAMICDASH +RS50 +OLED LINK +TEST 1 +``` + +There is no live telemetry, caller-controlled text, loop, SetIdle, raw HID +write, or other layout setter. + +## 1. Preconditions + +1. Keep G HUB and its agent/updater processes closed. +2. Wake the RS50 and keep it awake for the entire capture. +3. On the wheel select `Settings -> HomeScreen -> Dynamic`. +4. Confirm Dynamic visibly shows the Test fallback. +5. Do not touch the wheel after this confirmation unless a safety stop is + required. + +Any unexpected torque, LED, OLED, or wheel-position behavior is an immediate +stop condition. + +## 2. Baseline + +1. Start a new USBPcap capture on the RS50 controller. +2. Record the current RS50 USB address. +3. Capture at least five seconds without running the bridge. +4. Stop and save it as: + +```text +2026-07-27_rs50_static_layout_j_baseline.pcapng +``` + +## 3. Setter Capture + +Start a fresh capture on the same controller. Confirm again that the RS50 is +awake and Dynamic shows Test. + +Do not run any earlier support query in this process. Execute exactly: + +```powershell +.\artifacts\native\Release\Rs50DirectInputQuery.exe ` + --set-static-layout-j ` + --confirm-standard-data-format ` + --confirm-exclusive-acquire ` + --confirm-layout-j-static-text ` + --confirm-transmit-static-setter +``` + +Run it once only. Do not retry in the same capture, regardless of result. + +## 4. Observe + +For ten seconds after the call: + +1. Read the complete console result without launching another command. +2. Observe whether the four fixed strings replace the Test fallback. +3. Observe LEDs, torque, and wheel position. +4. Do not use SetIdle or G HUB to clean up. +5. Stop and save the capture as: + +```text +2026-07-27_rs50_static_layout_j_attempt_1.pcapng +``` + +Report the console output and exact physical result, including whether the +text persisted, disappeared, or changed after a wheel button press. + +## 5. Stop Conditions + +Stop without retrying if: + +- any HRESULT is nonzero; +- product identity is not VID `046D`, PID `C276`; +- inner command is not `22`; +- the wheel moves, torque changes, or LEDs behave unexpectedly; +- USB analysis does not show the expected `0x8130` function-`3` transaction; +- the device response is unmatched or reports an error. + +Only offline capture analysis may determine the next step. A visible static +result is required before any live telemetry API or repeated update loop is +implemented. diff --git a/LogiDynamicDash/docs/evidence/RS50_BUILD_G_PREFLIGHT_CAPTURE_2026-07-28.md b/LogiDynamicDash/docs/evidence/RS50_BUILD_G_PREFLIGHT_CAPTURE_2026-07-28.md new file mode 100644 index 0000000..5fb22fc --- /dev/null +++ b/LogiDynamicDash/docs/evidence/RS50_BUILD_G_PREFLIGHT_CAPTURE_2026-07-28.md @@ -0,0 +1,43 @@ +# RS50 Build G Preflight Capture + +## Outcome + +- Date: 2026-07-28 +- Capture: `2026-07-28_rs50_build_g_shared_hidpp_oneshot.pcapng` +- RS50 USB address: `1.3` +- Executable result: failed closed before stream open +- Operator-observed physical changes: none + +Build G rejected COL01/COL03 because their Windows device paths had different +instance segments. Descriptor enumeration occurred, but neither collection was +opened. + +## Offline USBPcap Result + +The capture contains: + +- 10,441 device-to-host reports; +- zero host-to-device reports carrying `usb.data_fragment`, `usb.capdata`, or + `usbhid.data`; +- no Root discovery request; +- no Layout J setter; +- no `0x8123`, `0x807A`, or `0x807B` operation. + +This independently supports the code-path result: the preflight failed before +the first HID write. + +## Design Correction + +Windows top-level HID collections cannot be proven to belong to the same +physical device by replacing `col01`/`col03` and comparing the remaining device +path. Their instance segments may legitimately differ. + +The corrected gate still requires: + +- exact VID `0x046D` and PID `0xC276`; +- exactly one `mi_01&col01` and one `mi_01&col03`; +- exactly one expected usage on each collection; +- exact input/output report lengths. + +A second attached RS50 would create duplicate qualifying collections, causing +the adapter to reject before opening either stream. diff --git a/LogiDynamicDash/docs/evidence/RS50_BUILD_G_SHARED_HIDPP_SUCCESS_2026-07-28.md b/LogiDynamicDash/docs/evidence/RS50_BUILD_G_SHARED_HIDPP_SUCCESS_2026-07-28.md new file mode 100644 index 0000000..4b8a88e --- /dev/null +++ b/LogiDynamicDash/docs/evidence/RS50_BUILD_G_SHARED_HIDPP_SUCCESS_2026-07-28.md @@ -0,0 +1,63 @@ +# RS50 Build G Shared HID++ Success + +## Physical Result + +- Date: 2026-07-28 +- Capture: + `2026-07-28_rs50_build_g_shared_hidpp_oneshot_attempt2.pcapng` +- RS50 USB address: `1.3` +- G HUB: closed +- iRacing: closed +- OLED mode: Dynamic +- Process exit: success + +The RS50 displayed exactly: + +```text +RS50 SHARED HIDPP +BUILD G +ONE SHOT ONLY +USBPCAP +``` + +The operator observed no unexpected OLED behavior, LED change, FFB or torque +change, or wheel movement. + +## Exact USB Sequence + +| Frame | Epoch | Direction | Endpoint | Payload | +|---|---:|---|---:|---| +| 14077 | 1785224168.473669 | host → RS50 | `0x00` | `10ff000a813000` | +| 14079 | 1785224168.476329 | RS50 → host | `0x82` | `12ff000a1200...` | +| 14081 | 1785224168.478073 | host → RS50 | `0x00` | fixed Layout J `12ff123a09...` | +| 14083 | 1785224168.480287 | RS50 → host | `0x82` | `12ff123a0000...` | + +Both host outputs are `URB_FUNCTION_CLASS_INTERFACE`, endpoint-0 +`URB_CONTROL`, HID `SET_REPORT`, interface `1`: + +- discovery: output report ID `0x10`, length 7; +- Layout J: output report ID `0x12`, length 64. + +The discovery response validates runtime feature index `0x12`, flags `0`, and +version `0`. The Layout J response is the exact zero-body acknowledgement. + +## Timing + +- discovery round trip: 2.660 ms; +- Layout J round trip: 2.214 ms; +- complete discovery-through-acknowledgement sequence: 6.618 ms. + +## Forbidden-Traffic Check + +The capture contains exactly two host reports carrying HID data: the discovery +and fixed Layout J setter above. It therefore contains: + +- zero `0x8123` FFB operations; +- zero `0x807A` or `0x807B` LED/LIGHTSYNC operations; +- zero MI_02 / endpoint-`0x03` host output; +- no DirectInput Acquire/Unacquire lifecycle. + +This validates the shared HID++ route as a physically functional alternative +to exclusive DirectInput for one RS50 OLED frame. It does not yet validate a +repeated stationary stream, simulator coexistence, moving-car use, or a full +lap. diff --git a/LogiDynamicDash/docs/evidence/RS50_BUILD_H_BOUNDED_STREAM_SUCCESS_2026-07-28.md b/LogiDynamicDash/docs/evidence/RS50_BUILD_H_BOUNDED_STREAM_SUCCESS_2026-07-28.md new file mode 100644 index 0000000..b018852 --- /dev/null +++ b/LogiDynamicDash/docs/evidence/RS50_BUILD_H_BOUNDED_STREAM_SUCCESS_2026-07-28.md @@ -0,0 +1,70 @@ +# RS50 Build H Bounded Stream Success + +## Physical Result + +- Date: 2026-07-28 +- Capture: `2026-07-28_rs50_build_h_1hz_five_frames.pcapng` +- RS50 USB address: `1.3` +- G HUB: closed +- iRacing: closed +- OLED mode: Dynamic +- Process exit: success + +The OLED visibly advanced in order from `FRAME 1 OF 5` through +`FRAME 5 OF 5`. The operator observed no LED, FFB, torque, or wheel-position +change. + +## Exact Traffic + +The capture contains exactly six host reports carrying HID data: + +| Frame | Epoch | Operation | +|---:|---:|---| +| 7 | 1785225318.036998 | Root discovery of `0x8130` | +| 11 | 1785225318.046539 | Layout J `FRAME 1 OF 5` | +| 15 | 1785225319.051380 | Layout J `FRAME 2 OF 5` | +| 19 | 1785225320.054977 | Layout J `FRAME 3 OF 5` | +| 23 | 1785225321.057969 | Layout J `FRAME 4 OF 5` | +| 27 | 1785225322.061663 | Layout J `FRAME 5 OF 5` | + +Every host output used: + +- `URB_FUNCTION_CLASS_INTERFACE`; +- endpoint `0x00`, direction OUT; +- `URB_CONTROL`; +- HID `SET_REPORT`; +- interface `1`. + +The discovery used output report `0x10`, length 7. Every Layout J setter used +output report `0x12`, length 64. + +COL03 returned exactly six matching reports: + +- frame 9: public feature `0x8130`, runtime index `0x12`, version 0; +- frames 13, 17, 21, 25, and 28: exact zero-body Layout J acknowledgements. + +## Rate + +The measured intervals between consecutive setters were: + +- 1.004841 seconds; +- 1.003597 seconds; +- 1.002992 seconds; +- 1.003694 seconds. + +Every interval was at least one second. The bounded stream therefore remained +at or below its authorized 1 Hz rate. + +## Forbidden-Traffic Check + +Because the six expected operations are the only host reports carrying HID +data, the capture contains: + +- zero `0x8123` FFB operations; +- zero `0x807A` or `0x807B` LED/LIGHTSYNC operations; +- zero MI_02 / endpoint-`0x03` host output; +- no DirectInput Acquire/Unacquire lifecycle. + +This validates repeated shared-HID++ OLED writes with the simulator closed. It +does not validate simulator coexistence, telemetry input, moving-car use, or a +full lap. diff --git a/LogiDynamicDash/docs/evidence/RS50_BUILD_I_IRACING_COEXISTENCE_SUCCESS_2026-07-28.md b/LogiDynamicDash/docs/evidence/RS50_BUILD_I_IRACING_COEXISTENCE_SUCCESS_2026-07-28.md new file mode 100644 index 0000000..1b98b77 --- /dev/null +++ b/LogiDynamicDash/docs/evidence/RS50_BUILD_I_IRACING_COEXISTENCE_SUCCESS_2026-07-28.md @@ -0,0 +1,99 @@ +# RS50 Build I iRacing Coexistence Success + +## Physical Result + +- Date: 2026-07-28 +- Accepted capture: + `2026-07-28_rs50_build_i_iracing_stationary_coexistence_attempt2.pcapng` +- RS50 USB address: `1.3` +- Capture method: USBPcapCMD, device filter `3`, 128 MB buffer +- G HUB: closed +- iRacing: running +- Car: stationary in pits, neutral, brake held +- Process exit: success + +The OLED visibly advanced from `FRAME 1 OF 5` through `FRAME 5 OF 5`. +Before and after Build I, the operator confirmed: + +- normal centering/FFB after a small steering displacement; +- normal shift-LED response during a brief neutral rev. + +There was no unexpected movement, torque impulse, input loss, LED failure, FFB +change, or simulator disconnect. + +## Capture Quality + +The first Wireshark-managed capture recorded only frames 2 and 3 because dense +TRUEFORCE traffic produced multi-second capture gaps. Its physical outcome was +positive, but it was rejected as protocol evidence. + +Attempt 2 used direct USBPcapCMD capture with an address-3 filter and 128 MB +buffer. The resulting pcapng contains 820,863 packets over 137.419 seconds +without gaps in the Build I/FFB validation window. + +## Exact Display Sequence + +| Frame | Epoch | Operation | +|---:|---:|---| +| 468390 | 1785226923.923343 | Root discovery of `0x8130` | +| 468426 | 1785226923.929201 | Layout J `FRAME 1 OF 5` | +| 474475 | 1785226924.942312 | Layout J `FRAME 2 OF 5` | +| 480509 | 1785226925.952897 | Layout J `FRAME 3 OF 5` | +| 486527 | 1785226926.957416 | Layout J `FRAME 4 OF 5` | +| 492566 | 1785226927.964961 | Layout J `FRAME 5 OF 5` | + +COL03 returned: + +- frame 468408: public `0x8130`, runtime index `0x12`, version 0; +- frames 468502, 474534, 480526, 486543, and 492583: exact zero-body + acknowledgements. + +Every display output used interface 1, endpoint 0, `URB_CONTROL`, and HID +`SET_REPORT`. + +## Timing + +Setter intervals: + +- 1.013111 seconds; +- 1.010585 seconds; +- 1.004519 seconds; +- 1.007545 seconds. + +Request-to-response times: + +- discovery: 2.954 ms; +- frame 1: 12.096 ms; +- frame 2: 9.143 ms; +- frame 3: 2.719 ms; +- frame 4: 2.358 ms; +- frame 5: 2.973 ms. + +## FFB and LED Coexistence + +The complete capture has zero endpoint-0 control reports addressed to runtime +index `0x10`, previously mapped to feature `0x8123` Force Feedback. Therefore +there is no `RESET_ALL`, `SET_GLOBAL_GAINS`, or DirectInput exclusive-lifecycle +sequence. + +Host submissions to real-time TRUEFORCE endpoint `0x03` remained continuous in +every one-second bucket across a 25-second Build I window: + +- minimum: 990 transfers/s; +- maximum: 1000 transfers/s; +- total: 24,920 transfers. + +Runtime-`0x0B` rev-light operations were captured during both manual checks: + +- pre-check: epochs 1785226890–1785226892; +- post-check: epochs 1785226937–1785226938. + +During the Build I sequence itself, the only endpoint-0 control reports were +the one display discovery and five display setters. + +## Conclusion + +Repeated shared-HID++ OLED writes coexist with iRacing's stationary FFB and +rev-light ownership without the destructive DirectInput lifecycle. This +validates designing a separately bounded stationary telemetry stage. It does +not yet authorize moving-car use or a full lap. diff --git a/LogiDynamicDash/docs/evidence/RS50_BUILD_J2_STATIONARY_TELEMETRY_SUCCESS_2026-07-29.md b/LogiDynamicDash/docs/evidence/RS50_BUILD_J2_STATIONARY_TELEMETRY_SUCCESS_2026-07-29.md new file mode 100644 index 0000000..c5f1ebb --- /dev/null +++ b/LogiDynamicDash/docs/evidence/RS50_BUILD_J2_STATIONARY_TELEMETRY_SUCCESS_2026-07-29.md @@ -0,0 +1,147 @@ +# RS50 Build J2 Stationary Telemetry Success + +## Classification + +- Physical OLED and coexistence result: **confirmed success** +- Native process result: **confirmed success** +- Host transcript evidence: **confirmed** +- Independent USB transaction evidence: **confirmed** +- Stationary telemetry gate: **complete** +- Moving-car or full-lap authorization: **not granted by this result alone** + +Build J2 ran exactly once on 2026-07-29 under a fresh operator authorization. +G HUB was closed, iRacing was running, the car was stopped in the pits in +neutral with the brake held, the RS50 was awake, Dynamic showed the firmware +Test fallback, and direct USBPcapCMD capture plus video were active. + +## Local Evidence + +The raw artifacts remain local and ignored: + +| Artifact | Size | SHA-256 | +|---|---:|---| +| `2026-07-29_rs50_build_j2_stationary_telemetry_direct.pcap` | 215,941,404 bytes | `37F3F7D348A3DDA75B4F389776586E38179F2F472662DF9E53C5739461261922` | +| `.tmp/rs50-build-j-transcripts/rs50-build-j-transcript-20260729-081420-1587314Z.jsonl` | 1,144 bytes | `00352CC5CE7B201E73FDA2219A617AC0AC44490014B2C762F9A425DD3CD05E60` | +| `IMG_2847.MOV` | 40,328,228 bytes | `B7D951C874B9CCB535EBE007AC1C907273D25A685449F8F190433DDB3BD470CD` | + +The MOV contains private phone, device, and location metadata. It and the raw +PCAP must not be committed or uploaded. + +## Native and Physical Result + +The separately compiled process reported: + +```text +Build J local transcript: .tmp/rs50-build-j-transcripts\rs50-build-j-transcript-20260729-081420-1587314Z.jsonl +Build J completed: 1 stationary telemetry frame(s) were acknowledged and the HID streams were closed. +``` + +The OLED displayed the live Layout J frame: + +```text +SPEED +0 KMH +GEAR +N +``` + +The video confirms that this frame remained stable. Its opening already shows +the telemetry frame, so the video alone does not establish the precise +Test-to-telemetry transition. The operator reported no physical change or +disconnect and confirmed normal LEDs and FFB after the trial. + +## Exact Transcript + +The bounded write-through transcript contains exactly four events: + +1. Root discovery request: + `10FF000A813000` +2. Root discovery response, exact header match: + `12FF000A1200` followed by zero padding +3. Layout J setter: + `12FF123A095350454544000000000000000000000000000030204B4D480000000000474541520000000000000000000000000000004E00000000000000000000` +4. Layout J response, exact header match: + `12FF123A00` followed by zero padding + +The setter decodes to layout ID `9`, then the four wire fields +`SPEED`, `0 KMH`, `GEAR`, and `N`. Transcript exchange timings were 8,268 +microseconds for discovery and 2,726 microseconds for the setter. + +The request timestamp is recorded immediately before entering the physical +exchange, not at the USB bus. This is visible on the first transaction, where +stream startup precedes the bus request. The setter timestamps differ from +the corresponding bus timestamps by less than 0.6 ms. + +## Independent Direct USB Capture + +USBPcapCMD captured the RS50 address directly with a 128 MiB buffer and full +65,535-byte snap length. `capinfos` validated the resulting file: + +- capture duration: 534.559233 seconds; +- 3,121,328 packets; +- 166,000,132 captured data bytes; +- strict time order: true; +- one USBPcap interface. + +The complete decoder reconstructed 1,048,412 HID reports: + +- 2,421 host reports; +- 1,045,991 device reports; +- 2,421 exact matched HID++ response headers; +- zero unmatched host requests; +- zero invalid records. + +The relevant bus frames are: + +| Frame | Epoch | Operation | +|---:|---:|---| +| 1,807,248 | 1785312860.413341 | Root discovery request | +| 1,807,265 | 1785312860.415635 | Root discovery response | +| 1,808,610 | 1785312860.640016 | Layout J setter | +| 1,808,627 | 1785312860.642658 | Layout J response | + +Bus request-to-response latency was 2.294 ms for discovery and 2.642 ms for +the setter. Every byte in both PCAP request/response pairs matches the local +transcript. The PCAP contains exactly one operational `0x8130` request and no +display transaction absent from the transcript. + +USBPcapCMD did not stop on the operator's initial interrupt. Its exact capture +process tree was terminated, after which no USBPcapCMD process remained. +`capinfos` then read the file successfully and confirmed the packet count, +timestamps, full snap length, strict ordering, and hashes above. The OLED +transaction occurred more than three minutes before the capture ended, so +termination of the capture tail does not affect the accepted transaction. + +## FFB and LED Coexistence + +The capture does not contain the destructive DirectInput lifecycle: + +```text +RESET_ALL -> SET_GLOBAL_GAINS(0xFFFF) -> RESET_ALL +``` + +It does contain one matched `0x8123 RESET_ALL` operation: + +- request frame 3,080,866 at epoch `1785313072.904763`; +- request bytes `10FF101E000000`; +- response frame 3,080,881 at epoch `1785313072.907045`; +- response bytes `12FF101E` followed by zero padding. + +This isolated request used SW-ID `0xE`, not Build J's SW-ID `0xA`, and occurred +212.265 seconds after the Layout J setter. There was no `SET_GLOBAL_GAINS` +operation and no second reset. It is therefore unrelated to the Build J +transaction and is not the exclusive DirectInput lifecycle previously shown +to disrupt iRacing. Normal real-time RS50 traffic continued, and the operator +confirmed normal LEDs and FFB. + +## Decision + +J2 closes J1's missing-capture gap. The live stationary telemetry frame is +independently supported by the physical observation, exact bounded host +transcript, and byte-identical USB request/response pairs. + +This result completes the stationary telemetry gate. It supports designing a +new bounded moving-car stage, but it does not itself authorize a full lap. +The next physical stage must define a short duration, low speed, fixed update +rate, explicit stop conditions, and fresh operator authorization before any +moving-car execution. diff --git a/LogiDynamicDash/docs/evidence/RS50_BUILD_J_STATIONARY_TELEMETRY_RESULT_2026-07-29.md b/LogiDynamicDash/docs/evidence/RS50_BUILD_J_STATIONARY_TELEMETRY_RESULT_2026-07-29.md new file mode 100644 index 0000000..9e5f370 --- /dev/null +++ b/LogiDynamicDash/docs/evidence/RS50_BUILD_J_STATIONARY_TELEMETRY_RESULT_2026-07-29.md @@ -0,0 +1,102 @@ +# RS50 Build J Stationary Telemetry Result + +## Classification + +- Physical OLED and coexistence result: **confirmed success** +- Native process result: **confirmed success** +- Complete USB transaction evidence: **inconclusive** +- Moving-car or full-lap authorization: **not granted** + +Build J ran exactly once on 2026-07-29 under a separate operator +authorization. G HUB was closed, iRacing was running, the car was stopped in +the pits in neutral with the brake held, the RS50 was awake, Dynamic showed +the firmware Test fallback, and video plus USBPcap were active. + +## Local Evidence + +- PCAP: + `2026-07-29_rs50_build_j_iracing_stationary_telemetry_10s.pcapng` +- PCAP size: 77,120,020 bytes +- PCAP SHA-256: + `9E03A2187204FC546C1F58B4B2B492CE53E3FBEF02F3B9AA51F8C5324C75FA53` +- Video: `IMG_2843.MOV` +- Video size: 90,779,705 bytes +- Video SHA-256: + `A97F7A2A86CEEA284E1826E9A13DEF7C1534E84D62D162570D69CBFA695D20A7` + +Both files remain local and ignored. The MOV contains private phone, +device, and location metadata and must not be committed or uploaded. + +## Native and Physical Result + +The separately compiled process reported: + +```text +Build J completed: 1 stationary telemetry frame(s) were acknowledged and the HID streams were closed. +``` + +The 51.65-second video begins with the normal four-bar `C/B/G/H` Test +fallback. Between approximately 2.25 and 2.50 seconds, the OLED changes to +the expected live Layout J frame: + +```text +SPEED +0 KMH +GEAR +N +``` + +It remains stable for the trial. This independently confirms that the +connected iRacing telemetry source supplied the stopped state and that the +shared HID++ route activated the intended OLED layout. + +The operator reported no movement, torque impulse, unexpected resistance, +input loss, or disconnect. Afterward, a small steering displacement returned +normally to center and a brief neutral rev produced normal RPM LED behavior. + +## USB Capture Result + +The PCAP spans 2026-07-29 01:19:04.125116 through +01:22:23.286784 and contains: + +- 869,037 USB packets; +- 48,265,516 captured data bytes; +- 261,595 reconstructed HID reports; +- 227 host and 261,368 device reports; +- continuous normal RS50 input and iRacing-related traffic; +- one Root discovery of public feature `0x8130` at runtime index `0x12`; +- no `0x8123` reset/gain/reset lifecycle or other host feature operation + attributable to Build J. + +The discovery request is at frame 188911 and its exact response is at frame +188922, an approximately 2.034 ms latency. + +However, exhaustive searches of reconstructed reports, raw control data, +interrupt data, and the literal `SPEED`/`GEAR` payload found no Layout J +setter and no matching function-3 acknowledgement. The cause is unknown. +Possible capture loss or a transfer-visibility gap is not established because +the PCAP contains no interface statistics with a dropped-packet count. + +The process result and video prove that a request reached the wheel and +changed the OLED. They do not permit inventing missing USB evidence. +Therefore the strict requirement for a complete setter/ACK pair is not met +by this capture. + +## Decision + +J1 proves the physical stationary telemetry path and its safe coexistence in +this session. It does not provide the complete USB audit trail required to +advance directly to moving-car or full-lap testing. + +Do not repeat J1 merely to obtain a cleaner capture. Before any new physical +run, add host-side timestamped request/response evidence and determine a +capture method that can independently retain the known 64-byte transaction. +Any repetition requires a new design review and a fresh operator +authorization. + +The host-side bounded JSONL transcript was subsequently implemented and +validated offline. J2 subsequently used it on hardware and closed the missing +USB evidence gap with a byte-identical direct capture. J1's classification +above remains unchanged; see +[`RS50_BUILD_J2_STATIONARY_TELEMETRY_SUCCESS_2026-07-29.md`](RS50_BUILD_J2_STATIONARY_TELEMETRY_SUCCESS_2026-07-29.md) +for the accepted stationary-gate result. diff --git a/LogiDynamicDash/docs/evidence/RS50_BUILD_K_LAYOUT_GALLERY_SUCCESS_2026-07-28.md b/LogiDynamicDash/docs/evidence/RS50_BUILD_K_LAYOUT_GALLERY_SUCCESS_2026-07-28.md new file mode 100644 index 0000000..8bb03ec --- /dev/null +++ b/LogiDynamicDash/docs/evidence/RS50_BUILD_K_LAYOUT_GALLERY_SUCCESS_2026-07-28.md @@ -0,0 +1,132 @@ +# RS50 Build K OLED Layout Gallery Success + +## Session + +- Date: 2026-07-28 +- Device: Logitech RS50, VID `0x046D`, PID `0xC276` +- Initial OLED state: Dynamic showing the firmware Test fallback +- G HUB: closed +- iRacing: closed +- Execution: one separately authorized Build K run +- Physical transport: shared HID++ MI_01 COL01/COL03 +- PCAP: `2026-07-28_rs50_build_k_layout_gallery.pcapng` +- PCAP size: 1,518,484 bytes +- PCAP SHA-256: + `1BC5F3A79BF986A47E8171803552011DF0212B55A64E129D988552E7E094CF7F` +- Video: `gallery-video.mov` +- Video size: 173,675,930 bytes +- Video SHA-256: + `F018A5626132FA2656D853EB65BCFF7B9F44B6062082087ADD8463815AA41A90` + +The raw captures remain local and must not be committed. The phone video +contains private device/location metadata and must not be uploaded without +first creating a metadata-stripped derivative. + +## Operator Observation + +The operator confirmed: + +- all ten layouts visibly advanced in A-J order; +- bars, graphics, and text sizes changed between layouts; +- there was no wheel movement, torque impulse, unexpected resistance, LED + change, input loss, or disconnect. + +The opening video frames show four striped vertical bars labelled +`C`, `B`, `G`, and `H`. This is the normal firmware Test fallback that was +already visible before Build K; it is not Layout A output. Layout B later +reproduced that same Test composition. + +## USB Evidence + +The complete PCAP contains 19,966 USB frames. Offline extraction reconstructed +9,980 HID reports: + +| Direction/report | Count | +|---|---:| +| HOST `0x10` | 1 | +| HOST `0x12` | 10 | +| DEVICE `0x08` | 9,958 | +| DEVICE `0x12` | 11 | + +The only host HID reports were: + +1. one Root discovery of public feature `0x8130`; +2. ten canonical feature-`0x8130`, function-`3` setters for Layouts A-J. + +All eleven host requests had an exact response-header match. There were no +unmatched host requests and no additional host HID reports. + +| Operation | Host frame | ACK frame | ACK latency | +|---|---:|---:|---:| +| Discovery | 19923 | 19925 | 2.351 ms | +| Layout A | 19927 | 19929 | 2.210 ms | +| Layout B | 19931 | 19933 | 2.394 ms | +| Layout C | 19935 | 19937 | 2.799 ms | +| Layout D | 19939 | 19941 | 2.769 ms | +| Layout E | 19943 | 19945 | 2.906 ms | +| Layout F | 19947 | 19949 | 2.388 ms | +| Layout G | 19951 | 19953 | 2.353 ms | +| Layout H | 19955 | 19957 | 2.546 ms | +| Layout I | 19959 | 19961 | 3.069 ms | +| Layout J | 19963 | 19965 | 2.358 ms | + +Setter intervals were 3.005647-3.019032 seconds. Because the complete host +set consists only of the eleven transactions above, Build K produced zero +`0x8123`, `0x807A`, `0x807B`, MI_02, subdevice, FFB, LED, or unknown-feature +host operations. + +## Video Evidence + +The local MOV is 58.65 seconds at 1920 x 1080 and approximately 59.95 fps, +with a 90-degree display rotation. Representative stable frames identify the +layouts unambiguously: + +| Layout | Approx. video time | Observed output | +|---|---:|---| +| Baseline Test | 0.5 s | four striped vertical bars `C/B/G/H` | +| A | 11.5 s | completely blank/black OLED; panel power state unknown | +| B | 14.5 s | the same four-bar `C/B/G/H` Test composition | +| C | 17.5 s | one horizontal striped gauge, solid to about 50% | +| D | 20.5 s | `LAYOUT D`, a 25% main gauge, and a 75% thin indicator | +| E | 23.5 s | `LAYOUTE` left, `E1` right, plus the same two indicators | +| F | 26.5 s | medium `F`, separator, very large `123` | +| G | 29.5 s | very large `G`, separator, medium `456` | +| H | 32.5 s | small centered first row and larger second row | +| I | 35.5 s | four rows; rows 1/3 small and rows 2/4 larger/right-aligned | +| J | 38.5 s | four centered rows; rows 1/3 small and rows 2/4 larger | + +Layouts C-E physically confirm the statically recovered normalized gauge +mapping. The unfilled portion of the main gauge uses a fixed diagonal-stripe +background; the supplied value replaces it from left to right with a solid +fill. D/E's second value controls the separate thin lower indicator. + +Layouts F/G physically confirm that font size is selected by layout: the same +one-character/three-character field shape swaps the 27 px and 37 px firmware +fonts. H/I/J physically confirm the 9 px and 18 px text hierarchy and their +fixed alignment rules. + +No arbitrary icon, bitmap, host-selected font, or host-selected coordinate was +observed. Layout B's Test bars are firmware-owned and data-free. Reserved +left-side fields in H/I were blank in this stationary session; no additional +decoration should be claimed without a separate legitimate state correlation. + +Layout A accepts no function-3 data after its layout index, and its renderer +produced a completely black frame. It is therefore a blank layout, not a +host-drawable canvas through feature `0x8130`. The video cannot establish +whether the OLED controller or panel power was disabled, so "electrically +off" is not claimed. + +## Accepted Conclusion + +K1 passed all Build K acceptance criteria: + +- one discovery and ten setters, all exactly acknowledged; +- visible A-J progression in order; +- clear visual evidence for gauges and five layout-selected font sizes; +- no unrelated host operation; +- no adverse physical behavior reported. + +For the confirmed Dynamic interface, A-J are a fixed firmware-rendered visual +vocabulary, not a raw framebuffer API. LogiDynamicDash can choose a layout and +update its bounded values/text. It cannot use feature `0x8130` to choose an +arbitrary typeface, font size, coordinate, color, bitmap, or drawing command. diff --git a/LogiDynamicDash/docs/evidence/RS50_DIRECTINPUT_QUERY_BUILD_A2_2026-07-27.md b/LogiDynamicDash/docs/evidence/RS50_DIRECTINPUT_QUERY_BUILD_A2_2026-07-27.md new file mode 100644 index 0000000..d08cf8e --- /dev/null +++ b/LogiDynamicDash/docs/evidence/RS50_DIRECTINPUT_QUERY_BUILD_A2_2026-07-27.md @@ -0,0 +1,65 @@ +# RS50 DirectInput Support Query Build A2 Result + +## Scope + +Build A2 preserved the sole documented display-support request from Build A +and added the DirectInput condition proven by +`DIERR_NOTEXCLUSIVEACQUIRED`: + +- visible process-owned foreground window; +- `DISCL_EXCLUSIVE | DISCL_FOREGROUND`; +- one `Acquire` attempt; +- one Escape only if acquisition succeeded; +- immediate `Unacquire` after Escape; +- no layout query, setter, effect, raw HID call, or retry. + +## Exact result + +```text +Query call started UTC: 2026-07-28T04:18:02.323Z +Query call completed UTC: 2026-07-28T04:18:02.323Z +Product: Logitech G HUB RS50 (USB) +VID: 0x46d PID: 0xc276 +Acquired: 0 +Cooperative HRESULT: 0x0 +Acquire HRESULT: 0x80070057 +Escape HRESULT: 0x8000ffff +Unacquire HRESULT: 0x8000ffff +Output capacity: 1 -> 1 +Output byte: 0xa5 +Supported: 0 +Query failed: Exclusive foreground device acquisition failed +``` + +`0x80070057` is `E_INVALIDARG`, returned by DirectInput as +`DIERR_INVALIDPARAM`. Microsoft documents that a data format must be set with +`SetDataFormat` or `SetActionMap` before `Acquire`, even when the application +does not intend to read input state: +[`IDirectInputDevice8::Acquire`](https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ee417818%28v%3Dvs.85%29). + +The `Escape` and `Unacquire` fields remain their initialized +`E_UNEXPECTED` values because acquisition never succeeded. The unchanged +sentinel is not a negative device-support answer. + +## Capture analysis + +The RS50 remained USB device address `3`. The saved query capture spans +`04:17:34.830862Z` through `04:18:02.320021Z`; its final packet precedes the +failed Acquire by approximately 3 ms. The offline analysis found: + +```text +Candidate HID++ reports: 0 +Candidate invalid lines: 0 +Candidate non-HID++ reports ignored: 0 +Positive exact-report count deltas: + (none) +``` + +Therefore Build A2 produced no host transport request. + +## Decision + +Build A2 is retired and must not be repeated. Build A3 adds only +`SetDataFormat(&c_dfDIJoystick2)` before the exclusive acquisition. It records +the data-format HRESULT separately and preserves the one-query, immediate +release, no-setter boundary. diff --git a/LogiDynamicDash/docs/evidence/RS50_DIRECTINPUT_QUERY_BUILD_A3_SUCCESS_2026-07-27.md b/LogiDynamicDash/docs/evidence/RS50_DIRECTINPUT_QUERY_BUILD_A3_SUCCESS_2026-07-27.md new file mode 100644 index 0000000..4cf714a --- /dev/null +++ b/LogiDynamicDash/docs/evidence/RS50_DIRECTINPUT_QUERY_BUILD_A3_SUCCESS_2026-07-27.md @@ -0,0 +1,85 @@ +# RS50 DirectInput Support Query Build A3 Success + +## Result + +Build A3 completed the guarded general-display-support query successfully: + +```text +Query call started UTC: 2026-07-28T04:23:44.922Z +Query call completed UTC: 2026-07-28T04:23:45.039Z +Product: Logitech G HUB RS50 (USB) +VID: 0x46d PID: 0xc276 +Acquired: 1 +Cooperative HRESULT: 0x0 +Data format HRESULT: 0x0 +Acquire HRESULT: 0x0 +Escape HRESULT: 0x0 +Unacquire HRESULT: 0x0 +Output capacity: 1 -> 1 +Output byte: 0x1 +Supported: 1 +``` + +The operator observed no change to the OLED, torque, LEDs, or wheel position. +Dynamic continued to show its normal Test fallback. + +The query contained no layout request, display setter, force-feedback effect, +raw HID call, input-state read, or retry. + +## USB evidence + +The RS50 remained USB device address `3`. The saved query capture contains 30 +HID++ reports: + +```text +Directions: HOST 15, DEVICE 15, UNKNOWN 0 +Exact HID++ response headers: 15 +HOST HID++ requests without an exact header match: 0 +``` + +Matched Root feature discovery produced: + +```text +runtime 0x0B -> feature 0x807A RPM Indicator +runtime 0x10 -> feature 0x8123 Force Feedback +runtime 0x12 -> feature 0x8130 Display Game Data +``` + +The `0x8130` discovery request and response were: + +```text +HOST 10 ff 00 0e 81 30 00 +DEVICE 12 ff 00 0e 12 00 00 ... +``` + +The response assigns runtime index `0x12`, public flags `0x00`, version `0`. +No operational request targeted runtime `0x12`. + +The capture also contains three HOST operations on runtime `0x10`, public +feature `0x8123` Force Feedback. They surround the exclusive +Acquire/Unacquire lifecycle. This association is an inference from timing and +feature identity; it is not display traffic. + +## Interpretation + +The general support command returns true after discovering the public +`Display Game Data` feature. It does not render or transmit a layout. This +explains why the OLED did not change and provides a safe gate for the next +non-setter stage. + +The result proves: + +- the installed Logitech DirectInput driver accepts outer command `4`; +- the RS50 reports general Dynamic Display support; +- public feature `0x8130` is present at runtime `0x12` in this device session; +- the query and bounded exclusive lifecycle were physically non-mutating. + +It does not yet prove: + +- Layout J capability or its returned descriptor; +- that a Layout J setter activates Dynamic; +- live telemetry rendering. + +The next physical stage may query only Layout J capability command `12` after +separate code review, capture preparation, and explicit authorization. No +setter is authorized by this result. diff --git a/LogiDynamicDash/docs/evidence/RS50_DIRECTINPUT_QUERY_BUILD_A_2026-07-27.md b/LogiDynamicDash/docs/evidence/RS50_DIRECTINPUT_QUERY_BUILD_A_2026-07-27.md new file mode 100644 index 0000000..57b37c9 --- /dev/null +++ b/LogiDynamicDash/docs/evidence/RS50_DIRECTINPUT_QUERY_BUILD_A_2026-07-27.md @@ -0,0 +1,75 @@ +# RS50 DirectInput Support Query Build A Result + +## Scope + +Build A attempted exactly one documented general-display-support query: + +- DirectInput device VID `046D`, PID `C276`; +- outer `DIEFFESCAPE.dwCommand = 4`; +- inner command `2`; +- version `1`; +- 12-byte input and one-byte output initialized to sentinel `0xA5`; +- nonexclusive/background cooperative level; +- no acquisition and no display setter. + +The RS50 had entered power-saving sleep before the call, so this run is not a +valid physical OLED-support observation. It remains valid evidence about the +DirectInput precondition because the runtime rejected the call before a +boolean output was produced. + +## Exact result + +```text +Query call started UTC: 2026-07-28T03:57:11.771Z +Query call completed UTC: 2026-07-28T03:57:11.779Z +Product: Logitech G HUB RS50 (USB) +VID: 0x46d PID: 0xc276 +Acquired: 0 +Cooperative HRESULT: 0x0 +Escape HRESULT: 0x80040205 +Output capacity: 1 -> 1 +Output byte: 0xa5 +Supported: 0 +Query failed: Documented display support query failed +``` + +Windows SDK `dinput.h` defines `0x80040205` as +`DIERR_NOTEXCLUSIVEACQUIRED`: the operation cannot be performed unless the +device is acquired in `DISCL_EXCLUSIVE` mode. + +`Supported: 0` is not a device answer. The unchanged sentinel proves that no +valid boolean response was returned. + +## Decision + +Build A is retired and must not be repeated. Build A2 remains query-only but +uses a visible process-owned foreground window, requests +`DISCL_EXCLUSIVE | DISCL_FOREGROUND`, acquires immediately before the single +Escape call, and unacquires immediately afterward. It records cooperative, +Acquire, Escape, and Unacquire HRESULTs. + +Build A2 still contains no layout query, display setter, force-feedback +effect, raw HID call, or retry loop. Its physical execution requires a new +baseline, an active query capture, an awake RS50 showing Dynamic/Test, and a +new explicit authorization. + +## Capture analysis + +The saved capture identified the RS50 at USB device address `3`. Restricting +the capture to the exact CLI interval +`03:57:11.771Z`-`03:57:11.779Z` produced: + +```text +Parsed reports: 8 +Directions: HOST 0, DEVICE 8, UNKNOWN 0 +Report IDs: DEVICE 0x08=8 +HOST HID++ requests without an exact header match: 0 +``` + +The offline baseline/query comparator found zero HID++ reports and zero +positive exact-report deltas. The eight device-originated `0x08` reports are +ordinary non-HID++ input and were ignored by the differential analyzer. + +This capture independently supports the HRESULT interpretation: Build A did +not put a Logitech HID++ request on the USB transport during the attempted +Escape call. diff --git a/LogiDynamicDash/docs/evidence/RS50_DYNAMIC_TELEMETRY_SUCCESS_2026-07-27.md b/LogiDynamicDash/docs/evidence/RS50_DYNAMIC_TELEMETRY_SUCCESS_2026-07-27.md new file mode 100644 index 0000000..9f4f115 --- /dev/null +++ b/LogiDynamicDash/docs/evidence/RS50_DYNAMIC_TELEMETRY_SUCCESS_2026-07-27.md @@ -0,0 +1,121 @@ +# RS50 Dynamic Telemetry Success + +## Result + +Build D completed its first authorized physical trial on 2026-07-27. With +iRacing running and the car stationary in the pits, LogiDynamicDash acquired +the RS50, displayed live telemetry for ten seconds, and released the device +automatically. + +The operator observed: + +```text +SPEED +0 KMH +GEAR +N +``` + +No immediate LED, torque, force-feedback, or wheel-movement change was +reported during the stationary ten-second observation. Subsequent stationary +checking found that the shift LEDs no longer updated and the normal +force-feedback centering did not return the wheel to center. The wheel instead +moved slightly counterclockwise. + +## Guarded Execution + +The Release build required the four exact ordered arming arguments documented +in the operator checklist. Its console progressed through: + +```text +IRACING / WAITING +SPEED / N/A / GEAR / ? +SPEED / 0 KMH / GEAR / N +``` + +The process exited successfully after its ten-second cancellation boundary. + +## USBPcap Evidence + +The local capture is: + +```text +2026-07-27_rs50_build_d_live_telemetry_trial_1.pcapng +``` + +SHA-256: + +```text +1E3F92EFAAAAE885023D012DE65C75E072C49EBF572935B8D1E7EEAB1688A61C +``` + +Raw captures remain local and are not committed. + +Offline, device-scoped analysis at USB address `3` extracted 68,001 reports. +All 29 HOST HID++ requests had exact DEVICE response headers, with zero +unmatched requests and zero invalid records. Display Game Data runtime `0x12` +contained exactly three function-`3` setters: + +1. `IRACING / WAITING / "" / ""` +2. `SPEED / N/A / GEAR / ?` +3. `SPEED / 0 KMH / GEAR / N` + +All three setters had matched responses. Their payloads were distinct, the +rate was below the five-updates-per-second limit, and no HID++ error response +was present. The same capture reconstructed the ten-layout catalog and Layout +J capacities `19/10/19/10`. + +The capture also contains three matched operations on runtime `0x10`, public +feature `0x8123` Force Feedback, surrounding the exclusive lifecycle: + +```text +start +0.000 s function 1 parameters 00 00 00 +start +0.010 s function 8 parameters FF FF 00 ... +end +9.070 s function 1 parameters 00 00 00 +``` + +Their timing and feature identity correlate with the observed loss of normal +iRacing LED/FFB behavior. The HID++ `0x8123` command table in the upstream +Linux Logitech driver identifies function `1` as `RESET_ALL` and function `8` +as `SET_GLOBAL_GAINS`. The captured `FF FF` value sets maximum global gain. +The DirectInput lifecycle therefore emitted: + +```text +RESET_ALL -> SET_GLOBAL_GAINS(0xFFFF) -> RESET_ALL +``` + +This explains why iRacing's existing force effects were no longer present +after LogiDynamicDash released the device. + +Leaving and re-entering the car did not recover the shift LEDs. Returning to +the iRacing main menu and loading the circuit again did recover them, which is +consistent with the simulator process rebuilding its Logitech output state. +The operator subsequently reported that normal centering/FFB also appeared to +be restored. + +## Interpretation + +This remains the first end-to-end confirmation that iRacing telemetry can travel +through LogiDynamicDash, the guarded DirectInput bridge, Logitech's +Display Game Data feature `0x8130`, and the RS50 firmware-rendered Dynamic +OLED. + +It is not an acceptance of the streaming design. Exclusive foreground +acquisition is required by the installed driver and disrupted the simulator's +LED and FFB state beyond the ten-second session. A full-lap or moving-car test +is prohibited until a transport or lifecycle design demonstrates safe +coexistence without taking persistent exclusive ownership from iRacing. + +The RS50 protocol specification maintained by the open Linux driver documents +three separate interfaces: joystick input on interface `0`, HID++ configuration +on interface `1`, and real-time direct-drive FFB on interface `2` / endpoint +`0x03`. A narrowly scoped, shared HID++ transport targeting only discovered +feature `0x8130` is therefore the next offline design candidate. It has not +been implemented or authorized for physical execution. + +## Primary References + +- Linux `hid-logitech-hidpp` command definitions: + +- RS50/G PRO protocol specification: + diff --git a/LogiDynamicDash/docs/evidence/RS50_FEATURE_8130_DISPLAY_GAME_DATA_2026-07-22.md b/LogiDynamicDash/docs/evidence/RS50_FEATURE_8130_DISPLAY_GAME_DATA_2026-07-22.md new file mode 100644 index 0000000..a315d71 --- /dev/null +++ b/LogiDynamicDash/docs/evidence/RS50_FEATURE_8130_DISPLAY_GAME_DATA_2026-07-22.md @@ -0,0 +1,551 @@ +# RS50 Feature 0x8130 Display Game Data Evidence + +## Result + +HID++ feature `0x8130` is the strongest Dynamic OLED candidate found so far. +The conclusion is based on two independent local observations: + +1. The physical RS50 advertised public feature `0x8130` during G HUB startup. +2. The installed G HUB implementation names the corresponding class + `Feature8130DisplayGameData`. + +This identifies a game-data feature, not yet a safe write protocol. No report +from this feature has been transmitted or replayed by the project. + +## Physical FeatureSet Evidence + +- Capture date: 2026-07-18 +- Device: Logitech RS50, VID `0x046D`, PID `0xC276` +- Capture scope: physical RS50 USB address only +- Initial state: G HUB closed; then started normally +- Host request frame: 267, 25.830623 seconds +- Device response frame: 269, 25.833538 seconds + +Sanitized request: + +```text +10 FF 01 1B 12 00 00 +``` + +Sanitized response prefix: + +```text +12 FF 01 1B 81 30 00 00 +``` + +Interpretation: + +- device index: `0xFF` (base device) +- FeatureSet runtime index: `0x01` +- requested ordinal/runtime index: `0x12` +- returned feature ID: `0x8130` +- flags: `0x00` (public) +- version: 0 +- request/response latency: approximately 2.915 ms + +The updated offline batch analyzer reconstructed the complete catalog from +matched FeatureSet requests and responses. For this entry it reported: + +```text +device 0xFF, runtime 0x12 -> feature 0x8130 +(Display Game Data; G HUB static name), flags 0x00, version 0; +HOST operational requests 0 +``` + +The full PCAP remains local and is not committed. + +## Installed G HUB Static Evidence + +The inspected executable was: + +```text +C:\ProgramData\LGHUB\depots\794591\core\LGHUB\lghub_agent.exe +File version: 2026.4.919028 +Size: 88,446,616 bytes +SHA-256: 77E3B6FF1ED78CBEBFCCC6F63EED297D14D6DF1454A8B4E50AD1263115B7BED6 +``` + +Its MSVC run-time type information contains these exact class names: + +```text +devio::IFeature8130DisplayGameData +devio::IIFeature8130DisplayGameData +devio::Feature8130DisplayGameData +devio::FeatureCreator +``` + +The same feature implementation is linked into the installed +`di_ffb_manager.exe`. Deeper inspection found internal +`Logi::Display::Message::SetLayout` types there, but no corresponding export in +the installed public Wheel or TRUEFORCE SDK DLLs. This is static binary +evidence; the external entry path must still be confirmed at runtime. + +An exact-string search across the complete installed G HUB depot found +`DisplayGameData` only in `lghub_agent.exe` and `di_ffb_manager.exe`. The +Electron front-end archive (`app.asar`) contains neither that class name nor an +`0x8130` route. The DirectInput/FFB manager does preserve internal layout +message types, while the public SDK-facing exports do not. This places the +known feature below G HUB's public UI and public SDK surface. + +### Embedded DirectInput Driver + +`di_ffb_manager.exe` is a resource container as well as a manager. Its PE +resources include four force-feedback drivers: + +| Resource | Size | SHA-256 | +|---|---:|---| +| `HIDPP_FORCEFEEDBACK_X64.DLL` | 4,900,504 | `17AB8FBB23FD549CCCCDB72A502C3BDCD984F80B6C40E48027C25512D0405A7F` | +| `HIDPP_FORCEFEEDBACK_X86.DLL` | 4,277,400 | `ECB831F985CBE8F59CE2E2EC450F4B3B8AECBDC7B632264698AF1EC0EACD7E8D` | +| `JERRY_FORCEFEEDBACK_X64.DLL` | 378,008 | `B6B4F9952B7D05896BB73480725948F8CDE9E6DDE737009CE80152F25E295DEA` | +| `JERRY_FORCEFEEDBACK_X86.DLL` | 306,840 | `EBA7B29F5269D9C5DD7EB9E078187520284CC2B3D4FF8520CF1BA9CF095545F5` | + +The extracted files were inspected as data only and were not loaded or +executed. They remain outside the repository. + +The same HID++ DLLs are installed as COM servers at version `1.1.13`. The +physical RS50 joystick registry key selects this driver explicitly: + +```text +Joystick OEM key: VID_046D&PID_C276 +OEMForceFeedback CLSID: {62B43F0E-E7DB-4329-8C13-A966D84A289F} +x64 InProcServer32: + C:\Program Files\Logitech\Direct Input Force Feedback\1_1_13\ + hidpp_forcefeedback_x64.dll +``` + +The installed x64/x86 hashes exactly match the corresponding embedded +resources above. The RS50 INF applies the standard HID installation to +`USB\VID_046D&PID_C276&MI_00` and registers this force-feedback COM driver; it +does not attach the legacy Logitech lower HID filter to the RS50 entry. + +The installed x64 COM server also has a valid Authenticode signature from +`Logitech Inc`; the signing-certificate thumbprint observed on 2026-07-22 is +`BFAB6EF922D0DDE848AE747B2AD58506E17D9ED1`. The future bridge gate checks both +publisher validity and the audited binary hash, so a G HUB driver update stops +the probe until the new binary is reviewed. + +The HID++ driver's RTTI exposes `std::function` callbacks for +`EscapeCommands::Wheel::Rpm`, `LedConfig`, `LedCaps`, and every display payload +from `LayoutC` through `LayoutJ`. This locates the display producer interface +inside Logitech's DirectInput force-feedback driver. It is an internal +DirectInput Escape surface rather than a public export; the DLL exports only +the four standard COM registration/class-factory functions. + +The callbacks construct these consecutive internal display messages: + +| Message ID | Payload type | Game-facing fields copied by the driver | +|---:|---|---| +| 29 | Layout C | one 32-bit floating value | +| 30 | Layout D | two 32-bit values, one string | +| 31 | Layout E | two 32-bit values, two strings | +| 32 | Layout F | two strings | +| 33 | Layout G | two strings | +| 34 | Layout H | two strings | +| 35 | Layout I | four strings | +| 36 | Layout J | four strings | + +The DLL implements the standard COM `IDirectInputEffectDriver` interface. Its +vtable identifies the display entry point as `IDirectInputEffectDriver::Escape`. +Microsoft documents that this driver method is reached from an application's +`IDirectInputEffect::Escape` or `IDirectInputDevice::Escape` call, and that +`DIEFFESCAPE.dwCommand` is manufacturer-specific: +[driver interface](https://learn.microsoft.com/en-us/windows/win32/api/dinputd/nn-dinputd-idirectinputeffectdriver), +[escape structure](https://learn.microsoft.com/en-us/windows/win32/api/dinput/ns-dinput-dieffescape). +The recovered two-level dispatch envelope is: + +```text +DIEFFESCAPE.dwCommand = 4 +lpvInBuffer + 0x04: uint32 version = 1 +lpvInBuffer + 0x08: inner command +lpvInBuffer + 0x0C: layout payload +``` + +The outer value is anchored by the driver's six-entry dispatch table, not by +the order of nearby type names. The dispatcher reads `dwCommand` at +`DIEFFESCAPE + 4`. Table entry `0` parses the exact 20-byte version-1 RPM +structure published in Logitech's official SDK sample. Entry `4` forwards the +escape object to the 22-command Display Game Data dispatcher. Entry `5` +instead parses a distinct 20-byte family with a subtype byte and a double; +it is not the OLED display envelope. This official-command-0 cross-check +corrects an earlier off-by-one interpretation of the outer selector. + +The independently extracted 32-bit driver confirms the same mapping. Its +dispatcher at `0x102676E2` reads `[DIEFFESCAPE + 4]`, bounds the selector to +`0..5`, and uses the absolute jump table at `0x1026789C`. Entry `0` at +`0x102676F5` validates the same 20-byte RPM structure. Entry `4` at +`0x1026786B` calls `0x102678C0`, whose inner dispatcher checks version `1`, +subtracts one from the byte at input offset `8`, bounds it to `0..21`, and +therefore accepts inner commands `1..22`. Entry `5` at `0x102677F4` again +parses the separate subtype-and-double family. Both architectures thus agree +that the display outer command is `4`. + +The display setters and minimum `cbInBuffer` values are: + +| Inner command | Layout | Minimum bytes | Payload after the 12-byte header | +|---:|---|---:|---| +| 13 | A | 12 | none | +| 14 | B | 12 | none | +| 15 | C | 16 | one `float` | +| 16 | D | 52 | two `float` values, one MSVC `std::string` | +| 17 | E | 84 | two `float` values, two MSVC `std::string` objects | +| 18 | F | 76 | two MSVC `std::string` objects | +| 19 | G | 76 | two MSVC `std::string` objects | +| 20 | H | 76 | two MSVC `std::string` objects | +| 21 | I | 140 | four MSVC `std::string` objects | +| 22 | J | 140 | four MSVC `std::string` objects | + +The commands preceding the setters complete the same display family: + +| Inner command | Meaning | +|---:|---| +| 1 | `DisplaySetIdleMessage` / set Dynamic display idle | +| 2 | `DisplayIsSupportedMessage` / query general display support | +| 3-12 | query support/capabilities for layouts A-J, in order | + +These meanings are corroborated by the dispatch order and the preserved +`DisplayIsLayoutA...JSupportedMessage` type names, rather than inferred from +the numeric sequence alone. + +The x64 driver's command-4 dispatcher also reveals the exact output-buffer +contract for those support queries. Each successful callback writes its +boolean result to the first output byte, after checking these minimum output +capacities: + +| Inner command | Query | Minimum output bytes | +|---:|---|---:| +| 2 | General display support | 1 | +| 3-5 | Layout A-C support | 1 | +| 6 | Layout D support | 4 | +| 7-10 | Layout E-H support | 6 | +| 11-12 | Layout I-J support | 10 | + +The larger capacities are validation requirements, not additional recovered +fields. Every query branch calls its capability object and then converges on +the same `mov byte ptr [rdi], al` at `0x1802AA5DE`; no branch writes bytes +`1..N-1`. Only output byte `0` therefore has defined meaning. A physical test +should prefill the full output buffer with a sentinel and verify that all +remaining bytes stay unchanged rather than interpreting them as capabilities. + +Inner command `3` (Layout A query) has an unsafe malformed-buffer path in the +x64 driver: a null or undersized output is normalized to null, but the common +return path can still store through it. This does not affect a correctly formed +one-byte output buffer, but it rules out negative or boundary probing. Every +query must provide the documented exact capacity; malformed buffers are not a +valid research test. + +All commands 1-12 require the same 12-byte minimum input envelope and version +`1`. Setter commands 13-22 do not require an output buffer. These checks are +now represented by a transport-free contract type in the explorer; it cannot +open DirectInput or transmit a command. + +The general support callback is not a local cached flag. Its call chain creates +an asynchronous request object, submits it through the driver's feature +transport, and waits with a 200 ms timeout before returning the boolean. A +physical command-2 validation should therefore expect a host/device exchange +even though it is a query and not a setter. It remains an explicitly approved +transmission, not part of passive observation. + +Layout support queries use a separate lazy capability cache. Every A-J getter +first calls `0x1800F4F00`. On its first invocation, that routine sends feature +function `0` with a one-byte zero input, reads the returned layout count, then +loops from zero to count-minus-one and sends feature function `1` for each +layout index. It parses the six-byte descriptors into per-layout cached fields +and marks the cache initialized. With the RS50 firmware's count of ten, the +first layout-support query is therefore expected to produce eleven feature +transactions; subsequent A-J queries on the same feature-object lifetime use +the cache. This is another reason to isolate Build B from the one-request +general support probe. + +The routine sets its one-time initialization flag before issuing those +requests and does not clear it on the observed failure paths. A timeout or +partial first load can therefore leave false/empty cached capabilities for the +rest of that feature-object lifetime. A physical retry must recreate the +DirectInput device/feature object rather than issuing another layout query on +the same handle. + +This is an in-process C++ ABI, not a portable byte packet: a text-bearing +Escape buffer includes live `std::string` object state and possibly pointers. +Blindly reproducing the buffer from another language or process would be both +unsafe and incorrect. The firmware-side HID++ payload remains the actual +portable wire format, but it still awaits an explicitly approved physical +validation. + +The recovered sizes also identify the x64 structure packing. A 12-byte header, +two four-byte floats, and one 32-byte MSVC `std::string` total 52 bytes only +with four-byte packing; the driver reads Layout D's values at offsets `0x0C` +and `0x10` and its string at `0x14`. The same model exactly produces E at 84, +F/G/H at 76, and I/J at 140 bytes. Managed layout models now test every size +and field offset while keeping the string representation opaque and explicitly +non-marshallable. + +The proposed production boundary is therefore an x64 MSVC C++ bridge that owns +the live strings and DirectInput objects, with a small C ABI consumed by .NET. +The staged implementation and physical safety gates are documented in +[`../RS50_DIRECTINPUT_BRIDGE_DESIGN.md`](../RS50_DIRECTINPUT_BRIDGE_DESIGN.md). + +The same DLL contains the complete `Feature8130DisplayGameData` implementation. +For Layout C its final setter allocates a two-byte function-`3` payload: the +firmware layout ID followed by the converted numeric byte. The D-J setters +similarly prepend their layout ID, copy bounded byte/text fields according to +the capability descriptor, and dispatch function `3`. This joins the formerly +separate DirectInput-message and firmware findings into one static path: + +```text +game/SDK -> DirectInput Escape Layout C-J -> internal message 29-36 + -> Feature8130DisplayGameData -> HID++ function 3 -> firmware renderer +``` + +The Layout C Escape callback at `0x18001BF30` passes the incoming value's raw +32-bit representation to `0x1800115D0`. The latter allocates a 24-byte message, +sets message ID `29`, and stores those bits unchanged at object offset `0x14`. +This locates the float-to-byte conversion after internal message creation and +before the final Feature8130 setter. The 36-entry dispatcher jump table maps +message `29` to a wrapper that calls the Layout C adapter at `0x180015A20`. +That adapter clamps the float to `0.0..1.0`, multiplies it by the confirmed +constant `255.0`, calls the bundled rounding helper, and passes the resulting +byte to the Feature8130 setter. Layout D (`0x180015AA0`) and Layout E +(`0x180015C40`) repeat the same conversion independently for both numeric +fields. The resulting cross-layer formula is therefore: + +```text +wire byte = round(clamp(game float, 0.0, 1.0) * 255.0) +OLED extent = wire byte * 118 / 255 +``` + +The research explorer now contains an offline encoder for these exact A-J +parameter layouts. Its output starts with the firmware's zero-based layout +index and ends with the bounded layout fields. It intentionally omits all +transport metadata: no HID report ID, device ID, runtime feature index, or USB +write operation is present. Non-finite numeric inputs, embedded NUL, and +oversized text fields are rejected before a payload can be produced. This is a +reproducible protocol artifact, not a hardware sender. + +The callable entry point has not been invoked at runtime, so this path is +evidence for protocol structure, not authorization to inject an Escape command +or HID report. + +### Official SDK Transport Precedent + +Logitech's official Steering Wheel SDK 8.75.30 provides an independent C++ +sample that sends dynamic RPM values through `IDirectInputDevice8::Escape`. +The published sample uses outer command `0`, a versioned input structure, and +three float values; its manual says the DInput helper works without SDK +initialization. This independently confirms DirectInput Escape as an intended +Logitech game-to-wheel transport pattern. + +The SDK predates the RS50/PRO display extension and exposes no OLED or layout +API. The installed driver extends the same boundary with outer command `4` and +the A-J inner command family. Full source hashes, manual verification, and the +public/private API boundary are recorded in +[`RS50_OFFICIAL_WHEEL_SDK_DIRECTINPUT_2026-07-22.md`](RS50_OFFICIAL_WHEEL_SDK_DIRECTINPUT_2026-07-22.md). + +The neighboring public features advertised by the same base device also have +unambiguous G HUB class names: + +| Feature | G HUB class meaning | +|---|---| +| `0x807A` | RPM Indicator | +| `0x807B` | RPM LED Pattern | +| `0x8120` | Gaming Attachments | +| `0x8123` | Force Feedback | +| `0x8127` | Dual Clutch | +| `0x8130` | **Display Game Data** | +| `0x8132` | Axis Mapping | +| `0x8133` | Global Damping | +| `0x8134` | Brake Force | +| `0x8136` | Torque Limit | +| `0x8137` | Configuration Profiles | +| `0x8138` | Operating Range | +| `0x8139` | TRUEFORCE | +| `0x8140` | FFB Filter | + +This neighborhood rules out a simple confusion between `0x8130` and the RPM +LED or force-feedback features: G HUB implements and names them separately. + +## Firmware Confirmation + +The official base firmware already downloaded by G HUB was inspected without +executing or modifying it: + +```text +File: rs50_main_v165_4_39.dfu +Embedded version: U165.04_B0039-gf6375b51 +Size: 235,600 bytes +SHA-256: 62C152D68BA0873B7330401050A2DD96E099CFB6E648759F4329D59E382D3D9D +Image base: 0x08010000 +DFU wrapper before image: 0x20 bytes +``` + +At file offset `0x2C64C`, the firmware feature registry contains feature ID +`0x8130`, function count `4`, and four Thumb handler pointers: + +```text +function 0 -> 0x08033911 +function 1 -> 0x08033919 +function 2 -> 0x0803393D +function 3 -> 0x08033981 +``` + +This independently confirms that `0x8130` is implemented by the RS50 base +firmware rather than invented solely by G HUB. + +## Confirmed Feature Shape + +The firmware and G HUB implementations agree on this protocol: + +1. Function `0` returns exactly `10` layouts. +2. Function `1` accepts an index from `0` through `9` and returns six bytes: + the index, layout ID `index + 1`, and a four-byte layout-capability record. +3. Function `2` clears the pending Dynamic-data state in RAM. +4. Function `3` accepts a layout index and its bounded data fields, sanitizes + text, and schedules the selected layout for rendering. + +Function `3` performs that scheduling directly: it marks the Dynamic-data +state pending, stores the selected layout, and reloads an expiry counter with +decimal `240000` (`0x0003A980`). A firmware service routine decrements that +counter and clears pending state when it reaches zero. No function-`0` or +function-`1` handshake is required before the setter. + +The counter's timing chain is also statically recoverable: + +1. `0x08033440` decrements the counter once and clears pending at zero. +2. Its only direct caller is `0x0803481C`, inside the main display task. +3. That task waits on RAM flag `0x20001510` before each iteration. +4. `0x0801959C` sets that flag and calls the routine at `0x08011540`. +5. The vector-table entry at image offset `0x3C` points to `0x0801959D`, + identifying it as the Cortex-M `SysTick` handler. +6. `0x08011540` has the STM32 `HAL_IncTick` operation + `uwTick += uwTickFreq`. + +ST's official HAL source documents the default SysTick time base as 1 ms and +shows that exact increment operation in `HAL_IncTick`: +[STM32G4 HAL time-base implementation](https://github.com/STMicroelectronics/stm32g4xx-hal-driver/blob/master/Src/stm32g4xx_hal.c). +Consequently the nominal expiry is `240000 ms = 240 s = 4 min`. This duration +should still be confirmed with an elapsed-time physical observation after a +legitimate producer stops. + +The ten function-1 descriptors are: + +```text +request parameter: zero-based layout index 0..9 +response byte 0: zero-based layout index 0..9 +response byte 1: one-based layout ID 1..10 +response bytes 2..5: four capability bytes +``` + +The firmware handler at `0x08033918` writes the requested index to byte `0`, +`index + 1` to byte `1`, and the four-byte descriptor to bytes `2..5`. The +driver dispatches on byte `1`, stores the descriptor, and sets a separate +per-layout validity flag. DirectInput returns that validity flag as its +layout-support boolean; it does not reinterpret response byte `0` as support. + +| Index | Layout | Capability bytes | Function-3 data after index | +|---:|---|---|---| +| 0 | A | `00 00 00 00` | none | +| 1 | B | `00 00 00 00` | none | +| 2 | C | `00 00 00 00` | one byte | +| 3 | D | `0B 00 00 00` | two bytes, then text up to 11 bytes | +| 4 | E | `07 03 00 00` | two bytes, text up to 3, text up to 7 | +| 5 | F | `01 03 00 00` | text up to 1, text up to 3 | +| 6 | G | `01 03 00 00` | text up to 1, text up to 3 | +| 7 | H | `15 0A 00 00` | text up to 21, text up to 10 | +| 8 | I | `13 0A 13 0A` | text up to 19, 10, 19, and 10 | +| 9 | J | `13 0A 13 0A` | text up to 19, 10, 19, and 10 | + +The display dispatcher at `0x080338BC` selects the renderer through a +ten-entry table rooted at `0x0804607C`. The recovered targets are: + +| Index | Layout | Renderer | +|---:|---|---:| +| 0 | A | `0x08033380` | +| 1 | B | `0x0803390C` | +| 2 | C | `0x080333A0` | +| 3 | D | `0x08032E3C` | +| 4 | E | `0x08032F1C` | +| 5 | F | `0x08033028` | +| 6 | G | `0x080330BC` | +| 7 | H | `0x08033150` | +| 8 | I | `0x0803322C` | +| 9 | J | `0x08032D48` | + +Layout J renders in five phases: clear, then one phase for each text field. +The firmware measures and horizontally centers all four fields. Their vertical +positions are respectively 2, 12, 35, and 45. Layout I uses the same four RAM +fields, centers fields one and three, right-aligns fields two and four against +an x boundary of 116, and draws additional left-side glyphs or decoration on +the second and fourth rows. Consequently J is the least semantically +prescriptive candidate for an initial text dashboard, while I is a decorated +variant. The suggested use of J for speed/unit and gear/value is a project +mapping, not a recovered Logitech field name. + +The text copier stops at NUL, converts lowercase ASCII to uppercase, accepts +printable bytes `0x20` through `0x7F`, replaces other bytes with `?`, and adds +a terminator. The largest I/J payload is 59 bytes including the layout index, +which fits a 64-byte HID++ report with its four-byte header and padding. + +Separate firmware draw routines load the numeric state bytes at offsets 2 and +3, convert them to floating point, and calculate `value * 118 / 255` before a +display draw call. This establishes their wire domain as normalized `0..255` +gauge/progress values mapped onto a 118-pixel span. It does not by itself name +either gauge as RPM, speed, fuel, or another telemetry concept. The +game-facing conversion is the normalized formula documented above, but the +semantic assignment of each gauge still requires a legitimate producer +capture or an explicitly approved physical test. + +G HUB's embedded DirectInput/FFB driver provides a second independent naming layer. +Its preserved C++ type information contains +`Logi::Display::Message::SetLayout` specializations for +`EscapeCommands::Wheel::LayoutC` through `LayoutJ`, with consecutive message +IDs 29 through 36. Layouts A and B carry no function-3 data, which explains why +no payload structure type is preserved for them. + +This establishes a typed, firmware-rendered layout protocol rather than a raw +pixel framebuffer. The semantic assignment of each text/byte field to visible +gear, speed, units, or labels still requires one legitimate runtime capture. + +## Important Negative Evidence + +### Official Product Position + +The official Logitech PRO Racing Wheel setup guide describes Dynamic as +support for "potential future updates" to screen functionality and says it +defaults to Test. The same page describes Test, Profile, and Torque as active +features: +[official guide, printed page 16](https://www.logitech.com/assets/70071/3/pro_racing_wheel_for_ps_%26_pc_samr.pdf). + +The PDF page was rendered and visually checked, not inferred only from search +text. This is strong evidence that Logitech shipped the Dynamic selector as a +reserved extension point rather than promising a currently supported game +dashboard. It does not prove that no later private integration exists, but it +explains the observed Test fallback and the lack of an OLED method in the +public Wheel SDK. + +During the complete 51.09-second G HUB startup capture: + +- G HUB enumerated runtime index `0x12` as `0x8130`. +- It sent zero operational requests to device `0xFF`, runtime index `0x12`. +- No 64-byte host report or sustained display-rate stream appeared. + +Therefore normal G HUB startup discovers the feature but does not activate a +Dynamic data session. A legitimate game-side producer or another activation +condition is still required to capture real payloads. + +## Safe Next Evidence + +The next useful capture must observe a legitimate producer while the wheel is +already in Dynamic mode. The decisive signature is host traffic to device +`0xFF`, runtime index `0x12`, especially function `3`; functions `0` and `1` +may appear for discovery but are not a required setter handshake. + +Until such traffic is captured, the project must not transmit function-`3` +payloads from the static analysis. The layout bounds and numeric conversion +are now known, but field meanings, session requirements, update cadence, and +rejection behavior remain unverified on physical hardware. + +This was the original safety gate before the DirectInput route could be +validated. It is now superseded only for the separately guarded, explicitly +approved Build C experiment: physical general and Layout J queries matched the +static model, and Build C exposes one fixed Layout J payload through the +audited Logitech driver. Arbitrary function-`3` traffic, raw HID transmission, +live telemetry, and repeated setters remain prohibited until that single +static experiment succeeds. diff --git a/LogiDynamicDash/docs/evidence/RS50_GHUB_STARTUP_USB_2026-07-18.md b/LogiDynamicDash/docs/evidence/RS50_GHUB_STARTUP_USB_2026-07-18.md index aa99b53..2257007 100644 --- a/LogiDynamicDash/docs/evidence/RS50_GHUB_STARTUP_USB_2026-07-18.md +++ b/LogiDynamicDash/docs/evidence/RS50_GHUB_STARTUP_USB_2026-07-18.md @@ -56,6 +56,27 @@ After enumeration, G HUB sent no request to device `0x01` runtime index `0x09`, `0x0E`, or `0x0F`. Its subsequent device `0x01` requests used only runtime indices `0x02`, `0x03`, and `0x05`. +## Base Device Display Game Data Enumeration + +The same capture contains a stronger candidate on base device index `0xFF`: + +| Runtime index | Feature ID | Flags | Version | Operational host calls | +|---|---|---:|---:|---:| +| `0x12` | `0x8130` | `0x00` | 0 | 0 | + +The matched sanitized transaction is: + +```text +HOST 10 FF 01 1B 12 00 00 +DEVICE 12 FF 01 1B 81 30 00 00 ... +``` + +Later static inspection of the installed G HUB agent identified the exact +class name `Feature8130DisplayGameData`. That evidence was not available when +this capture was first summarized; it moves `0x8130` ahead of the device +`0x01` candidates. Complete details are in +[`RS50_FEATURE_8130_DISPLAY_GAME_DATA_2026-07-22.md`](RS50_FEATURE_8130_DISPLAY_GAME_DATA_2026-07-22.md). + ## Interpretation This capture distinguishes feature discovery from feature use. G HUB learned @@ -89,6 +110,10 @@ result: runtime feature indices `0x00`, `0x01`, `0x02`, `0x03`, and `0x05` were present, while `0x09`, `0x0E`, and `0x0F` were absent. This confirms that the enumerated display candidates were not invoked during the startup session. +After its FeatureSet reconstruction was added, the analyzer also reproduced +the base mapping `runtime 0x12 -> feature 0x8130` and counted zero operational +host requests to it. + The analyzer found 171 device reports with an exact preceding host header match. Another 210 host HID++ requests had no exact match under the conservative rule requiring the same device, feature, function, and software ID. Some use diff --git a/LogiDynamicDash/docs/evidence/RS50_HIDPP_COLLECTION_INVENTORY_2026-07-28.md b/LogiDynamicDash/docs/evidence/RS50_HIDPP_COLLECTION_INVENTORY_2026-07-28.md new file mode 100644 index 0000000..4ae291c --- /dev/null +++ b/LogiDynamicDash/docs/evidence/RS50_HIDPP_COLLECTION_INVENTORY_2026-07-28.md @@ -0,0 +1,48 @@ +# RS50 HID++ Collection Inventory + +## Session + +- Date: 2026-07-28 +- Device: Logitech RS50, VID `0x046D`, PID `0xC276` +- Operation: descriptor inventory only +- Streams opened: none +- Output/feature reports sent: none + +The existing Release explorer enumerated descriptors through HidSharp without +calling `TryOpen`, `Write`, or `SetFeature`. + +## Observed Collections + +| Collection | Usage | Input | Output | Classification | +|---|---|---|---|---| +| MI_00 | `0001:0004` | no-ID, 31 bytes | none | joystick inputs | +| MI_01 COL01 | `FF43:0701` | `0x10`, 7 bytes | `0x10`, 7 bytes | HID++ short | +| MI_01 COL02 | `FF43:0702` | `0x11`, 20 bytes | `0x11`, 20 bytes | HID++ long | +| MI_01 COL03 | `FF43:0704` | `0x12`, 64 bytes | `0x12`, 64 bytes | HID++ very long | +| MI_02 | `FFFD:FD01` | `0x01`, 64 bytes | `0x01`, 64 bytes | real-time FFB/TRUEFORCE | + +The inventory exactly matches the report IDs and lengths observed in saved +USBPcap traffic. + +## Build F Consequence + +Feature discovery for `0x8130` sends a short `0x10` request and receives a +very-long `0x12` response. Layout J sends and receives `0x12`. A shared HID++ +adapter therefore needs: + +1. MI_01 COL01 for the discovery write; +2. MI_01 COL03 for Layout J writes and all matching responses. + +It does not need MI_00, MI_01 COL02, or MI_02. Build F rejects those +collections as targets and requires exactly one validated COL01 and one +validated COL03. Windows gives top-level collections distinct device-path +instance segments, so their full path strings are not expected to match. + +This inventory does not prove how Windows/HidSharp will implement an output +write on these collections. A future captured stationary trial must verify +that the host produces the expected endpoint-0 HID `SET_REPORT` control +transfer and no interface-2/endpoint-`0x03` output. + +Microsoft documents `WriteFile` as the user-mode path for continuously sending +HID output reports, but does not guarantee the resulting USB transfer type: +[Sending HID Reports](https://learn.microsoft.com/en-us/windows-hardware/drivers/hid/sending-hid-reports). diff --git a/LogiDynamicDash/docs/evidence/RS50_IRACING_REV_LIGHT_2026-07-27.md b/LogiDynamicDash/docs/evidence/RS50_IRACING_REV_LIGHT_2026-07-27.md new file mode 100644 index 0000000..651aef7 --- /dev/null +++ b/LogiDynamicDash/docs/evidence/RS50_IRACING_REV_LIGHT_2026-07-27.md @@ -0,0 +1,105 @@ +# RS50 iRacing Rev-Light Capture Evidence — 2026-07-27 + +## Scope + +This note preserves the sanitized findings from four controlled USB captures of a Logitech RS50 while iRacing was running. + +The rev-light investigation was not the project's final objective. It was used as a visible, repeatable protocol target to learn how the RS50 discovers HID++ features, initializes them, streams live state, and acknowledges host writes. Those lessons are directly relevant to the ongoing Dynamic OLED investigation. + +Raw `.pcapng` files remain local and are intentionally not committed. + +## Test Conditions + +- Device: Logitech RS50 +- USB VID/PID: `046D:C276` +- Game: iRacing +- Capture tools: Wireshark + USBPcap +- Capture scope: RS50 USB traffic +- G HUB application window: closed +- Logitech background components: left running normally +- Rev-light strip: visibly active during the live RPM tests + +Closing the G HUB window does not imply that Logitech background services were stopped. The captures therefore establish wire behavior, but they should not be used to claim that the traffic originated from the visible G HUB application itself. + +## Capture Set + +- `2026-07-27_rs50_iracing_led_initialization.pcapng` +- `2026-07-27_rs50_iracing_ack_responses.pcapng` +- `2026-07-27_rs50_iracing_shift_overrev.pcapng` +- `2026-07-27_rs50_iracing_pit_limiter.pcapng` + +## Feature Discovery and Startup + +The startup capture began before iRacing was launched and preserved the one-time setup traffic that steady-state captures had missed. + +Feature `0x807A` was discovered and assigned runtime feature index `0x0B` in the captured session. + +The observed startup sequence was: + +```text +10ff0b0c000000 fn0 +10ff0b1c000000 fn1 +10ff0b2c000000 fn2 +10ff0b0c000000 fn0 +11ff0b6c... then the fn2 + fn6 live stream +This sequence provided first-party evidence that an extra fn3 command previously carried in a third-party-derived implementation was not part of the normal RS50 arm sequence. +The maintainer of mescon/logitech-trueforce-linux-driver identified that extra fn3 as SET_EFFECT. In that implementation it could switch the wheel to effect 2 and overwrite the user's active LIGHTSYNC effect, which had required a separate snapshot/restore workaround. +The startup capture allowed both the incorrect command and the workaround to be removed. +Live Rev-Light Stream +The live rev display uses feature 0x807A with the short 0x10 plus long 0x11 write pair. +The level field is 0..10: +0 = strip dark +10 = all ten LEDs lit +The steady-state cadence is approximately 60 Hz, or one update pair roughly every 16 ms while the feed is active. +The capture comparison also exposed a bug in the external Linux telemetry feeder: it had been capped near 6 Hz, making RPM sweeps visibly lag. The maintainer corrected the feeder to approximately 60 Hz after comparing it with this capture. +0x12 Response Pattern +The dedicated ACK capture showed a 0x12 response corresponding to every tested host write in the rev-light stream. +The structural pattern is: +host 0x10 write +→ device 0x12 response +→ host 0x11 write +→ device 0x12 response +→ next update cycle +This is important for future Dynamic OLED work: candidate display traffic should be analyzed bidirectionally, and host writes should be correlated with their device responses rather than studied in isolation. +The capture establishes a consistent acknowledgement pattern. It does not, by itself, prove the exact host-side blocking or flow-control policy. +Redline Behavior +The dedicated over-rev capture confirmed that redline does not require a separate flash command or a value above the normal range. +At redline the live feed simply reaches: +LL = 10 +No LL value above 0x0A and no distinct redline-specific function were observed. +This matches the physical behavior: the strip fills completely at redline rather than switching to a separate protocol effect. +iRacing Pit-Limiter Behavior +The pit-limiter capture isolated a second visible behavior using the same live level mechanism. +When the full strip visibly flashed, the feed alternated between: +LL = 10 +LL = 0 +LL = 10 +LL = 0 +... +The complete ON/OFF cycle is approximately 1.2 Hz. +No separate flash effect or alternate feature was required. The pit-limiter indication can therefore be reproduced by a telemetry feeder using the already understood level command and timing the 10 ↔ 0 alternation itself. +External Review +These captures were reviewed with the maintainer of: +mescon/logitech-trueforce-linux-driver +Discussion: +https://github.com/mescon/logitech-trueforce-linux-driver/issues/20 +The maintainer reported that the startup capture fixed a real arm-sequence bug, the ACK capture confirmed a 0x12 response for every write, the redline capture confirmed plain LL = 10, and the pit-limiter capture confirmed full-strip 10/0 alternation at about 1.2 Hz. +No further rev-light captures were requested after these findings. +Relevance to Dynamic OLED Research +The important result for LogiDynamicDash is methodological. +A known visible RS50 behavior showed that a useful protocol investigation can be decomposed into: +feature discovery +→ runtime feature index +→ one-time initialization +→ live command stream +→ device responses +→ controlled physical-state comparison +The Dynamic OLED investigation should use the same structure. +In particular: +Capture before any candidate display producer becomes active. +Distinguish FeatureSet enumeration from actual feature invocation. +Preserve host-to-device and device-to-host traffic together. +Correlate physical OLED state changes with exact runtime feature/function traffic. +Keep unknown writes, replay, fuzzing, firmware operations, and speculative display commands out of the research path until semantics are established. +This evidence does not identify an OLED transport. +It provides a validated workflow and a reference example of how a proprietary RS50 feature behaves when it is successfully discovered and used. \ No newline at end of file diff --git a/LogiDynamicDash/docs/evidence/RS50_LAYOUT_J_QUERY_SUCCESS_2026-07-27.md b/LogiDynamicDash/docs/evidence/RS50_LAYOUT_J_QUERY_SUCCESS_2026-07-27.md new file mode 100644 index 0000000..a1fee08 --- /dev/null +++ b/LogiDynamicDash/docs/evidence/RS50_LAYOUT_J_QUERY_SUCCESS_2026-07-27.md @@ -0,0 +1,97 @@ +# RS50 Layout J Capability Query Success + +Date: 2026-07-27 local / 2026-07-28 UTC + +## Scope + +Build B executed exactly one guarded DirectInput Layout J capability query. +It used the standard `c_dfDIJoystick2` data format, exclusive foreground +acquisition, outer command `4`, inner command `12`, and a ten-byte output +buffer prefilled with sentinel `0xA5`. This build contains no display setter, +generic command entry point, raw HID write, or force-feedback effect. + +G HUB was closed, the RS50 was awake, and Dynamic was showing the firmware +Test screen. The operator authorized the standard format, exclusive +acquisition, and one Layout J capability query. + +## DirectInput Result + +The bounded run started at `2026-07-28T04:38:21.830Z` and completed at +`2026-07-28T04:38:21.929Z`. + +```text +Query: Layout J support +Acquired: 1 +SetCooperativeLevel HRESULT: 0x00000000 +SetDataFormat HRESULT: 0x00000000 +Acquire HRESULT: 0x00000000 +Escape HRESULT: 0x00000000 +Unacquire HRESULT: 0x00000000 +Input capacity: 0 +Output capacity: 10 +Inner command: 12 +Output bytes: 01 A5 A5 A5 A5 A5 A5 A5 A5 A5 +Supported: 1 +``` + +Only output byte zero changed. Bytes 1-9 retained the sentinel, so the result +matches the recovered ABI contract and reports Layout J as supported. + +## USBPcap Result + +Capture: +`2026-07-27_rs50_layout_j_support_success.pcapng` + +Baseline: +`2026-07-27_rs50_layout_j_baseline.pcapng` + +The device-scoped offline analysis identified the RS50 at USB address `3` and +reconstructed: + +- 52 HID++ reports: 26 HOST and 26 DEVICE; +- 26 exact request/response matches and zero unmatched reports; +- Root discovery of public feature `0x8130` at runtime index `0x12`; +- exactly 11 operational requests to runtime `0x12`, matching the static + prediction for an uncached first layout query. + +The 11 display-feature exchanges consisted of one function-`0` layout-count +query followed by ten function-`1` descriptor queries for indices 0 through 9. +The device reported ten layouts: + +| Index | Layout ID | Descriptor bytes after ID | +| ---: | ---: | --- | +| 0 | 1 (A) | `00 00 00 00` | +| 1 | 2 (B) | `00 00 00 00` | +| 2 | 3 (C) | `00 00 00 00` | +| 3 | 4 (D) | `0B 00 00 00` | +| 4 | 5 (E) | `07 03 00 00` | +| 5 | 6 (F) | `01 03 00 00` | +| 6 | 7 (G) | `01 03 00 00` | +| 7 | 8 (H) | `15 0A 00 00` | +| 8 | 9 (I) | `13 0A 13 0A` | +| 9 | 10 (J) | `13 0A 13 0A` | + +Layout J therefore reports ID `10` and four text capacities +`19/10/19/10`, exactly matching the independently recovered driver and +firmware model. + +## Physical Observation + +After the capture was saved, the operator explicitly confirmed: + +> no observe ningun cambio fisico + +No change was observed in the OLED, LEDs, torque, or wheel position during or +after the single Layout J query. Dynamic continued to show the Test fallback. + +## Interpretation and Safety Gate + +The DirectInput result and USB transaction shape both match the static model. +This confirms that the installed Logitech driver can query the physical +RS50's Display Game Data layout catalog and that Layout J is supported. + +Together with the non-mutating physical observation, this closes the Build B +gate. It permits compiling and auditing the separately guarded Build C fixed +setter. It does not authorize executing that setter without a new explicit +operator approval and a scoped USB capture, and it does not authorize live +telemetry. diff --git a/LogiDynamicDash/docs/evidence/RS50_OFFICIAL_WHEEL_SDK_DIRECTINPUT_2026-07-22.md b/LogiDynamicDash/docs/evidence/RS50_OFFICIAL_WHEEL_SDK_DIRECTINPUT_2026-07-22.md new file mode 100644 index 0000000..6781e05 --- /dev/null +++ b/LogiDynamicDash/docs/evidence/RS50_OFFICIAL_WHEEL_SDK_DIRECTINPUT_2026-07-22.md @@ -0,0 +1,144 @@ +# Official Wheel SDK DirectInput Escape Evidence + +## Result + +Logitech's official Steering Wheel SDK establishes `IDirectInputDevice8::Escape` +as a supported game-to-wheel transport for dynamic RPM data. The SDK's +independent sample constructs `DIEFFESCAPE` directly and calls +`deviceHandle->Escape`, without requiring the Logitech SDK to be initialized. + +This is important lineage for the RS50 OLED investigation: the installed +RS50 force-feedback driver exposes its Display Game Data family through the +same DirectInput Escape entry point. It does **not** make the OLED extension a +public SDK API. The 2018 SDK publishes RPM LEDs only; no OLED, layout, text, +gear, or speed-display function appears in its headers or manual. + +No executable from the downloaded SDK was installed or run. The archive, +manual, headers, and samples were inspected as data only. + +## Official Source + +The archive was downloaded on 2026-07-22 from the Logitech G Partner Developer +Lab's `DOWNLOAD FOR WINDOWS` link: + +- Developer page: +- Archive: +- Page publication label: `07/02/2018` +- Archive size: 3,175,771 bytes +- Archive SHA-256: + `D33EFE079085C15A92AD921EEEF4A77D4898A46D3ACD0EA7AEC86404615DAD66` + +The developer page describes the Steering Wheel SDK as wrapping DirectInput +controls. It also states that G HUB is needed for the SDKs, although the +specific DInput helper documented below is explicitly usable without SDK +initialization. + +## Published Manual Evidence + +Inspected file: + +```text +Doc/LogitechGamingSteeringWheelSDK.pdf +Size: 971,866 bytes +SHA-256: 75B004FA8B99585BD1606CAF1644554F6A5BF5385A7225B2F35052F8D0FDFEDD +``` + +Pages 21-22 document these related APIs: + +```cpp +bool LogiPlayLeds( + const int index, + const float currentRPM, + const float rpmFirstLedTurnsOn, + const float rpmRedLine); + +bool LogiPlayLedsDInput( + const LPDIRECTINPUTDEVICE8 deviceHandle, + const float currentRPM, + const float rpmFirstLedTurnsOn, + const float rpmRedLine); +``` + +The manual says `LogiPlayLedsDInput` plays the controller LEDs and can be used +without `LogiSteeringInitialize`. Its three game-facing RPM values are floats. + +The relevant PDF pages were rendered to PNG and visually checked after text +extraction. Function names, parameter types, and the initialization note were +legible and consistent with the extracted text. + +## Published Source Evidence + +The archive contains a standalone C++ sample that reimplements the RPM helper +using only DirectInput. Its header defines: + +```cpp +CONST DWORD ESCAPE_COMMAND_LEDS = 0; +CONST DWORD LEDS_VERSION_NUMBER = 0x00000001; + +struct LedsRpmData +{ + FLOAT currentRPM; + FLOAT rpmFirstLedTurnsOn; + FLOAT rpmRedLine; +}; + +struct WheelData +{ + DWORD size; + DWORD versionNbr; + LedsRpmData rpmData; +}; +``` + +The implementation zeroes both structures, fills `size`, version `1`, and the +three floats, then performs: + +```cpp +data_.dwSize = sizeof(DIEFFESCAPE); +data_.dwCommand = ESCAPE_COMMAND_LEDS; +data_.lpvInBuffer = &wheelData_; +data_.cbInBuffer = sizeof(wheelData_); +hr = deviceHandle->Escape(&data_); +``` + +Reproducibility hashes: + +| SDK path | SHA-256 | +|---|---| +| `Samples/.../LogiIndependant.h` | `5061E7A180E91AC03BC325AB3AB71B1E62B0B836C7AC0D733C94EE5793535216` | +| `Samples/.../LogiIndependant.cpp` | `BC3F18FE5D238FB27E1F47A1B0CFD58B27B54EB6B97778CF09C3186C951BF384` | + +## Relationship To RS50 Display Game Data + +The public 2018 RPM and internal current display paths share these structural +properties: + +| Layer | Official 2018 RPM API | Installed RS50 display extension | +|---|---|---| +| Application handle | `LPDIRECTINPUTDEVICE8` | `LPDIRECTINPUTDEVICE8` | +| Entry point | `IDirectInputDevice8::Escape` | `IDirectInputDevice8::Escape` | +| Outer command | `0` | `4` | +| Version field | `1` | `1` at input offset 4 | +| Payload | three RPM floats | inner command plus typed layout fields | +| Public header/API | yes | no identified export or header | + +This comparison strengthens the recovered producer path: + +```text +game -> DirectInput Escape -> Logitech force-feedback driver + -> internal feature adapter -> wheel HID++ feature +``` + +It also identifies the safest future integration boundary. A native C++ +producer using a real `LPDIRECTINPUTDEVICE8` and the driver's typed in-process +ABI is more faithful than manufacturing a raw HID packet. Text-bearing OLED +layouts still contain live MSVC `std::string` objects, so they must not be +packed manually from C# or replayed as captured process memory. + +## Safety Boundary + +This inspection does not authorize sending outer command `4`, any display +setter, or any raw HID++ function-3 report. A future physical validation should +first use a legitimate Logitech/game producer if one is found. If an explicit +test is later approved, begin with support queries and stop on any unexpected +force feedback, device movement, or display behavior. diff --git a/LogiDynamicDash/docs/evidence/RS50_RPM_LIVE_FEED_2026-07-22.md b/LogiDynamicDash/docs/evidence/RS50_RPM_LIVE_FEED_2026-07-22.md new file mode 100644 index 0000000..3f81eb9 --- /dev/null +++ b/LogiDynamicDash/docs/evidence/RS50_RPM_LIVE_FEED_2026-07-22.md @@ -0,0 +1,51 @@ +# RS50 Game RPM Live-Feed Capture + +## Result + +A controlled idle/revving USB capture confirmed that a game plus G HUB can +stream live telemetry-derived state to the RS50. In this session the stream +targeted the RPM LED feature only. It did not call the Dynamic OLED feature. + +This is useful negative evidence: a working game integration and visibly +changing RPM LEDs are not sufficient to activate `DisplayGameData`. + +## Local Capture Metadata + +The raw captures remain local and are excluded from Git. + +| Capture | Duration | SHA-256 | +|---|---:|---| +| `2026-07-22_rev_idle.pcapng` | 18.121068 s | `47F189ABBFB92A1B0EF2CA51C654264390C7194912BCE1233DBDDC91B73A5413` | +| `2026-07-22_rev_revving.pcapng` | 35.300351 s | `85838269B8FC070B6FB51D49D0E4CD376202D7CAE28DC59CCE074923194FAEDD` | + +Both captures contained the physical RS50 at USB bus 1, address 4, VID +`0x046D`, PID `0xC276`. + +## Offline Analysis + +The idle capture contained no host HID++ reports to the RS50 during the +selected interval. The revving capture contained 1,452 host reports associated +with base runtime index `0x0B`: + +- 726 short function-`2` reports with one fixed signature; +- 726 long function-`6` reports with 11 distinct signatures; +- the changing function-`6` field covered every state from 0 through 10; +- the activity ran from 4.211090 to 30.314614 seconds in the capture. + +The previously reconstructed startup FeatureSet catalog maps base runtime +`0x0B` to public feature `0x807A`, named `RPM Indicator` by G HUB. + +Direct packet filtering found zero host reports to base runtime `0x12` in both +captures. That runtime maps to feature `0x8130`, `Display Game Data`. + +## Interpretation Boundary + +The capture proves that the telemetry producer and G HUB were capable of live +wheel updates during the session. It does not identify the game telemetry +source, establish the OLED HomeScreen selection, or prove that this game knows +about the Dynamic display API. Therefore it is a negative `0x8130` result, not +evidence that the OLED feature is unusable. + +The next decisive passive test still requires a legitimate producer that is +known to request a display layout, followed by a device-scoped capture looking +for runtime `0x12`, functions `0`, `1`, or `3`. diff --git a/LogiDynamicDash/docs/evidence/RS50_STATIC_LAYOUT_J_SUCCESS_2026-07-27.md b/LogiDynamicDash/docs/evidence/RS50_STATIC_LAYOUT_J_SUCCESS_2026-07-27.md new file mode 100644 index 0000000..c12a3c4 --- /dev/null +++ b/LogiDynamicDash/docs/evidence/RS50_STATIC_LAYOUT_J_SUCCESS_2026-07-27.md @@ -0,0 +1,124 @@ +# RS50 Static Layout J OLED Success + +Date: 2026-07-27 local / 2026-07-28 UTC + +## Result + +Build C demonstrated the complete physical Dynamic OLED path on a Logitech +RS50: + +```text +application + -> DirectInput Escape command 4 / inner command 22 + -> signed Logitech force-feedback driver + -> HID++ public feature 0x8130, function 3 + -> firmware-rendered Layout J + -> visible OLED text +``` + +This is the project's first confirmed write to the Dynamic OLED. It is a +single static update, not live telemetry. + +## Guarded Invocation + +The operator explicitly authorized one execution while G HUB was closed, the +RS50 was awake, Dynamic showed the Test fallback, and USBPcap was recording. +The native bridge contained only one fixed setter payload: + +```text +game-facing argument 1: LOGIDYNAMICDASH +game-facing argument 2: RS50 +game-facing argument 3: OLED LINK +game-facing argument 4: TEST 1 +``` + +The call ran once from `2026-07-28T04:53:02.469Z` to +`2026-07-28T04:53:02.474Z`: + +```text +Product: Logitech G HUB RS50 (USB) +VID: 0x046D +PID: 0xC276 +Acquired: 1 +Cooperative HRESULT: 0x00000000 +Data format HRESULT: 0x00000000 +Acquire HRESULT: 0x00000000 +Escape HRESULT: 0x00000000 +Unacquire HRESULT: 0x00000000 +Inner command: 22 +``` + +No retry, SetIdle, or cleanup write was sent. + +## Visual Evidence + +The operator reported `FUNCIONO!` and supplied `IMG_2822.jpeg`. The photograph +shows these four centered rows from top to bottom: + +```text +RS50 +LOGIDYNAMI +TEST 1 +OLED LINK +``` + +The second row is exactly ten characters. This is not random corruption: it +matches the captured function-`3` payload and the previously recovered +`19/10/19/10` wire-field capacities. + +The physical result establishes that the Logitech DirectInput adapter maps +the four game-facing Layout J strings into visual/wire fields as follows: + +| Visual row / wire field | Capacity | Game-facing argument | +| ---: | ---: | ---: | +| 1 | 19 | 2 | +| 2 | 10 | 1 | +| 3 | 19 | 4 | +| 4 | 10 | 3 | + +Therefore a future DirectInput API that accepts desired visual rows +`R1/R2/R3/R4` must construct its x64 MSVC input strings in the order +`R2/R1/R4/R3`. The existing transport-free function-`3` encoder remains +wire-oriented and already uses visual order. + +## USBPcap Evidence + +Baseline: +`2026-07-27_rs50_static_layout_j_baseline.pcapng` + +Setter capture: +`2026-07-27_rs50_static_layout_j_attempt_1.pcapng` + +The baseline contained zero HID++ reports. The setter capture contained: + +- 52 parsed HID++ reports: 26 HOST and 26 DEVICE; +- 26 exact response-header matches; +- zero unmatched HOST requests; +- Root discovery of public feature `0x8130` at runtime index `0x12`; +- one function-`0` layout-count request and response; +- ten function-`1` descriptor requests and responses; +- exactly one function-`3` setter request and response. + +The analyzer decoded the sole HOST function-`3` request as: + +```text +set layout J: texts "RS50", "LOGIDYNAMI", "TEST 1", "OLED LINK" +``` + +The DEVICE returned a matched function-`3` response with no HID++ error. The +visible OLED text and captured bytes independently agree. + +## Boundary and Next Gate + +This result confirms static Layout J output through the installed Logitech +driver. It does not yet establish a safe sustained update rate, device-loss +behavior, expiry timing, or live telemetry loop. + +The operator explicitly confirmed that no torque, LED, or wheel-movement +change occurred; the OLED was the only observed physical change. This closes +the Build C safety gate for this RS50 and installed driver build. + +Build D may now implement a caller-controlled native Layout J API that +validates visual-row limits, performs the proven argument permutation, +coalesces identical frames, and initially caps output at 5 Hz. Physical live +telemetry still requires a separately approved captured test. diff --git a/LogiDynamicExplorer.Tests/ExplorerOptionsTests.cs b/LogiDynamicExplorer.Tests/ExplorerOptionsTests.cs index 10b05a0..8dea2ae 100644 --- a/LogiDynamicExplorer.Tests/ExplorerOptionsTests.cs +++ b/LogiDynamicExplorer.Tests/ExplorerOptionsTests.cs @@ -52,6 +52,45 @@ public void Parse_BatchAnalysisWithHardwareMode_ReturnsError() result.Error); } + [Fact] + public void Parse_OfflineComparison_ReturnsBothFiles() + { + ExplorerOptions result = ExplorerOptions.Parse( + ["--compare-reports", "baseline.tsv", "query.tsv"]); + + Assert.Null(result.Error); + Assert.Equal("baseline.tsv", result.CompareBaselineFile); + Assert.Equal("query.tsv", result.CompareCandidateFile); + Assert.Null(result.MonitorCollection); + } + + [Fact] + public void Parse_ComparisonWithoutCandidate_ReturnsError() + { + ExplorerOptions result = ExplorerOptions.Parse( + ["--compare-reports", "baseline.tsv"]); + + Assert.Equal( + "--compare-reports requires baseline and candidate file paths.", + result.Error); + } + + [Fact] + public void Parse_ComparisonWithHardwareMode_ReturnsError() + { + ExplorerOptions result = ExplorerOptions.Parse( + [ + "--compare-reports", + "baseline.tsv", + "query.tsv", + "--inventory" + ]); + + Assert.Equal( + "--compare-reports cannot be combined with hardware modes.", + result.Error); + } + [Fact] public void Parse_TimeLimitedMonitor_ReturnsNormalizedOptions() { diff --git a/LogiDynamicExplorer.Tests/HidReportBatchAnalyzerTests.cs b/LogiDynamicExplorer.Tests/HidReportBatchAnalyzerTests.cs index a2ac4eb..f794514 100644 --- a/LogiDynamicExplorer.Tests/HidReportBatchAnalyzerTests.cs +++ b/LogiDynamicExplorer.Tests/HidReportBatchAnalyzerTests.cs @@ -59,4 +59,129 @@ public void Analyze_InvalidLineIsReportedWithoutStoppingAnalysis() Assert.Contains("Invalid lines: 1", result); Assert.Contains(" line 1: unknown direction 'SIDEWAYS'", result); } + + [Fact] + public void Analyze_ReconstructsFeatureSetCatalogAcrossReportSizes() + { + string longResponse = + "12ff011b81300000" + new string('0', 112); + + IReadOnlyList result = HidReportBatchAnalyzer.Analyze( + [ + "HOST\t1.000\t10ff011b120000", + $"DEVICE\t1.001\t{longResponse}", + "HOST\t1.002\t10ff122b000000" + ]); + + Assert.Contains( + " device 0xFF, runtime 0x12 -> feature 0x8130 " + + "(Display Game Data; G HUB static name), flags 0x00, version 0; " + + "HOST operational requests 1", + result); + } + + [Fact] + public void Analyze_DoesNotTreatFeatureSetEnumerationAsOperationalUse() + { + IReadOnlyList result = HidReportBatchAnalyzer.Analyze( + [ + "HOST\t10ff011b120000", + "DEVICE\t11ff011b8130000000000000000000000000000000" + ]); + + Assert.Contains( + " device 0xFF, runtime 0x12 -> feature 0x8130 " + + "(Display Game Data; G HUB static name), flags 0x00, version 0; " + + "HOST operational requests 0", + result); + Assert.Contains( + " device 0xFF, runtime 0x12: no operational reports", + result); + } + + [Fact] + public void Analyze_DecodesResolvedDisplayGameDataActivity() + { + string layoutCReport = + "12ff123b022a" + new string('0', 116); + + IReadOnlyList result = HidReportBatchAnalyzer.Analyze( + [ + "HOST\t10ff011b120000", + "DEVICE\t11ff011b8130000000000000000000000000000000", + $"HOST\t{layoutCReport}" + ]); + + Assert.Contains( + " HOST device 0xFF, runtime 0x12, function 0x03: " + + "1 reports, 1 parameter signatures", + result); + Assert.Contains(" set layout C: value 42/255 (16.5%)", result); + } + + [Fact] + public void Analyze_DecodesDisplayDescriptorIndexAndOneBasedId() + { + string descriptorResponse = + "11ff121b090a130a130a" + new string('0', 20); + + IReadOnlyList result = HidReportBatchAnalyzer.Analyze( + [ + "HOST\t10ff011b120000", + "DEVICE\t11ff011b8130000000000000000000000000000000", + "HOST\t10ff121b090000", + $"DEVICE\t{descriptorResponse}" + ]); + + Assert.Contains( + " layout J, index 9, ID 10, capabilities 13 0A 13 0A", + result); + } + + [Fact] + public void Analyze_CountsLongDisplayGameDataRequestAsOperationalUse() + { + string layoutIReport = + "12ff123b08" + new string('0', 118); + + IReadOnlyList result = HidReportBatchAnalyzer.Analyze( + [ + "HOST\t10ff011b120000", + "DEVICE\t11ff011b8130000000000000000000000000000000", + $"HOST\t{layoutIReport}" + ]); + + Assert.Contains( + " device 0xFF, runtime 0x12 -> feature 0x8130 " + + "(Display Game Data; G HUB static name), flags 0x00, version 0; " + + "HOST operational requests 1", + result); + Assert.Contains( + " HOST device 0xFF, runtime 0x12, function 0x03: " + + "1 reports, 1 parameter signatures", + result); + } + + [Fact] + public void Analyze_MatchesVeryLongRootResponseAndDiscoversDisplayFeature() + { + string response = + "12ff000e120000" + new string('0', 114); + + IReadOnlyList result = HidReportBatchAnalyzer.Analyze( + [ + "HOST\t29\t1785212625.021284\t10ff000e813000", + $"DEVICE\t31\t1785212625.024044\t{response}" + ]); + + Assert.Contains("Exact HID++ response headers: 1", result); + Assert.Contains( + " device 0xFF, runtime 0x12 -> feature 0x8130 " + + "(Display Game Data; G HUB static name), flags 0x00, version 0; " + + "HOST operational requests 0", + result); + Assert.Contains( + " device 0xFF, runtime 0x12: no operational reports", + result); + } } diff --git a/LogiDynamicExplorer.Tests/HidReportBatchComparerTests.cs b/LogiDynamicExplorer.Tests/HidReportBatchComparerTests.cs new file mode 100644 index 0000000..1405e25 --- /dev/null +++ b/LogiDynamicExplorer.Tests/HidReportBatchComparerTests.cs @@ -0,0 +1,61 @@ +using LogiDynamicExplorer.Diagnostics; + +namespace LogiDynamicExplorer.Tests; + +public sealed class HidReportBatchComparerTests +{ + [Fact] + public void Compare_ReportsOnlyPositiveExactCountDeltas() + { + string[] baseline = + [ + "HOST\t1\t1000.0\t10ff052b010203", + "DEVICE\t2\t1000.1\t11ff052b0102030000000000000000000000000000" + ]; + string[] candidate = + [ + "HOST\t3\t1005.0\t10ff052b010203", + "HOST\t4\t1005.1\t10ff062b040506", + "HOST\t5\t1005.2\t10ff062b040506", + "DEVICE\t6\t1005.3\t11ff052b0102030000000000000000000000000000" + ]; + + IReadOnlyList result = + HidReportBatchComparer.Compare(baseline, candidate); + + Assert.Contains("Baseline HID++ reports: 2", result); + Assert.Contains("Candidate HID++ reports: 4", result); + Assert.Contains( + " +2 HOST ID 0x10, device 0xFF, feature 0x06, " + + "function 0x02, SW-ID 0x0B, parameters 040506", + result); + Assert.DoesNotContain( + result, + line => line.StartsWith(" +") && line.Contains("feature 0x05")); + } + + [Fact] + public void Compare_DoesNotTreatTimestampsAsPartOfSignature() + { + IReadOnlyList result = HidReportBatchComparer.Compare( + ["HOST\t1\t1000.0\t10ff052b010203"], + ["HOST\t99\t2000.0\t10ff052b010203"]); + + Assert.Contains(" (none)", result); + } + + [Fact] + public void Compare_RejectsNonDirectionalAndIgnoresNonHidppLines() + { + IReadOnlyList result = HidReportBatchComparer.Compare( + ["UNKNOWN\t1\t1000.0\t10ff052b010203"], + ["DEVICE\t2\t1001.0\t01020304"]); + + Assert.Contains("Baseline HID++ reports: 0", result); + Assert.Contains("Candidate HID++ reports: 0", result); + Assert.Contains("Baseline invalid lines: 1", result); + Assert.Contains("Candidate invalid lines: 0", result); + Assert.Contains("Baseline non-HID++ reports ignored: 0", result); + Assert.Contains("Candidate non-HID++ reports ignored: 1", result); + } +} diff --git a/LogiDynamicExplorer.Tests/Rs50DisplayGameDataDecoderTests.cs b/LogiDynamicExplorer.Tests/Rs50DisplayGameDataDecoderTests.cs new file mode 100644 index 0000000..cddd835 --- /dev/null +++ b/LogiDynamicExplorer.Tests/Rs50DisplayGameDataDecoderTests.cs @@ -0,0 +1,70 @@ +using LogiDynamicExplorer.Decoders; + +namespace LogiDynamicExplorer.Tests; + +public sealed class Rs50DisplayGameDataDecoderTests +{ + [Fact] + public void DecodeDeviceResponse_DescribesFirmwareLayoutDescriptor() + { + string result = Rs50DisplayGameDataDecoder.DecodeDeviceResponse( + 0x01, + [0x08, 0x09, 0x13, 0x0A, 0x13, 0x0A]); + + Assert.Equal( + "layout I, index 8, ID 9, capabilities 13 0A 13 0A", + result); + } + + [Fact] + public void DecodeDeviceResponse_FlagsInconsistentIndexAndId() + { + string result = Rs50DisplayGameDataDecoder.DecodeDeviceResponse( + 0x01, + [0x08, 0x0A, 0x13, 0x0A, 0x13, 0x0A]); + + Assert.EndsWith(" (inconsistent index/ID)", result); + } + + [Fact] + public void DecodeHostCommand_DecodesFourTextLayoutAndPadding() + { + byte[] parameters = new byte[60]; + parameters[0] = 8; + "SPEED"u8.CopyTo(parameters.AsSpan(1)); + "KMH"u8.CopyTo(parameters.AsSpan(20)); + "GEAR"u8.CopyTo(parameters.AsSpan(30)); + "3"u8.CopyTo(parameters.AsSpan(49)); + + string result = Rs50DisplayGameDataDecoder.DecodeHostCommand( + 0x03, + parameters); + + Assert.Equal( + "set layout I: texts \"SPEED\", \"KMH\", \"GEAR\", \"3\"", + result); + } + + [Fact] + public void DecodeHostCommand_IncompleteLayoutDoesNotThrow() + { + string result = Rs50DisplayGameDataDecoder.DecodeHostCommand( + 0x03, + [0x04, 0x01]); + + Assert.Equal( + "set layout E: values 1/255 (0.4%) (incomplete 1/2), " + + "texts (missing), (missing)", + result); + } + + [Fact] + public void DecodeHostCommand_InterpretsRecoveredNormalizedValue() + { + string result = Rs50DisplayGameDataDecoder.DecodeHostCommand( + 0x03, + [0x02, 0x80]); + + Assert.Equal("set layout C: value 128/255 (50.2%)", result); + } +} diff --git a/LogiDynamicExplorer.Tests/Rs50DisplayGameDataDirectInputAbiTests.cs b/LogiDynamicExplorer.Tests/Rs50DisplayGameDataDirectInputAbiTests.cs new file mode 100644 index 0000000..18e565b --- /dev/null +++ b/LogiDynamicExplorer.Tests/Rs50DisplayGameDataDirectInputAbiTests.cs @@ -0,0 +1,172 @@ +using System.Runtime.InteropServices; +using LogiDynamicExplorer.Protocol; + +namespace LogiDynamicExplorer.Tests; + +public sealed class Rs50DisplayGameDataDirectInputAbiTests +{ + [Fact] + public void DirectInputEscape32_MatchesPublicNativeLayout() + { + Assert.Equal( + 24, + Marshal.SizeOf< + Rs50DisplayGameDataDirectInputAbi.DirectInputEscape32Layout>()); + Assert.Equal( + 4, + OffsetOf< + Rs50DisplayGameDataDirectInputAbi.DirectInputEscape32Layout>( + "Command")); + Assert.Equal( + 8, + OffsetOf< + Rs50DisplayGameDataDirectInputAbi.DirectInputEscape32Layout>( + "InputPointer")); + Assert.Equal( + 12, + OffsetOf< + Rs50DisplayGameDataDirectInputAbi.DirectInputEscape32Layout>( + "InputSize")); + Assert.Equal( + 16, + OffsetOf< + Rs50DisplayGameDataDirectInputAbi.DirectInputEscape32Layout>( + "OutputPointer")); + Assert.Equal( + 20, + OffsetOf< + Rs50DisplayGameDataDirectInputAbi.DirectInputEscape32Layout>( + "OutputSize")); + } + + [Fact] + public void DirectInputEscape64_MatchesPublicNativeLayout() + { + Assert.Equal( + 40, + Marshal.SizeOf< + Rs50DisplayGameDataDirectInputAbi.DirectInputEscape64Layout>()); + Assert.Equal( + 4, + OffsetOf< + Rs50DisplayGameDataDirectInputAbi.DirectInputEscape64Layout>( + "Command")); + Assert.Equal( + 8, + OffsetOf< + Rs50DisplayGameDataDirectInputAbi.DirectInputEscape64Layout>( + "InputPointer")); + Assert.Equal( + 16, + OffsetOf< + Rs50DisplayGameDataDirectInputAbi.DirectInputEscape64Layout>( + "InputSize")); + Assert.Equal( + 24, + OffsetOf< + Rs50DisplayGameDataDirectInputAbi.DirectInputEscape64Layout>( + "OutputPointer")); + Assert.Equal( + 32, + OffsetOf< + Rs50DisplayGameDataDirectInputAbi.DirectInputEscape64Layout>( + "OutputSize")); + } + + [Fact] + public void Header_UsesRecoveredTwelveByteEnvelope() + { + Assert.Equal(12, Marshal.SizeOf()); + Assert.Equal(0, OffsetOf("Size")); + Assert.Equal(4, OffsetOf("Version")); + Assert.Equal(8, OffsetOf("Command")); + } + + [Fact] + public void OpaqueMsvcString_MatchesInstalledX64AbiSize() + { + Assert.Equal( + 32, + Marshal.SizeOf< + Rs50DisplayGameDataDirectInputAbi.MsvcString64Layout>()); + } + + [Fact] + public void LayoutC_HasFloatAtOffsetTwelveAndSizeSixteen() + { + Assert.Equal( + 16, + Marshal.SizeOf()); + Assert.Equal( + 12, + OffsetOf("Value")); + } + + [Fact] + public void LayoutD_MatchesTwoFloatsAndPackedString() + { + Assert.Equal( + 52, + Marshal.SizeOf()); + Assert.Equal( + 12, + OffsetOf("FirstValue")); + Assert.Equal( + 16, + OffsetOf("SecondValue")); + Assert.Equal( + 20, + OffsetOf("Text")); + } + + [Fact] + public void LayoutE_MatchesTwoFloatsAndTwoPackedStrings() + { + Assert.Equal( + 84, + Marshal.SizeOf()); + Assert.Equal( + 20, + OffsetOf("FirstText")); + Assert.Equal( + 52, + OffsetOf("SecondText")); + } + + [Fact] + public void TwoTextLayouts_MatchPackedStringOffsetsAndSize() + { + Assert.Equal( + 76, + Marshal.SizeOf()); + Assert.Equal( + 12, + OffsetOf("FirstText")); + Assert.Equal( + 44, + OffsetOf("SecondText")); + } + + [Fact] + public void FourTextLayouts_MatchPackedStringOffsetsAndSize() + { + Assert.Equal( + 140, + Marshal.SizeOf()); + Assert.Equal( + 12, + OffsetOf("FirstText")); + Assert.Equal( + 44, + OffsetOf("SecondText")); + Assert.Equal( + 76, + OffsetOf("ThirdText")); + Assert.Equal( + 108, + OffsetOf("FourthText")); + } + + private static int OffsetOf(string fieldName) => + checked((int)Marshal.OffsetOf(fieldName)); +} diff --git a/LogiDynamicExplorer.Tests/Rs50DisplayGameDataDirectInputContractTests.cs b/LogiDynamicExplorer.Tests/Rs50DisplayGameDataDirectInputContractTests.cs new file mode 100644 index 0000000..b435991 --- /dev/null +++ b/LogiDynamicExplorer.Tests/Rs50DisplayGameDataDirectInputContractTests.cs @@ -0,0 +1,102 @@ +using LogiDynamicExplorer.Protocol; + +namespace LogiDynamicExplorer.Tests; + +public sealed class Rs50DisplayGameDataDirectInputContractTests +{ + [Fact] + public void EnvelopeConstants_MatchRecoveredDriverAbi() + { + Assert.Equal(4u, Rs50DisplayGameDataDirectInputContract.OuterEscapeCommand); + Assert.Equal(1u, Rs50DisplayGameDataDirectInputContract.Version); + Assert.Equal(12, Rs50DisplayGameDataDirectInputContract.HeaderSize); + } + + [Theory] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.SetIdle, 12)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.QueryDisplaySupport, 12)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.QueryLayoutJ, 12)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.SetLayoutA, 12)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.SetLayoutB, 12)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.SetLayoutC, 16)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.SetLayoutD, 52)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.SetLayoutE, 84)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.SetLayoutF, 76)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.SetLayoutG, 76)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.SetLayoutH, 76)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.SetLayoutI, 140)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.SetLayoutJ, 140)] + public void MinimumInputSize_MatchesDriverChecks( + byte commandValue, + int expected) + { + var command = + (Rs50DisplayGameDataDirectInputContract.InnerCommand)commandValue; + + Assert.Equal( + expected, + Rs50DisplayGameDataDirectInputContract.GetMinimumInputSize(command)); + } + + [Theory] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.QueryDisplaySupport, 1)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.QueryLayoutA, 1)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.QueryLayoutB, 1)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.QueryLayoutC, 1)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.QueryLayoutD, 4)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.QueryLayoutE, 6)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.QueryLayoutF, 6)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.QueryLayoutG, 6)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.QueryLayoutH, 6)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.QueryLayoutI, 10)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.QueryLayoutJ, 10)] + public void MinimumOutputSize_MatchesDriverChecks( + byte commandValue, + int expected) + { + var command = + (Rs50DisplayGameDataDirectInputContract.InnerCommand)commandValue; + + Assert.True(Rs50DisplayGameDataDirectInputContract.IsSupportQuery(command)); + Assert.False(Rs50DisplayGameDataDirectInputContract.IsSetter(command)); + Assert.Equal( + expected, + Rs50DisplayGameDataDirectInputContract.GetMinimumOutputSize(command)); + Assert.Equal( + 1, + Rs50DisplayGameDataDirectInputContract.GetDefinedOutputSize(command)); + } + + [Theory] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.SetIdle)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.SetLayoutA)] + [InlineData((byte)Rs50DisplayGameDataDirectInputContract.InnerCommand.SetLayoutJ)] + public void Setters_HaveNoOutputContract(byte commandValue) + { + var command = + (Rs50DisplayGameDataDirectInputContract.InnerCommand)commandValue; + + Assert.True(Rs50DisplayGameDataDirectInputContract.IsSetter(command)); + Assert.False(Rs50DisplayGameDataDirectInputContract.IsSupportQuery(command)); + Assert.Equal( + 0, + Rs50DisplayGameDataDirectInputContract.GetMinimumOutputSize(command)); + Assert.Equal( + 0, + Rs50DisplayGameDataDirectInputContract.GetDefinedOutputSize(command)); + } + + [Fact] + public void UnknownCommand_IsRejected() + { + var unknown = + (Rs50DisplayGameDataDirectInputContract.InnerCommand)byte.MaxValue; + + Assert.Throws( + () => Rs50DisplayGameDataDirectInputContract.GetMinimumInputSize(unknown)); + Assert.Throws( + () => Rs50DisplayGameDataDirectInputContract.GetMinimumOutputSize(unknown)); + Assert.Throws( + () => Rs50DisplayGameDataDirectInputContract.GetDefinedOutputSize(unknown)); + } +} diff --git a/LogiDynamicExplorer.Tests/Rs50DisplayGameDataPayloadEncoderTests.cs b/LogiDynamicExplorer.Tests/Rs50DisplayGameDataPayloadEncoderTests.cs new file mode 100644 index 0000000..a9ab2e1 --- /dev/null +++ b/LogiDynamicExplorer.Tests/Rs50DisplayGameDataPayloadEncoderTests.cs @@ -0,0 +1,133 @@ +using LogiDynamicExplorer.Protocol; + +namespace LogiDynamicExplorer.Tests; + +public sealed class Rs50DisplayGameDataPayloadEncoderTests +{ + [Fact] + public void EncodeDataFreeLayouts_ReturnOnlyTheirLayoutIndex() + { + Assert.Equal([0], Rs50DisplayGameDataPayloadEncoder.EncodeLayoutA()); + Assert.Equal([1], Rs50DisplayGameDataPayloadEncoder.EncodeLayoutB()); + } + + [Theory] + [InlineData(-1.0f, 0)] + [InlineData(0.0f, 0)] + [InlineData(0.5f, 128)] + [InlineData(1.0f, 255)] + [InlineData(2.0f, 255)] + public void EncodeLayoutC_UsesRecoveredNormalizedConversion( + float value, + byte expected) + { + Assert.Equal( + [2, expected], + Rs50DisplayGameDataPayloadEncoder.EncodeLayoutC(value)); + } + + [Theory] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + [InlineData(float.NegativeInfinity)] + public void EncodeLayoutC_RejectsNonFiniteValues(float value) + { + Assert.Throws( + () => Rs50DisplayGameDataPayloadEncoder.EncodeLayoutC(value)); + } + + [Fact] + public void EncodeLayoutD_UsesFixedFieldsAndFirmwareTextSanitization() + { + byte[] result = Rs50DisplayGameDataPayloadEncoder.EncodeLayoutD( + 0.25f, + 0.75f, + "abé"); + + Assert.Equal(14, result.Length); + Assert.Equal(3, result[0]); + Assert.Equal(64, result[1]); + Assert.Equal(191, result[2]); + Assert.Equal("AB?"u8.ToArray(), result[3..6]); + Assert.All(result[6..], value => Assert.Equal(0, value)); + } + + [Fact] + public void EncodeLayoutE_EncodesRightFieldBeforeLeftFieldOnWire() + { + byte[] result = Rs50DisplayGameDataPayloadEncoder.EncodeLayoutE( + mainGaugeValue: 0.0f, + thinIndicatorValue: 1.0f, + rightText: "N", + leftText: "speed"); + + Assert.Equal(13, result.Length); + Assert.Equal([4, 0, 255], result[..3]); + Assert.Equal([.. "N"u8, 0, 0], result[3..6]); + Assert.Equal([.. "SPEED"u8, 0, 0], result[6..13]); + } + + [Theory] + [InlineData('F', 5, 5)] + [InlineData('G', 6, 5)] + [InlineData('H', 7, 32)] + public void EncodeTwoTextLayouts_UseRecoveredSizes( + char layout, + byte expectedIndex, + int expectedLength) + { + byte[] result = layout switch + { + 'F' => Rs50DisplayGameDataPayloadEncoder.EncodeLayoutF("N", "123"), + 'G' => Rs50DisplayGameDataPayloadEncoder.EncodeLayoutG("R", "456"), + 'H' => Rs50DisplayGameDataPayloadEncoder.EncodeLayoutH("TITLE", "VALUE"), + _ => throw new InvalidOperationException() + }; + + Assert.Equal(expectedLength, result.Length); + Assert.Equal(expectedIndex, result[0]); + } + + [Fact] + public void EncodeLayoutI_UsesMaximum59BytePayloadAndFourSlots() + { + byte[] result = Rs50DisplayGameDataPayloadEncoder.EncodeLayoutI( + "speed", + "kmh", + "gear", + "3"); + + Assert.Equal(59, result.Length); + Assert.Equal(8, result[0]); + Assert.Equal("SPEED"u8.ToArray(), result[1..6]); + Assert.Equal("KMH"u8.ToArray(), result[20..23]); + Assert.Equal("GEAR"u8.ToArray(), result[30..34]); + Assert.Equal((byte)'3', result[49]); + } + + [Fact] + public void EncodeLayoutJ_AllowsExactMaximumFieldLengths() + { + byte[] result = Rs50DisplayGameDataPayloadEncoder.EncodeLayoutJ( + new string('A', 19), + new string('B', 10), + new string('C', 19), + new string('D', 10)); + + Assert.Equal(59, result.Length); + Assert.Equal(9, result[0]); + Assert.All(result[1..20], value => Assert.Equal((byte)'A', value)); + Assert.All(result[20..30], value => Assert.Equal((byte)'B', value)); + Assert.All(result[30..49], value => Assert.Equal((byte)'C', value)); + Assert.All(result[49..59], value => Assert.Equal((byte)'D', value)); + } + + [Fact] + public void EncodeText_RejectsOversizeAndEmbeddedNul() + { + Assert.Throws( + () => Rs50DisplayGameDataPayloadEncoder.EncodeLayoutF("NN", "123")); + Assert.Throws( + () => Rs50DisplayGameDataPayloadEncoder.EncodeLayoutH("A\0B", "OK")); + } +} diff --git a/LogiDynamicExplorer.Tests/Rs50HidppLongReportDecoderTests.cs b/LogiDynamicExplorer.Tests/Rs50HidppLongReportDecoderTests.cs index 05dc476..1158312 100644 --- a/LogiDynamicExplorer.Tests/Rs50HidppLongReportDecoderTests.cs +++ b/LogiDynamicExplorer.Tests/Rs50HidppLongReportDecoderTests.cs @@ -40,6 +40,44 @@ public void Decode_FeatureSetResponse_DescribesRestrictedUnknownFeature() result); } + [Fact] + public void Decode_FeatureSetResponse_NamesDisplayGameDataEvidence() + { + byte[] report = + [ + 0x11, 0xFF, 0x01, 0x1B, + 0x81, 0x30, 0x00, 0x00 + ]; + + string result = Rs50ReportDecoder.Decode(report); + + Assert.Equal( + "HID++ FeatureSet: device 0xFF, " + + "feature 0x8130 (Display Game Data; G HUB static name), " + + "flags 0x00 (public), version 0, SW-ID 0x0B", + result); + } + + [Theory] + [InlineData(0x80, 0x7A, "RPM Indicator")] + [InlineData(0x81, 0x39, "TRUEFORCE")] + [InlineData(0x81, 0x40, "FFB Filter")] + public void Decode_FeatureSetResponse_NamesKnownWheelNeighbors( + byte featureHigh, + byte featureLow, + string expectedName) + { + byte[] report = + [ + 0x11, 0xFF, 0x01, 0x1B, + featureHigh, featureLow, 0x00, 0x00 + ]; + + string result = Rs50ReportDecoder.Decode(report); + + Assert.Contains($"({expectedName}; G HUB static name)", result); + } + [Fact] public void Decode_NonFeatureSetReport_PreservesHeaderFields() { diff --git a/LogiDynamicExplorer/Decoders/HidppFeatureNames.cs b/LogiDynamicExplorer/Decoders/HidppFeatureNames.cs new file mode 100644 index 0000000..941fb69 --- /dev/null +++ b/LogiDynamicExplorer/Decoders/HidppFeatureNames.cs @@ -0,0 +1,31 @@ +namespace LogiDynamicExplorer.Decoders; + +internal static class HidppFeatureNames +{ + public static string Format(ushort featureId) + { + return featureId switch + { + 0x00C3 => " (SecureDFU)", + 0x807A => " (RPM Indicator; G HUB static name)", + 0x807B => " (RPM LED Pattern; G HUB static name)", + 0x8091 => " (per-key/LED matrix)", + 0x80A4 => " (Axis Response Curve; G HUB static name)", + 0x80D0 => " (Combined Pedals; G HUB static name)", + 0x8120 => " (Gaming Attachments; G HUB static name)", + 0x8123 => " (Force Feedback; G HUB static name)", + 0x8127 => " (Dual Clutch; G HUB static name)", + 0x8130 => " (Display Game Data; G HUB static name)", + 0x8132 => " (Axis Mapping; G HUB static name)", + 0x8133 => " (Global Damping; G HUB static name)", + 0x8134 => " (Brake Force; G HUB static name)", + 0x8135 => " (Pedal Status; G HUB static name)", + 0x8136 => " (Torque Limit; G HUB static name)", + 0x8137 => " (Configuration Profiles; G HUB static name)", + 0x8138 => " (Operating Range; G HUB static name)", + 0x8139 => " (TRUEFORCE; G HUB static name)", + 0x8140 => " (FFB Filter; G HUB static name)", + _ => string.Empty + }; + } +} diff --git a/LogiDynamicExplorer/Decoders/Rs50DisplayGameDataDecoder.cs b/LogiDynamicExplorer/Decoders/Rs50DisplayGameDataDecoder.cs new file mode 100644 index 0000000..90a88de --- /dev/null +++ b/LogiDynamicExplorer/Decoders/Rs50DisplayGameDataDecoder.cs @@ -0,0 +1,199 @@ +using System.Globalization; +using System.Text; + +namespace LogiDynamicExplorer.Decoders; + +internal static class Rs50DisplayGameDataDecoder +{ + private static readonly string[] LayoutNames = + [ + "A", "B", "C", "D", "E", + "F", "G", "H", "I", "J" + ]; + + public static string DecodeHostCommand( + byte functionId, + ReadOnlySpan parameters) + { + return functionId switch + { + 0x00 => "query layout count", + 0x01 => parameters.IsEmpty + ? "query layout descriptor (missing index)" + : $"query layout descriptor index {parameters[0]}", + 0x02 => "clear pending Dynamic data", + 0x03 => DecodeSetLayout(parameters), + _ => $"unknown function 0x{functionId:X2}" + }; + } + + public static string DecodeDeviceResponse( + byte functionId, + ReadOnlySpan parameters) + { + if (functionId == 0x00) + { + return parameters.IsEmpty + ? "layout count response (missing count)" + : $"layout count {parameters[0]}"; + } + + if (functionId == 0x01) + { + if (parameters.Length < 6) + { + return + $"layout descriptor response incomplete " + + $"({parameters.Length}/6 bytes)"; + } + + byte index = parameters[0]; + byte layoutId = parameters[1]; + string layoutName = index < LayoutNames.Length + ? LayoutNames[index] + : $"unknown-{index}"; + string consistency = layoutId == index + 1 + ? string.Empty + : " (inconsistent index/ID)"; + + return + $"layout {layoutName}, index {index}, ID {layoutId}, " + + $"capabilities {FormatHex(parameters.Slice(2, 4))}" + + consistency; + } + + return $"function 0x{functionId:X2} response"; + } + + private static string DecodeSetLayout(ReadOnlySpan parameters) + { + if (parameters.IsEmpty) + { + return "set layout (missing index)"; + } + + byte index = parameters[0]; + + return index switch + { + 0 => "set layout A", + 1 => "set layout B", + 2 => parameters.Length < 2 + ? "set layout C (missing byte field)" + : $"set layout C: value {FormatNormalizedValue(parameters[1])}", + 3 => + $"set layout D: values {FormatNormalizedValues(parameters, 1, 2)}, " + + $"text {FormatText(parameters, 3, 11)}", + 4 => + $"set layout E: values {FormatNormalizedValues(parameters, 1, 2)}, " + + $"texts {FormatText(parameters, 3, 3)}, " + + $"{FormatText(parameters, 6, 7)}", + 5 => DecodeTwoTextLayout("F", parameters, 1, 3), + 6 => DecodeTwoTextLayout("G", parameters, 1, 3), + 7 => DecodeTwoTextLayout("H", parameters, 21, 10), + 8 => DecodeFourTextLayout("I", parameters), + 9 => DecodeFourTextLayout("J", parameters), + _ => $"set unknown layout index {index}" + }; + } + + private static string DecodeTwoTextLayout( + string layoutName, + ReadOnlySpan parameters, + int firstLength, + int secondLength) + { + return + $"set layout {layoutName}: texts " + + $"{FormatText(parameters, 1, firstLength)}, " + + $"{FormatText(parameters, 1 + firstLength, secondLength)}"; + } + + private static string DecodeFourTextLayout( + string layoutName, + ReadOnlySpan parameters) + { + return + $"set layout {layoutName}: texts " + + $"{FormatText(parameters, 1, 19)}, " + + $"{FormatText(parameters, 20, 10)}, " + + $"{FormatText(parameters, 30, 19)}, " + + $"{FormatText(parameters, 49, 10)}"; + } + + private static string FormatNormalizedValues( + ReadOnlySpan parameters, + int offset, + int length) + { + if (offset >= parameters.Length) + { + return "(missing)"; + } + + int availableLength = Math.Min(length, parameters.Length - offset); + string value = string.Join( + ", ", + parameters + .Slice(offset, availableLength) + .ToArray() + .Select(FormatNormalizedValue)); + + return availableLength == length + ? value + : $"{value} (incomplete {availableLength}/{length})"; + } + + private static string FormatNormalizedValue(byte value) + { + double percentage = value * 100.0 / byte.MaxValue; + return + $"{value}/255 " + + $"({percentage.ToString("0.0", CultureInfo.InvariantCulture)}%)"; + } + + private static string FormatText( + ReadOnlySpan parameters, + int offset, + int maximumLength) + { + if (offset >= parameters.Length) + { + return "(missing)"; + } + + ReadOnlySpan field = parameters.Slice( + offset, + Math.Min(maximumLength, parameters.Length - offset)); + + int terminatorIndex = field.IndexOf((byte)0); + + if (terminatorIndex >= 0) + { + field = field[..terminatorIndex]; + } + + StringBuilder value = new(); + + foreach (byte character in field) + { + if (character is >= 0x20 and <= 0x7E) + { + value.Append((char)character); + } + else + { + value.Append($"\\x{character:X2}"); + } + } + + return $"\"{value}\""; + } + + private static string FormatHex(ReadOnlySpan bytes) + { + return string.Join( + " ", + bytes.ToArray().Select(value => value.ToString("X2"))); + } +} diff --git a/LogiDynamicExplorer/Decoders/Rs50HidppLongReportDecoder.cs b/LogiDynamicExplorer/Decoders/Rs50HidppLongReportDecoder.cs index d9ae1bb..b339c87 100644 --- a/LogiDynamicExplorer/Decoders/Rs50HidppLongReportDecoder.cs +++ b/LogiDynamicExplorer/Decoders/Rs50HidppLongReportDecoder.cs @@ -59,21 +59,11 @@ private static string DecodeFeatureSetResponse( return $"HID++ FeatureSet: device 0x{deviceIndex:X2}, " + - $"feature 0x{featureId:X4}{FormatFeatureName(featureId)}, " + + $"feature 0x{featureId:X4}{HidppFeatureNames.Format(featureId)}, " + $"flags 0x{flags:X2} ({FormatFlags(flags)}), " + $"version {version}, SW-ID 0x{softwareId:X2}"; } - private static string FormatFeatureName(ushort featureId) - { - return featureId switch - { - 0x00C3 => " (SecureDFU)", - 0x8091 => " (per-key/LED matrix)", - _ => string.Empty - }; - } - private static string FormatParameters(ReadOnlySpan parameters) { return parameters.IsEmpty diff --git a/LogiDynamicExplorer/Diagnostics/ExplorerOptions.cs b/LogiDynamicExplorer/Diagnostics/ExplorerOptions.cs index 9619e76..9750caf 100644 --- a/LogiDynamicExplorer/Diagnostics/ExplorerOptions.cs +++ b/LogiDynamicExplorer/Diagnostics/ExplorerOptions.cs @@ -5,6 +5,8 @@ internal sealed record ExplorerOptions( bool ShowHelp, string? DecodeReport, string? AnalyzeReportFile, + string? CompareBaselineFile, + string? CompareCandidateFile, string? MonitorCollection, TimeSpan? MonitorDuration, string? Error) @@ -18,6 +20,8 @@ public static ExplorerOptions Parse( bool showHelp = false; string? decodeReport = null; string? analyzeReportFile = null; + string? compareBaselineFile = null; + string? compareCandidateFile = null; string? monitorCollection = null; TimeSpan? monitorDuration = null; @@ -60,6 +64,22 @@ public static ExplorerOptions Parse( break; + case "--compare-reports": + if (!TryReadValue( + arguments, + ref index, + out compareBaselineFile) || + !TryReadValue( + arguments, + ref index, + out compareCandidateFile)) + { + return Invalid( + "--compare-reports requires baseline and candidate file paths."); + } + + break; + case "--monitor": if (!TryReadValue( arguments, @@ -118,6 +138,17 @@ monitorCollection is not null || "--decode-report and --analyze-reports cannot be combined."); } + int offlineModeCount = + (decodeReport is null ? 0 : 1) + + (analyzeReportFile is null ? 0 : 1) + + (compareBaselineFile is null ? 0 : 1); + + if (offlineModeCount > 1) + { + return Invalid( + "Offline decode, analysis, and comparison modes cannot be combined."); + } + if (analyzeReportFile is not null && (inventoryOnly || monitorCollection is not null || monitorDuration is not null)) { @@ -125,6 +156,15 @@ monitorCollection is not null || "--analyze-reports cannot be combined with hardware modes."); } + if (compareBaselineFile is not null && + (inventoryOnly || + monitorCollection is not null || + monitorDuration is not null)) + { + return Invalid( + "--compare-reports cannot be combined with hardware modes."); + } + if (monitorCollection is not null && monitorDuration is null) { return Invalid( @@ -142,6 +182,8 @@ monitorCollection is not null || showHelp, decodeReport, analyzeReportFile, + compareBaselineFile, + compareCandidateFile, monitorCollection, monitorDuration, Error: null); @@ -174,6 +216,8 @@ private static ExplorerOptions Invalid( ShowHelp: false, DecodeReport: null, AnalyzeReportFile: null, + CompareBaselineFile: null, + CompareCandidateFile: null, MonitorCollection: null, MonitorDuration: null, Error: error); diff --git a/LogiDynamicExplorer/Diagnostics/HidReportBatchAnalyzer.cs b/LogiDynamicExplorer/Diagnostics/HidReportBatchAnalyzer.cs index 7adeea8..642e6aa 100644 --- a/LogiDynamicExplorer/Diagnostics/HidReportBatchAnalyzer.cs +++ b/LogiDynamicExplorer/Diagnostics/HidReportBatchAnalyzer.cs @@ -1,3 +1,6 @@ +using System.Buffers.Binary; +using LogiDynamicExplorer.Decoders; + namespace LogiDynamicExplorer.Diagnostics; internal static class HidReportBatchAnalyzer @@ -29,6 +32,14 @@ public static IReadOnlyList Analyze(IEnumerable lines) int deviceCount = reports.Count(report => report.Direction == ReportDirection.Device); int unknownCount = reports.Count(report => report.Direction == ReportDirection.Unknown); (int matched, int unmatched) = CountMatchingTransactions(reports); + IReadOnlyList featureSetEntries = + ExtractFeatureSetEntries(reports) + .Concat(ExtractRootFeatureEntries(reports)) + .DistinctBy(entry => ( + entry.DeviceIndex, + entry.RuntimeIndex, + entry.FeatureId)) + .ToArray(); List output = [ @@ -39,9 +50,42 @@ public static IReadOnlyList Analyze(IEnumerable lines) $"Exact HID++ response headers: {matched}", $"HOST HID++ requests without an exact header match: {unmatched}", $"Invalid lines: {errors.Count}", - "Groups:" + "Feature catalog (matched Root/FeatureSet request/response pairs):" ]; + if (featureSetEntries.Count == 0) + { + output.Add(" (none)"); + } + else + { + foreach (FeatureSetEntry entry in featureSetEntries + .OrderBy(entry => entry.DeviceIndex) + .ThenBy(entry => entry.RuntimeIndex)) + { + int operationalRequestCount = reports.Count(report => + report.Direction == ReportDirection.Host && + report.IsResolvedFeatureTransport && + !report.IsFeatureSetGetFeatureIdRequest && + report.Bytes[1] == entry.DeviceIndex && + report.Bytes[2] == entry.RuntimeIndex); + + output.Add( + $" device 0x{entry.DeviceIndex:X2}, " + + $"runtime 0x{entry.RuntimeIndex:X2} -> " + + $"feature 0x{entry.FeatureId:X4}" + + $"{HidppFeatureNames.Format(entry.FeatureId)}, " + + $"flags 0x{entry.Flags:X2}, version {entry.Version}; " + + $"HOST operational requests {operationalRequestCount}"); + } + } + + AddDisplayGameDataActivity(output, reports, featureSetEntries); + + output.Add( + "Groups:" + ); + IEnumerable> groups = reports .Where(report => report.IsHidpp) .GroupBy(report => report.GroupKey) @@ -146,7 +190,8 @@ private static (int Matched, int Unmatched) CountMatchingTransactions( Dictionary> pending = []; int matched = 0; - foreach (BatchReport report in reports.Where(report => report.IsHidpp)) + foreach (BatchReport report in + reports.Where(report => report.IsPotentialHidppTransport)) { HeaderKey key = report.HeaderKey; @@ -172,6 +217,185 @@ private static (int Matched, int Unmatched) CountMatchingTransactions( return (matched, pending.Values.Sum(queue => queue.Count)); } + private static IReadOnlyList ExtractFeatureSetEntries( + IEnumerable reports) + { + Dictionary> pendingRuntimeIndices = []; + List entries = []; + + foreach (BatchReport report in reports) + { + if (report.Direction == ReportDirection.Host && + report.IsFeatureSetGetFeatureIdRequest) + { + HeaderKey key = report.HeaderKey; + + if (!pendingRuntimeIndices.TryGetValue( + key, + out Queue? runtimeIndices)) + { + runtimeIndices = []; + pendingRuntimeIndices.Add(key, runtimeIndices); + } + + runtimeIndices.Enqueue(report.Bytes[4]); + continue; + } + + if (report.Direction != ReportDirection.Device || + !report.IsFeatureSetGetFeatureIdResponse || + !pendingRuntimeIndices.TryGetValue( + report.HeaderKey, + out Queue? pending) || + pending.Count == 0) + { + continue; + } + + byte runtimeIndex = pending.Dequeue(); + ushort featureId = BinaryPrimitives.ReadUInt16BigEndian( + report.Bytes.AsSpan(4, 2)); + + entries.Add(new FeatureSetEntry( + report.Bytes[1], + runtimeIndex, + featureId, + report.Bytes[6], + report.Bytes[7])); + } + + return entries; + } + + private static IReadOnlyList ExtractRootFeatureEntries( + IEnumerable reports) + { + Dictionary> pendingFeatureIds = []; + List entries = []; + + foreach (BatchReport report in reports) + { + if (report.Direction == ReportDirection.Host && + report.IsRootGetFeatureRequest) + { + HeaderKey key = report.HeaderKey; + + if (!pendingFeatureIds.TryGetValue( + key, + out Queue? featureIds)) + { + featureIds = []; + pendingFeatureIds.Add(key, featureIds); + } + + featureIds.Enqueue(BinaryPrimitives.ReadUInt16BigEndian( + report.Bytes.AsSpan(4, 2))); + continue; + } + + if (report.Direction != ReportDirection.Device || + !report.IsRootGetFeatureResponse || + !pendingFeatureIds.TryGetValue( + report.HeaderKey, + out Queue? pending) || + pending.Count == 0) + { + continue; + } + + entries.Add(new FeatureSetEntry( + report.Bytes[1], + report.Bytes[4], + pending.Dequeue(), + report.Bytes[5], + report.Bytes[6])); + } + + return entries; + } + + private static void AddDisplayGameDataActivity( + List output, + IReadOnlyList reports, + IReadOnlyList featureSetEntries) + { + FeatureSetEntry[] displayEntries = featureSetEntries + .Where(entry => entry.FeatureId == 0x8130) + .ToArray(); + + if (displayEntries.Length == 0) + { + return; + } + + output.Add("Display Game Data activity (feature 0x8130):"); + + foreach (FeatureSetEntry entry in displayEntries) + { + BatchReport[] activity = reports + .Where(report => + (report.Direction == ReportDirection.Host || + report.Direction == ReportDirection.Device) && + report.IsResolvedFeatureTransport && + report.Bytes[1] == entry.DeviceIndex && + report.Bytes[2] == entry.RuntimeIndex) + .ToArray(); + + if (activity.Length == 0) + { + output.Add( + $" device 0x{entry.DeviceIndex:X2}, " + + $"runtime 0x{entry.RuntimeIndex:X2}: " + + "no operational reports"); + continue; + } + + foreach (IGrouping<(ReportDirection Direction, byte FunctionId), BatchReport> group + in activity + .GroupBy(report => ( + report.Direction, + FunctionId: (byte)(report.Bytes[3] >> 4))) + .OrderBy(group => group.Key.Direction) + .ThenBy(group => group.Key.FunctionId)) + { + string[] signatures = group + .Select(report => report.ParameterSignature) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + output.Add( + $" {group.Key.Direction.ToString().ToUpperInvariant()} " + + $"device 0x{entry.DeviceIndex:X2}, " + + $"runtime 0x{entry.RuntimeIndex:X2}, " + + $"function 0x{group.Key.FunctionId:X2}: " + + $"{group.Count()} reports, " + + $"{signatures.Length} parameter signatures"); + + foreach (BatchReport report in group + .DistinctBy(report => report.ParameterSignature) + .Take(10)) + { + string decoded = report.Direction == ReportDirection.Host + ? Rs50DisplayGameDataDecoder.DecodeHostCommand( + group.Key.FunctionId, + report.Parameters) + : Rs50DisplayGameDataDecoder.DecodeDeviceResponse( + group.Key.FunctionId, + report.Parameters); + + output.Add($" {decoded}"); + } + + if (signatures.Length > 10) + { + output.Add( + $" ({signatures.Length - 10} additional " + + "signatures omitted)"); + } + } + } + } + private enum ReportDirection { Host, @@ -188,6 +412,44 @@ private sealed record BatchReport(ReportDirection Direction, byte[] Bytes) HasHidppHeader && (Bytes[0] == 0x10 || Bytes[0] == 0x11); + public bool IsPotentialHidppTransport => + HasHidppHeader && + Bytes[0] is 0x10 or 0x11 or 0x12; + + public bool IsHidppRequest => + IsHidpp; + + public bool IsFeatureSetGetFeatureIdRequest => + IsHidppRequest && + Bytes.Length >= 5 && + Bytes[2] == 0x01 && + (Bytes[3] >> 4) == 0x01; + + public bool IsFeatureSetGetFeatureIdResponse => + HasHidppHeader && + Bytes.Length >= 8 && + (Bytes[0] == 0x11 || Bytes[0] == 0x12) && + Bytes[2] == 0x01 && + (Bytes[3] >> 4) == 0x01; + + public bool IsRootGetFeatureRequest => + IsPotentialHidppTransport && + Bytes.Length >= 6 && + Bytes[2] == 0x00 && + (Bytes[3] >> 4) == 0x00; + + public bool IsRootGetFeatureResponse => + IsPotentialHidppTransport && + Bytes.Length >= 7 && + Bytes[2] == 0x00 && + (Bytes[3] >> 4) == 0x00; + + public bool IsResolvedFeatureTransport => + HasHidppHeader && + (Bytes[0] == 0x10 || + Bytes[0] == 0x11 || + (Bytes[0] == 0x12 && (Bytes[3] & 0x0F) != 0)); + public ReportGroupKey GroupKey => new( Direction, Bytes[0], @@ -199,6 +461,8 @@ private sealed record BatchReport(ReportDirection Direction, byte[] Bytes) public string ParameterSignature => Bytes.Length == 4 ? string.Empty : Convert.ToHexString(Bytes.AsSpan(4)); + + public ReadOnlySpan Parameters => Bytes.AsSpan(4); } private sealed record ReportGroupKey( @@ -212,4 +476,11 @@ private sealed record HeaderKey( byte DeviceIndex, byte FeatureIndex, byte FunctionAndSoftwareId); + + private sealed record FeatureSetEntry( + byte DeviceIndex, + byte RuntimeIndex, + ushort FeatureId, + byte Flags, + byte Version); } diff --git a/LogiDynamicExplorer/Diagnostics/HidReportBatchComparer.cs b/LogiDynamicExplorer/Diagnostics/HidReportBatchComparer.cs new file mode 100644 index 0000000..61daf09 --- /dev/null +++ b/LogiDynamicExplorer/Diagnostics/HidReportBatchComparer.cs @@ -0,0 +1,179 @@ +using LogiDynamicExplorer.Decoders; + +namespace LogiDynamicExplorer.Diagnostics; + +internal static class HidReportBatchComparer +{ + public static IReadOnlyList Compare( + IEnumerable baselineLines, + IEnumerable candidateLines) + { + ParsedBatch baseline = Parse(baselineLines); + ParsedBatch candidate = Parse(candidateLines); + + List output = + [ + "Offline HID report batch comparison (no HID access):", + $"Baseline HID++ reports: {baseline.Reports.Count}", + $"Candidate HID++ reports: {candidate.Reports.Count}", + $"Baseline invalid lines: {baseline.InvalidLineCount}", + $"Candidate invalid lines: {candidate.InvalidLineCount}", + $"Baseline non-HID++ reports ignored: {baseline.IgnoredReportCount}", + $"Candidate non-HID++ reports ignored: {candidate.IgnoredReportCount}", + "Positive exact-report count deltas:" + ]; + + ReportDelta[] positiveDeltas = CalculateDeltas( + baseline.Reports, + candidate.Reports) + .Where(delta => delta.CountDelta > 0) + .OrderBy(delta => delta.Signature.Direction) + .ThenBy(delta => delta.Signature.ReportId) + .ThenBy(delta => delta.Signature.DeviceIndex) + .ThenBy(delta => delta.Signature.FeatureIndex) + .ThenBy(delta => delta.Signature.FunctionAndSoftwareId) + .ThenBy(delta => delta.Signature.Parameters, StringComparer.Ordinal) + .ToArray(); + + if (positiveDeltas.Length == 0) + { + output.Add(" (none)"); + } + else + { + foreach (ReportDelta delta in positiveDeltas) + { + byte functionId = + (byte)(delta.Signature.FunctionAndSoftwareId >> 4); + byte softwareId = + (byte)(delta.Signature.FunctionAndSoftwareId & 0x0F); + + output.Add( + $" +{delta.CountDelta} {delta.Signature.Direction} " + + $"ID 0x{delta.Signature.ReportId:X2}, " + + $"device 0x{delta.Signature.DeviceIndex:X2}, " + + $"feature 0x{delta.Signature.FeatureIndex:X2}, " + + $"function 0x{functionId:X2}, SW-ID 0x{softwareId:X2}, " + + $"parameters {FormatParameters(delta.Signature.Parameters)}"); + } + } + + output.Add("Comparison boundary:"); + output.Add( + " Positive deltas are differences, not proof of causation."); + output.Add( + " Background traffic and unequal capture durations can also differ."); + + return output; + } + + private static ParsedBatch Parse(IEnumerable lines) + { + List reports = []; + int invalidLineCount = 0; + int ignoredReportCount = 0; + + foreach (string line in lines) + { + string trimmed = line.Trim(); + if (trimmed.Length == 0 || trimmed.StartsWith('#')) + { + continue; + } + + string[] fields = trimmed.Split('\t'); + if (fields.Length < 2) + { + invalidLineCount++; + continue; + } + + string direction = fields[0].Trim().ToUpperInvariant(); + if (direction is not ("HOST" or "DEVICE") || + !HexReportParser.TryParse( + fields[^1], + out byte[] bytes, + out _)) + { + invalidLineCount++; + continue; + } + + if (bytes.Length < 4 || + bytes[0] is not (0x10 or 0x11 or 0x12)) + { + ignoredReportCount++; + continue; + } + + reports.Add(new ReportSignature( + direction, + bytes[0], + bytes[1], + bytes[2], + bytes[3], + Convert.ToHexString(bytes.AsSpan(4)))); + } + + return new ParsedBatch( + reports, + invalidLineCount, + ignoredReportCount); + } + + private static IEnumerable CalculateDeltas( + IEnumerable baseline, + IEnumerable candidate) + { + Dictionary baselineCounts = baseline + .GroupBy(signature => signature) + .ToDictionary(group => group.Key, group => group.Count()); + Dictionary candidateCounts = candidate + .GroupBy(signature => signature) + .ToDictionary(group => group.Key, group => group.Count()); + + foreach ((ReportSignature signature, int candidateCount) in + candidateCounts) + { + baselineCounts.TryGetValue(signature, out int baselineCount); + yield return new ReportDelta( + signature, + candidateCount - baselineCount); + } + } + + private static string FormatParameters(string parameters) + { + const int MaximumDisplayedHexCharacters = 64; + + if (parameters.Length == 0) + { + return "(none)"; + } + + if (parameters.Length <= MaximumDisplayedHexCharacters) + { + return parameters; + } + + return parameters[..MaximumDisplayedHexCharacters] + + $"... ({parameters.Length / 2} bytes)"; + } + + private sealed record ParsedBatch( + IReadOnlyList Reports, + int InvalidLineCount, + int IgnoredReportCount); + + private sealed record ReportSignature( + string Direction, + byte ReportId, + byte DeviceIndex, + byte FeatureIndex, + byte FunctionAndSoftwareId, + string Parameters); + + private sealed record ReportDelta( + ReportSignature Signature, + int CountDelta); +} diff --git a/LogiDynamicExplorer/Program.cs b/LogiDynamicExplorer/Program.cs index 0db4423..f733233 100644 --- a/LogiDynamicExplorer/Program.cs +++ b/LogiDynamicExplorer/Program.cs @@ -33,6 +33,11 @@ Console.WriteLine( " Summarizes saved HOST/DEVICE report lines without HID access."); Console.WriteLine(); + Console.WriteLine( + " LogiDynamicExplorer --compare-reports BASELINE CANDIDATE"); + Console.WriteLine( + " Compares two saved report batches without HID access."); + Console.WriteLine(); Console.WriteLine( " LogiDynamicExplorer --monitor COLLECTION --duration SECONDS"); Console.WriteLine( @@ -89,6 +94,27 @@ return; } +if (options.CompareBaselineFile is not null && + options.CompareCandidateFile is not null) +{ + try + { + foreach (string line in HidReportBatchComparer.Compare( + File.ReadLines(options.CompareBaselineFile), + File.ReadLines(options.CompareCandidateFile))) + { + Console.WriteLine(line); + } + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException) + { + Console.WriteLine($"Error: {exception.Message}"); + } + + return; +} + Console.Title = "LogiDynamicExplorer - Passive Monitor"; Console.WriteLine("=============================================="); diff --git a/LogiDynamicExplorer/Protocol/Rs50DisplayGameDataDirectInputAbi.cs b/LogiDynamicExplorer/Protocol/Rs50DisplayGameDataDirectInputAbi.cs new file mode 100644 index 0000000..d067be8 --- /dev/null +++ b/LogiDynamicExplorer/Protocol/Rs50DisplayGameDataDirectInputAbi.cs @@ -0,0 +1,110 @@ +using System.Runtime.InteropServices; + +namespace LogiDynamicExplorer.Protocol; + +/// +/// Models the installed x64 driver's Display Game Data input ABI. +/// These structures exist only to verify recovered sizes and offsets. +/// They must not be marshalled to DirectInput: managed code cannot construct +/// the live MSVC std::string objects required by text layouts. +/// +internal static class Rs50DisplayGameDataDirectInputAbi +{ + /// + /// Size-and-offset model of the public 32-bit DirectInput DIEFFESCAPE. + /// Pointer fields are opaque integers; native code must use dinput.h. + /// + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct DirectInputEscape32Layout + { + public uint Size; + public uint Command; + public uint InputPointer; + public uint InputSize; + public uint OutputPointer; + public uint OutputSize; + } + + /// + /// Size-and-offset model of the public 64-bit DirectInput DIEFFESCAPE. + /// Pointer fields are opaque integers; native code must use dinput.h. + /// + [StructLayout(LayoutKind.Sequential, Pack = 8)] + public struct DirectInputEscape64Layout + { + public uint Size; + public uint Command; + public ulong InputPointer; + public uint InputSize; + public ulong OutputPointer; + public uint OutputSize; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct Header + { + public uint Size; + public uint Version; + public byte Command; + public byte Reserved0; + public byte Reserved1; + public byte Reserved2; + } + + /// + /// Opaque size model for the 32-byte x64 MSVC std::string object. + /// This is not a string implementation and must never hold live pointers. + /// + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct MsvcString64Layout + { + public ulong Opaque0; + public ulong Opaque1; + public ulong Opaque2; + public ulong Opaque3; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct LayoutCInput + { + public Header Header; + public float Value; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct LayoutDInput + { + public Header Header; + public float FirstValue; + public float SecondValue; + public MsvcString64Layout Text; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct LayoutEInput + { + public Header Header; + public float FirstValue; + public float SecondValue; + public MsvcString64Layout FirstText; + public MsvcString64Layout SecondText; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct TwoTextInput + { + public Header Header; + public MsvcString64Layout FirstText; + public MsvcString64Layout SecondText; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct FourTextInput + { + public Header Header; + public MsvcString64Layout FirstText; + public MsvcString64Layout SecondText; + public MsvcString64Layout ThirdText; + public MsvcString64Layout FourthText; + } +} diff --git a/LogiDynamicExplorer/Protocol/Rs50DisplayGameDataDirectInputContract.cs b/LogiDynamicExplorer/Protocol/Rs50DisplayGameDataDirectInputContract.cs new file mode 100644 index 0000000..1d83b49 --- /dev/null +++ b/LogiDynamicExplorer/Protocol/Rs50DisplayGameDataDirectInputContract.cs @@ -0,0 +1,92 @@ +namespace LogiDynamicExplorer.Protocol; + +/// +/// Describes the recovered DirectInput Escape envelope for Display Game Data. +/// This type contains metadata only; it has no DirectInput or HID transport. +/// +internal static class Rs50DisplayGameDataDirectInputContract +{ + public const uint OuterEscapeCommand = 4; + public const uint Version = 1; + public const int HeaderSize = 12; + + public enum InnerCommand : byte + { + SetIdle = 1, + QueryDisplaySupport = 2, + QueryLayoutA = 3, + QueryLayoutB = 4, + QueryLayoutC = 5, + QueryLayoutD = 6, + QueryLayoutE = 7, + QueryLayoutF = 8, + QueryLayoutG = 9, + QueryLayoutH = 10, + QueryLayoutI = 11, + QueryLayoutJ = 12, + SetLayoutA = 13, + SetLayoutB = 14, + SetLayoutC = 15, + SetLayoutD = 16, + SetLayoutE = 17, + SetLayoutF = 18, + SetLayoutG = 19, + SetLayoutH = 20, + SetLayoutI = 21, + SetLayoutJ = 22 + } + + public static bool IsSupportQuery(InnerCommand command) => + command is >= InnerCommand.QueryDisplaySupport + and <= InnerCommand.QueryLayoutJ; + + public static bool IsSetter(InnerCommand command) => + command is InnerCommand.SetIdle + or >= InnerCommand.SetLayoutA and <= InnerCommand.SetLayoutJ; + + public static int GetMinimumInputSize(InnerCommand command) => command switch + { + >= InnerCommand.SetIdle and <= InnerCommand.QueryLayoutJ => HeaderSize, + InnerCommand.SetLayoutA => 12, + InnerCommand.SetLayoutB => 12, + InnerCommand.SetLayoutC => 16, + InnerCommand.SetLayoutD => 52, + InnerCommand.SetLayoutE => 84, + InnerCommand.SetLayoutF => 76, + InnerCommand.SetLayoutG => 76, + InnerCommand.SetLayoutH => 76, + InnerCommand.SetLayoutI => 140, + InnerCommand.SetLayoutJ => 140, + _ => throw new ArgumentOutOfRangeException(nameof(command), command, null) + }; + + /// + /// Returns the output capacity checked by the installed x64 driver. + /// Support queries place their boolean result in the first output byte. + /// Setters do not require an output buffer. + /// + public static int GetMinimumOutputSize(InnerCommand command) => command switch + { + InnerCommand.QueryDisplaySupport => 1, + InnerCommand.QueryLayoutA => 1, + InnerCommand.QueryLayoutB => 1, + InnerCommand.QueryLayoutC => 1, + InnerCommand.QueryLayoutD => 4, + >= InnerCommand.QueryLayoutE and <= InnerCommand.QueryLayoutH => 6, + InnerCommand.QueryLayoutI => 10, + InnerCommand.QueryLayoutJ => 10, + >= InnerCommand.SetIdle and <= InnerCommand.SetLayoutJ => 0, + _ => throw new ArgumentOutOfRangeException(nameof(command), command, null) + }; + + /// + /// Returns the bytes whose meaning is defined by the recovered callback. + /// Some layout queries require extra capacity but still write only byte 0. + /// + public static int GetDefinedOutputSize(InnerCommand command) => command switch + { + >= InnerCommand.QueryDisplaySupport and <= InnerCommand.QueryLayoutJ => 1, + >= InnerCommand.SetIdle and <= InnerCommand.SetLayoutJ => 0, + _ => throw new ArgumentOutOfRangeException(nameof(command), command, null) + }; +} diff --git a/LogiDynamicExplorer/Protocol/Rs50DisplayGameDataPayloadEncoder.cs b/LogiDynamicExplorer/Protocol/Rs50DisplayGameDataPayloadEncoder.cs new file mode 100644 index 0000000..8cd91b7 --- /dev/null +++ b/LogiDynamicExplorer/Protocol/Rs50DisplayGameDataPayloadEncoder.cs @@ -0,0 +1,174 @@ +namespace LogiDynamicExplorer.Protocol; + +/// +/// Builds function-3 parameters for the RS50 Display Game Data feature. +/// This type has no HID transport and cannot transmit a report. +/// +internal static class Rs50DisplayGameDataPayloadEncoder +{ + public const ushort PublicFeatureId = 0x8130; + public const byte FunctionId = 0x03; + + public static byte[] EncodeLayoutA() => [0]; + + public static byte[] EncodeLayoutB() => [1]; + + public static byte[] EncodeLayoutC(float mainGaugeValue) => + [2, EncodeNormalizedValue(mainGaugeValue, nameof(mainGaugeValue))]; + + public static byte[] EncodeLayoutD( + float mainGaugeValue, + float thinIndicatorValue, + string text) + { + byte[] parameters = new byte[14]; + parameters[0] = 3; + parameters[1] = EncodeNormalizedValue( + mainGaugeValue, + nameof(mainGaugeValue)); + parameters[2] = EncodeNormalizedValue( + thinIndicatorValue, + nameof(thinIndicatorValue)); + WriteText(parameters.AsSpan(3, 11), text, nameof(text)); + return parameters; + } + + public static byte[] EncodeLayoutE( + float mainGaugeValue, + float thinIndicatorValue, + string rightText, + string leftText) + { + byte[] parameters = new byte[13]; + parameters[0] = 4; + parameters[1] = EncodeNormalizedValue( + mainGaugeValue, + nameof(mainGaugeValue)); + parameters[2] = EncodeNormalizedValue( + thinIndicatorValue, + nameof(thinIndicatorValue)); + WriteText(parameters.AsSpan(3, 3), rightText, nameof(rightText)); + WriteText(parameters.AsSpan(6, 7), leftText, nameof(leftText)); + return parameters; + } + + public static byte[] EncodeLayoutF(string leftText, string rightText) => + EncodeTwoTextLayout(5, 1, 3, leftText, rightText); + + public static byte[] EncodeLayoutG(string leftText, string rightText) => + EncodeTwoTextLayout(6, 1, 3, leftText, rightText); + + public static byte[] EncodeLayoutH(string topText, string bottomText) => + EncodeTwoTextLayout(7, 21, 10, topText, bottomText); + + public static byte[] EncodeLayoutI( + string firstText, + string secondText, + string thirdText, + string fourthText) => + EncodeFourTextLayout( + 8, + firstText, + secondText, + thirdText, + fourthText); + + public static byte[] EncodeLayoutJ( + string firstText, + string secondText, + string thirdText, + string fourthText) => + EncodeFourTextLayout( + 9, + firstText, + secondText, + thirdText, + fourthText); + + private static byte[] EncodeTwoTextLayout( + byte layoutIndex, + int firstLength, + int secondLength, + string firstText, + string secondText) + { + byte[] parameters = new byte[1 + firstLength + secondLength]; + parameters[0] = layoutIndex; + WriteText( + parameters.AsSpan(1, firstLength), + firstText, + nameof(firstText)); + WriteText( + parameters.AsSpan(1 + firstLength, secondLength), + secondText, + nameof(secondText)); + return parameters; + } + + private static byte[] EncodeFourTextLayout( + byte layoutIndex, + string firstText, + string secondText, + string thirdText, + string fourthText) + { + byte[] parameters = new byte[59]; + parameters[0] = layoutIndex; + WriteText(parameters.AsSpan(1, 19), firstText, nameof(firstText)); + WriteText(parameters.AsSpan(20, 10), secondText, nameof(secondText)); + WriteText(parameters.AsSpan(30, 19), thirdText, nameof(thirdText)); + WriteText(parameters.AsSpan(49, 10), fourthText, nameof(fourthText)); + return parameters; + } + + private static byte EncodeNormalizedValue(float value, string parameterName) + { + if (!float.IsFinite(value)) + { + throw new ArgumentOutOfRangeException( + parameterName, + value, + "The normalized display value must be finite."); + } + + float scaled = Math.Clamp(value, 0.0f, 1.0f) * byte.MaxValue; + return checked((byte)MathF.Round(scaled, MidpointRounding.AwayFromZero)); + } + + private static void WriteText( + Span destination, + string text, + string parameterName) + { + ArgumentNullException.ThrowIfNull(text, parameterName); + + if (text.Contains('\0')) + { + throw new ArgumentException( + "Display text cannot contain an embedded NUL.", + parameterName); + } + + if (text.Length > destination.Length) + { + throw new ArgumentException( + $"Display text exceeds the {destination.Length}-character " + + "layout field.", + parameterName); + } + + for (int index = 0; index < text.Length; index++) + { + char character = text[index]; + + if (character is >= 'a' and <= 'z') + { + character = (char)(character - ('a' - 'A')); + } + + destination[index] = character is >= (char)0x20 and <= (char)0x7F + ? (byte)character + : (byte)'?'; + } + } +} diff --git a/README.md b/README.md index 756875b..3d52675 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,185 @@ screen on Logitech PRO Racing Wheel and RS50. ## Current status -- The main application reads iRacing telemetry and simulates the intended OLED - layout in the console. +- The main application reads iRacing telemetry and formats an exact four-line + Layout J presentation (`19/10/19/10`) for its console preview, including + speed/gear, brake bias, last lap, and bounded connection states. Its display + path is capped at the planned initial hardware rate of 5 Hz and suppresses + identical frames before they reach a sink. - The research application can passively monitor supported RS50 HID input - collections and decode saved `0x11`/`0x12` reports without accessing HID - hardware. -- Output to the physical Dynamic OLED has not been demonstrated and is not - currently supported. + collections, decode saved HID++ reports, reconstruct FeatureSet catalogs, + and count runtime-feature use without accessing HID hardware. +- A read-only Wireshark/USBPcap extractor can feed device-scoped captures + directly into the offline analyzer. +- A query-capture inspection script combines extraction and offline analysis + without opening the RS50 or transmitting HID data. +- An offline baseline/query comparison reports exact HID++ traffic deltas + without attributing background differences to the display. +- A transport-free encoder can build and validate the recovered function-`3` + parameters for layouts A-J. It deliberately omits the HID report header, + device address, and runtime feature index, so it cannot transmit to hardware. +- A second transport-free contract records the recovered DirectInput command + numbers and minimum input/output sizes, including outer command `4` and + support queries 2-12. Both x86 and x64 Logitech drivers independently + confirm that outer selector. The contract contains no DirectInput or HID + calls. +- Verified managed ABI models reproduce the installed x64 driver's packed + structure sizes and offsets without constructing live C++ strings. An x64 + MSVC guarded bridge now implements the DirectInput boundary without raw HID, + C# pointer emulation, force-feedback effects, or a generic display surface. + Build + A proved exclusive acquisition is required; Build A2 proved a data format is + required first; Build A3 uses `c_dfDIJoystick2`, Acquire, one support query, + and Unacquire. +- A read-only prerequisite checker verifies the local MSVC, Windows SDK, and + Logitech DirectInput-driver environment before any native bridge is added. + `LogiDynamicDash.vsconfig` supplies the minimal Visual Studio workload, + stable x64/x86 MSVC tools, and Windows 11 SDK needed by that bridge. +- The strongest current lead is public HID++ feature `0x8130`, advertised by + the RS50 and named `DisplayGameData` inside G HUB and its embedded + DirectInput/FFB driver. Firmware and driver analysis recovered ten typed, + firmware-rendered layouts (A-J) and the exact normalized conversion from + game floats to gauge bytes. Build K has now physically classified all ten + layouts, their gauges, alignments, and five built-in bitmap font sizes. +- A guarded physical DirectInput support query now succeeds on the RS50: + `SetDataFormat`, exclusive Acquire, outer command `4` / inner command `2`, + and Unacquire all returned success. The device returned `Supported: 1`; + USBPcap independently captured discovery of public feature `0x8130` at + runtime `0x12`, with no operational display call and no physical change. +- The separately guarded Build B completed one non-setter Layout J capability + query (`inner 12`). The RS50 returned `Supported: 1`, preserved all nine + trailing sentinel bytes, and USBPcap matched the predicted 11 `0x8130` + exchanges. The device reported Layout J ID `10` with four text capacities + `19/10/19/10`; the operator confirmed no OLED, LED, torque, or wheel-position + change. +- Guarded Build C now compiles one fixed Layout J setter containing only + `LOGIDYNAMICDASH / RS50 / OLED LINK / TEST 1`. It has no caller-controlled + text, repeat loop, idle command, raw HID path, or other setter. +- Build C physically succeeded. DirectInput command `22` produced exactly one + matched `0x8130` function-`3` transaction, and the RS50 visibly rendered + `RS50 / LOGIDYNAMI / TEST 1 / OLED LINK`. This confirms static OLED output + and reveals the driver argument-to-row permutation `2/1/4/3`. The operator + observed no torque, LED, or wheel-movement change. +- Build D now has an offline-validated dynamic session API. It accepts only + canonical visual rows with limits `19/10/19/10`, performs the proven + `row2/row1/row4/row3` DirectInput permutation, suppresses identical frames, + rate-limits changed frames to 5 Hz, and stops a session after any Escape + failure. The managed iRacing sink requires four exact arming arguments and + automatically ends after ten seconds. +- Build D completed its first guarded end-to-end physical trial. iRacing + telemetry rendered `SPEED / 0 KMH / GEAR / N` on the Dynamic OLED for ten + seconds with exact matched USB responses. The trial also exposed a blocking + coexistence failure: iRacing's shift LEDs stopped updating and normal FFB + centering was absent afterward. Returning to the iRacing main menu and + reloading the circuit restored the LEDs. No moving-car or full-lap test is + authorized until the exclusive DirectInput lifecycle is redesigned or + replaced. +- Offline decoding identified the lifecycle traffic as HID++ `0x8123` + `RESET_ALL`, `SET_GLOBAL_GAINS(0xFFFF)`, then `RESET_ALL`. The next research + candidate is a strictly typed shared transport for display feature `0x8130` + on the RS50's separate HID++ interface, avoiding DirectInput acquisition and + the dedicated real-time FFB interface. +- Build E implements the shared `0x8130` codec and fail-closed 5 Hz session + offline. It can only construct Root discovery and Layout J function-`3` + transactions, validates exact 64-byte responses, and has no physical HID + implementation in its protocol/session assemblies or CLI route. +- Build F compiles a separate, unreferenced HidSharp adapter gated to the + RS50's exact MI_01 COL01 (`FF43:0701`, 7-byte `0x10`) and COL03 + (`FF43:0704`, 64-byte `0x12`) unique collections. Its fake-only tests cover + routing, matching, disposal, uniqueness, identity, usage, length, and path + failures. The dashboard does not reference or copy the transport assembly, + and no Build F stream has been opened. +- Build G compiles a separate one-shot executable with six exact ordered + confirmations. It performs one feature discovery and one fixed Layout J + setter, then closes both streams. It has no loop, telemetry, caller text, or + application route. After a safe failed preflight exposed Windows's distinct + collection paths, the corrected physical attempt succeeded: the OLED showed + all four fixed lines exactly, USBPcap captured only the expected endpoint-0 + discovery and Layout J `SET_REPORT` operations with exact acknowledgements, + and the operator observed no LED, FFB, torque, or wheel-position change. +- Build H compiles a separate bounded-stream executable for the next baseline. + It spells out exactly five fixed frames at 1 Hz with no loop, retry, + telemetry, or caller-controlled values. Its H1 physical baseline passed: + all five counters rendered in order, USBPcap contained exactly one discovery + plus five endpoint-0 setters and their exact acknowledgements, and the + operator observed no LED, FFB, torque, or wheel-position change. +- Build I compiles a separate coexistence trial for iRacing with + the car stationary in the pits. It sends only five fixed 1 Hz frames and + imports no telemetry SDK. Its high-buffer physical capture passed: OLED + frames, centering, and rev LEDs all remained correct; TRUEFORCE endpoint + traffic stayed continuous; and no `0x8123` reset lifecycle occurred. +- Build J's separate stationary telemetry path is now fully confirmed. J1 + physically rendered live `SPEED / 0 KMH / GEAR / N` with normal centering, + RPM LEDs, inputs, and connection, but its first PCAP omitted the setter/ACK. + J2 repeated the same bounded trial once with direct USBPcapCMD and the + bounded write-through transcript. The transcript and PCAP contain + byte-identical discovery and Layout J request/response pairs, with no + destructive `RESET_ALL -> SET_GLOBAL_GAINS -> RESET_ALL` lifecycle or + observed physical side effect. This completes the stationary telemetry + gate; moving-car operation still requires a separately reviewed bounded + stage before any full lap. +- Build K's separately armed visual-capability gallery physically passed. The + OLED advanced through fixed Layouts A-J; video confirmed blank/Test, + solid/striped gauges, split numeric layouts, four-row layouts, and built-in + 9/16/18/27/37 px bitmap fonts. USBPcap contained exactly one discovery and + ten setters with exact ACKs and no unrelated host operation. The operator + observed no movement, torque, resistance, LED change, input loss, or + disconnect. The confirmed interface does not expose arbitrary graphics, + fonts, positioning, Unicode, or color. + +Build the guarded native bridge and its non-hardware tests with: + +```powershell +.\scripts\Build-Rs50DirectInputBridge.ps1 -Configuration Release +``` + +Audit the offline Build E shared-HID++ surface with: + +```powershell +.\scripts\Test-Rs50SharedHidppSurface.ps1 +``` + +Audit the disconnected Build F physical-adapter surface with: + +```powershell +.\scripts\Test-Rs50SharedHidppTransportSurface.ps1 +``` + +Audit the unexecuted Build G one-shot surface with: + +```powershell +.\scripts\Test-Rs50SharedHidppOneShotSurface.ps1 +``` + +Audit the unexecuted Build H bounded-stream surface with: + +```powershell +.\scripts\Test-Rs50SharedHidppBoundedStreamSurface.ps1 +``` + +Audit the unexecuted Build I stationary-coexistence surface with: + +```powershell +.\scripts\Test-Rs50SharedHidppCoexistenceSurface.ps1 +``` + +Audit the unexecuted Build J stationary-telemetry surface with: + +```powershell +.\scripts\Test-Rs50SharedHidppTelemetryTrialSurface.ps1 +``` + +Audit the Build K A-J layout-gallery source surface with: + +```powershell +.\scripts\Test-Rs50SharedHidppLayoutGallerySurface.ps1 +``` + +Inspect the five embedded OLED bitmap fonts from an explicitly supplied, +hash-validated RS50 DFU without opening the wheel: + +```powershell +python .\scripts\Inspect-Rs50OledFirmwareVisuals.py ` + --firmware "" ` + --bmp-output ".tmp\rs50_oled_font_samples.bmp" +``` diff --git a/Rs50DirectInputBridge.Tests/Rs50DirectInputBridge.Tests.vcxproj b/Rs50DirectInputBridge.Tests/Rs50DirectInputBridge.Tests.vcxproj new file mode 100644 index 0000000..a6ee9f8 --- /dev/null +++ b/Rs50DirectInputBridge.Tests/Rs50DirectInputBridge.Tests.vcxproj @@ -0,0 +1,74 @@ + + + + + Debug + x64 + + + Release + x64 + + + + 18.0 + Win32Proj + {17CE3948-E337-481D-8DF5-70DA3A13BEE2} + Rs50DirectInputBridgeTests + 10.0.26100.0 + + + + Application + true + v145 + Unicode + + + Application + false + v145 + true + Unicode + + + + $(MSBuildThisFileDirectory)..\artifacts\native\$(Configuration)\ + $(MSBuildThisFileDirectory)obj\$(Configuration)\ + Rs50DirectInputBridge.Tests + false + + + + $(MSBuildThisFileDirectory)..\Rs50DirectInputBridge\include;%(AdditionalIncludeDirectories) + stdcpp20 + MultiThreadedDebugDLL + Level4 + true + + + + + $(MSBuildThisFileDirectory)..\Rs50DirectInputBridge\include;%(AdditionalIncludeDirectories) + true + true + stdcpp20 + MultiThreadedDLL + Level4 + true + + + true + true + + + + + + + + {6D37AF52-F9E3-4477-91AD-A8E2B76DD3BD} + + + + diff --git a/Rs50DirectInputBridge.Tests/src/main.cpp b/Rs50DirectInputBridge.Tests/src/main.cpp new file mode 100644 index 0000000..a8b1365 --- /dev/null +++ b/Rs50DirectInputBridge.Tests/src/main.cpp @@ -0,0 +1,179 @@ +#include "rs50_display_bridge.h" +#include "rs50_layout_j_frame.h" + +#include +#include +#include +#include +#include + +namespace +{ +int failures = 0; + +void Expect(bool condition, const wchar_t *description) +{ + if (!condition) + { + std::wcerr << L"FAILED: " << description << L"\n"; + ++failures; + } +} + +rs50_display_layout_j_frame MakeFrame( + std::string_view row1, std::string_view row2, + std::string_view row3, std::string_view row4) +{ + rs50_display_layout_j_frame frame{}; + frame.struct_size = sizeof(frame); + + const auto copyText = [](std::string_view source, char *destination, + std::uint8_t &length) + { + std::copy(source.begin(), source.end(), destination); + length = static_cast(source.size()); + }; + + copyText(row1, frame.row1, frame.row1_length); + copyText(row2, frame.row2, frame.row2_length); + copyText(row3, frame.row3, frame.row3_length); + copyText(row4, frame.row4, frame.row4_length); + return frame; +} +} // namespace + +int wmain() +{ + Expect(rs50_display_abi_version() == 6, L"ABI version is six"); + + Expect(rs50_display_open(0, nullptr) == RS50_DISPLAY_INVALID_ARGUMENT, + L"open rejects a null output pointer before any hardware work"); + + rs50_display_handle *handle = + reinterpret_cast(static_cast(1)); + Expect(rs50_display_open(0, &handle) == RS50_DISPLAY_INVALID_OWNER_WINDOW, + L"open rejects a null owner window before driver or device access"); + Expect(handle == nullptr, L"failed open clears the output handle"); + + rs50_display_query_result result{}; + result.struct_size = sizeof(result); + Expect(rs50_display_query_support(nullptr, &result) == + RS50_DISPLAY_INVALID_ARGUMENT, + L"query rejects a null handle"); + Expect(rs50_display_query_layout_j_support(nullptr, &result) == + RS50_DISPLAY_INVALID_ARGUMENT, + L"Layout J query rejects a null handle"); + + rs50_display_static_layout_j_result setterResult{}; + setterResult.struct_size = sizeof(setterResult); + Expect(rs50_display_set_static_layout_j(nullptr, &setterResult) == + RS50_DISPLAY_INVALID_ARGUMENT, + L"static Layout J setter rejects a null handle"); + Expect(rs50_display_set_static_layout_j(nullptr, nullptr) == + RS50_DISPLAY_INVALID_ARGUMENT, + L"static Layout J setter rejects null arguments"); + + rs50_display_stream_result streamResult{}; + streamResult.struct_size = sizeof(streamResult); + Expect(rs50_display_begin_layout_j_stream(nullptr, &streamResult) == + RS50_DISPLAY_INVALID_ARGUMENT, + L"stream begin rejects a null handle"); + Expect(rs50_display_end_layout_j_stream(nullptr, &streamResult) == + RS50_DISPLAY_INVALID_ARGUMENT, + L"stream end rejects a null handle"); + + rs50_display_layout_j_frame frame = + MakeFrame("SPEED", "123 KMH", "GEAR", "4"); + Expect(rs50_display_set_layout_j_frame(nullptr, &frame, &streamResult) == + RS50_DISPLAY_INVALID_ARGUMENT, + L"stream frame rejects a null handle"); + + Expect(sizeof(rs50_display_layout_j_frame) == 68, + L"Layout J frame ABI size is 68 bytes"); + Expect(offsetof(rs50_display_layout_j_frame, row1) == 8, + L"row 1 starts at offset 8"); + Expect(offsetof(rs50_display_layout_j_frame, row2) == 27, + L"row 2 starts at offset 27"); + Expect(offsetof(rs50_display_layout_j_frame, row3) == 37, + L"row 3 starts at offset 37"); + Expect(offsetof(rs50_display_layout_j_frame, row4) == 56, + L"row 4 starts at offset 56"); + Expect(sizeof(rs50_display_stream_result) == 560, + L"stream result ABI size is 560 bytes"); + Expect(rs50::layout_j::IsValidFrame(frame), + L"canonical visual frame is valid"); + + std::array arguments = + rs50::layout_j::MakeDriverArguments(frame); + Expect(arguments[0] == "123 KMH" && arguments[1] == "SPEED" && + arguments[2] == "4" && arguments[3] == "GEAR", + L"visual rows map to driver arguments 2/1/4/3"); + + rs50_display_layout_j_frame invalid = frame; + invalid.row2_length = 11; + Expect(!rs50::layout_j::IsValidFrame(invalid), + L"oversize row is rejected"); + invalid = frame; + invalid.row1[0] = '\n'; + Expect(!rs50::layout_j::IsValidFrame(invalid), + L"control character is rejected"); + invalid = frame; + invalid.row1[frame.row1_length] = 'X'; + Expect(!rs50::layout_j::IsValidFrame(invalid), + L"nonzero canonical tail is rejected"); + invalid = frame; + invalid.reserved[0] = 1; + Expect(!rs50::layout_j::IsValidFrame(invalid), + L"nonzero reserved byte is rejected"); + + Expect(rs50::layout_j::DecideFrame(nullptr, 0, frame, 1000) == + rs50::layout_j::GateDecision::Transmit, + L"first frame transmits"); + Expect(rs50::layout_j::DecideFrame(&frame, 1000, frame, 1001) == + rs50::layout_j::GateDecision::Unchanged, + L"identical frame is suppressed before rate limiting"); + rs50_display_layout_j_frame changed = + MakeFrame("SPEED", "124 KMH", "GEAR", "4"); + Expect(rs50::layout_j::DecideFrame(&frame, 1000, changed, 1199) == + rs50::layout_j::GateDecision::RateLimited, + L"changed frame inside 200 ms is rate limited"); + Expect(rs50::layout_j::DecideFrame(&frame, 1000, changed, 1200) == + rs50::layout_j::GateDecision::Transmit, + L"changed frame at 200 ms transmits"); + + Expect(rs50_display_status_message(RS50_DISPLAY_QUERY_ALREADY_ATTEMPTED) != + nullptr, + L"every public failure exposes a stable message"); + Expect(rs50_display_status_message(RS50_DISPLAY_QUERY_OUTPUT_INVALID) != + nullptr, + L"unexpected query output has a stable failure message"); + Expect(rs50_display_status_message(RS50_DISPLAY_ACQUIRE_FAILED) != nullptr, + L"acquisition failure has a stable failure message"); + Expect(rs50_display_status_message(RS50_DISPLAY_UNACQUIRE_FAILED) != + nullptr, + L"release failure has a stable failure message"); + Expect(rs50_display_status_message(RS50_DISPLAY_DATA_FORMAT_FAILED) != + nullptr, + L"data format failure has a stable failure message"); + Expect(rs50_display_status_message( + RS50_DISPLAY_OPERATION_ALREADY_ATTEMPTED) != nullptr, + L"repeated operation failure has a stable message"); + Expect(rs50_display_status_message(RS50_DISPLAY_SESSION_NOT_STARTED) != + nullptr, + L"inactive stream has a stable message"); + Expect(rs50_display_status_message(RS50_DISPLAY_FRAME_INVALID) != nullptr, + L"invalid frame has a stable message"); + Expect(rs50_display_status_message(RS50_DISPLAY_SESSION_FAILED) != nullptr, + L"failed stream has a stable message"); + + rs50_display_close(nullptr); + + if (failures != 0) + { + return 1; + } + + std::wcout << L"Guarded bridge safety tests passed.\n" + << L"No DirectInput object or HID device was opened.\n"; + return 0; +} diff --git a/Rs50DirectInputBridge.slnx b/Rs50DirectInputBridge.slnx new file mode 100644 index 0000000..c43a92b --- /dev/null +++ b/Rs50DirectInputBridge.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/Rs50DirectInputBridge/README.md b/Rs50DirectInputBridge/README.md new file mode 100644 index 0000000..2bfb9a6 --- /dev/null +++ b/Rs50DirectInputBridge/README.md @@ -0,0 +1,101 @@ +# RS50 Guarded DirectInput Bridge + +This x64 native bridge is the guarded transport boundary for the +recovered Logitech `DisplayGameData` interface. It contains exactly one +Escape call site and three explicitly bounded command choices: general +display-support query `2`, Layout J capability query `12`, and one fixed +or validated dynamic Layout J setter `22`, all inside DirectInput Escape +command `4`. + +There is no generic command surface, idle command, raw HID write, unknown +layout setter, or force-feedback effect. The fixed setter always uses +`LOGIDYNAMICDASH / RS50 / OLED LINK / TEST 1`. Build D additionally accepts a +68-byte canonical visual-row structure, validates printable ASCII and zeroed +tails, maps rows to the proven DirectInput order, suppresses duplicate frames, +and caps changed output at 5 Hz. Build A proved exclusive acquisition is +required, and +Build A2 proved DirectInput requires a data format before acquisition. Build +A3 sets the standard `c_dfDIJoystick2` format, then performs the bounded +lifecycle: Acquire, one support query, Unacquire. + +## Build and safe tests + +From the repository root: + +```powershell +.\scripts\Build-Rs50DirectInputBridge.ps1 -Configuration Release +``` + +`Rs50DirectInputBridge.slnx` contains the three native projects for IDE use; +the PowerShell command remains the authoritative build and safety-test entry +point. + +The script: + +1. verifies x64 MSVC, Windows SDK, driver registration, audited SHA-256, and + Authenticode; +2. builds the DLL, guarded query executable, and native safety tests; +3. audits the DLL export/import surface for exactly the fixed and validated + Layout J setters and no raw HID APIs; +4. runs only invalid-argument tests, unarmed refusal checks, and `--describe`. + +Those tests do not create DirectInput, enumerate a controller, or open HID +hardware. + +## Runtime safety gates + +Before any operation can reach `IDirectInputDevice8::Escape`, the bridge +requires: + +- a process-owned top-level window; +- the exact audited Logitech driver registration, SHA-256, and valid + Authenticode signature; +- exactly one DirectInput controller with VID `046D`, PID `C276`, and + force-feedback driver GUID + `{62B43F0E-E7DB-4329-8C13-A966D84A289F}`; +- a visible process-owned foreground window; +- successful exclusive foreground cooperative level; +- successful standard `c_dfDIJoystick2` data format; +- an unused bridge handle. + +One-shot operations set the standard joystick format, acquire immediately +before `Escape`, and unacquire immediately afterward. A Build D stream sets +the format and acquires once, performs only validated Layout J updates, and +unacquires on explicit end or close. Any Escape failure permanently blocks +further frames on that handle. Cooperative, data-format, Acquire, Escape, and +Unacquire HRESULTs are reported. + +Layout J uses an exact ten-byte output capacity. Only byte zero is defined; +bytes 1-9 must remain at sentinel `0xA5` or the bridge rejects the result. + +## Physical validation gate + +Do not run transmitting mode as part of a build or automated test. It requires +the user present, G HUB state recorded, Dynamic selected, and a device-scoped +USB capture: + +```powershell +.\artifacts\native\Release\Rs50DirectInputQuery.exe ` + --query-display-support ` + --confirm-standard-data-format ` + --confirm-exclusive-acquire ` + --confirm-transmit-query +``` + +All three confirmation arguments are required in addition to the query +selector. One process can attempt the query only once. Close and recreate the +process only for a separately approved new trial. + +The separately authorized Layout J capability mode is documented in +[`RS50_LAYOUT_J_QUERY_OPERATOR_CHECKLIST.md`](../LogiDynamicDash/docs/RS50_LAYOUT_J_QUERY_OPERATOR_CHECKLIST.md). + +The fixed static setter requires a new, separate authorization and follows +[`RS50_STATIC_LAYOUT_J_OPERATOR_CHECKLIST.md`](../LogiDynamicDash/docs/RS50_STATIC_LAYOUT_J_OPERATOR_CHECKLIST.md). + +The bounded dynamic telemetry trial requires another separate authorization +and follows +[`RS50_DYNAMIC_TELEMETRY_OPERATOR_CHECKLIST.md`](../LogiDynamicDash/docs/RS50_DYNAMIC_TELEMETRY_OPERATOR_CHECKLIST.md). + +Follow the complete +[`RS50_QUERY_ONLY_OPERATOR_CHECKLIST.md`](../LogiDynamicDash/docs/RS50_QUERY_ONLY_OPERATOR_CHECKLIST.md) +instead of running the executable ad hoc. diff --git a/Rs50DirectInputBridge/Rs50DirectInputBridge.vcxproj b/Rs50DirectInputBridge/Rs50DirectInputBridge.vcxproj new file mode 100644 index 0000000..e0196b8 --- /dev/null +++ b/Rs50DirectInputBridge/Rs50DirectInputBridge.vcxproj @@ -0,0 +1,73 @@ + + + + + Debug + x64 + + + Release + x64 + + + + 18.0 + Win32Proj + {6D37AF52-F9E3-4477-91AD-A8E2B76DD3BD} + Rs50DirectInputBridge + 10.0.26100.0 + + + + DynamicLibrary + true + v145 + Unicode + + + DynamicLibrary + false + v145 + true + Unicode + + + + $(MSBuildThisFileDirectory)..\artifacts\native\$(Configuration)\ + $(MSBuildThisFileDirectory)obj\$(Configuration)\ + Rs50DirectInputBridge + false + + + + $(MSBuildThisFileDirectory)include;%(AdditionalIncludeDirectories) + stdcpp20 + RS50_BRIDGE_EXPORTS;WIN32_LEAN_AND_MEAN;NOMINMAX;_ITERATOR_DEBUG_LEVEL=0;%(PreprocessorDefinitions) + MultiThreadedDebugDLL + Level4 + true + + + + + $(MSBuildThisFileDirectory)include;%(AdditionalIncludeDirectories) + true + true + stdcpp20 + RS50_BRIDGE_EXPORTS;WIN32_LEAN_AND_MEAN;NOMINMAX;NDEBUG;%(PreprocessorDefinitions) + MultiThreadedDLL + Level4 + true + + + true + true + + + + + + + + + diff --git a/Rs50DirectInputBridge/include/rs50_display_bridge.h b/Rs50DirectInputBridge/include/rs50_display_bridge.h new file mode 100644 index 0000000..f380fba --- /dev/null +++ b/Rs50DirectInputBridge/include/rs50_display_bridge.h @@ -0,0 +1,147 @@ +#pragma once + +#include + +#if defined(RS50_BRIDGE_EXPORTS) +#define RS50_BRIDGE_API __declspec(dllexport) +#else +#define RS50_BRIDGE_API __declspec(dllimport) +#endif + +extern "C" +{ + struct rs50_display_handle; + + enum rs50_display_status : std::int32_t + { + RS50_DISPLAY_OK = 0, + RS50_DISPLAY_INVALID_ARGUMENT = 1, + RS50_DISPLAY_INVALID_OWNER_WINDOW = 2, + RS50_DISPLAY_DRIVER_REGISTRATION_MISSING = 3, + RS50_DISPLAY_DRIVER_HASH_MISMATCH = 4, + RS50_DISPLAY_DRIVER_SIGNATURE_INVALID = 5, + RS50_DISPLAY_DIRECTINPUT_CREATE_FAILED = 6, + RS50_DISPLAY_ENUMERATION_FAILED = 7, + RS50_DISPLAY_DEVICE_NOT_FOUND = 8, + RS50_DISPLAY_DEVICE_AMBIGUOUS = 9, + RS50_DISPLAY_COOPERATIVE_LEVEL_FAILED = 10, + RS50_DISPLAY_QUERY_ALREADY_ATTEMPTED = 11, + RS50_DISPLAY_ESCAPE_FAILED = 12, + RS50_DISPLAY_QUERY_OUTPUT_INVALID = 13, + RS50_DISPLAY_INTERNAL_ERROR = 14, + RS50_DISPLAY_ACQUIRE_FAILED = 15, + RS50_DISPLAY_UNACQUIRE_FAILED = 16, + RS50_DISPLAY_DATA_FORMAT_FAILED = 17, + RS50_DISPLAY_OPERATION_ALREADY_ATTEMPTED = 18, + RS50_DISPLAY_SESSION_NOT_STARTED = 19, + RS50_DISPLAY_SESSION_ALREADY_STARTED = 20, + RS50_DISPLAY_FRAME_INVALID = 21, + RS50_DISPLAY_SESSION_FAILED = 22 + }; + + struct rs50_display_query_result + { + std::uint32_t struct_size; + std::uint32_t vendor_id; + std::uint32_t product_id; + std::int32_t cooperative_level_hresult; + std::int32_t data_format_hresult; + std::int32_t acquire_hresult; + std::int32_t escape_hresult; + std::int32_t unacquire_hresult; + std::uint32_t output_capacity_before; + std::uint32_t output_capacity_after; + std::uint8_t supported; + std::uint8_t output_value; + std::uint8_t acquired; + std::uint8_t inner_command; + std::uint8_t output_bytes[12]; + wchar_t product_name[260]; + }; + + struct rs50_display_static_layout_j_result + { + std::uint32_t struct_size; + std::uint32_t vendor_id; + std::uint32_t product_id; + std::int32_t cooperative_level_hresult; + std::int32_t data_format_hresult; + std::int32_t acquire_hresult; + std::int32_t escape_hresult; + std::int32_t unacquire_hresult; + std::uint8_t acquired; + std::uint8_t inner_command; + std::uint8_t reserved[2]; + wchar_t product_name[260]; + }; + + struct rs50_display_layout_j_frame + { + std::uint32_t struct_size; + std::uint8_t row1_length; + std::uint8_t row2_length; + std::uint8_t row3_length; + std::uint8_t row4_length; + char row1[19]; + char row2[10]; + char row3[19]; + char row4[10]; + std::uint8_t reserved[2]; + }; + + struct rs50_display_stream_result + { + std::uint32_t struct_size; + std::uint32_t vendor_id; + std::uint32_t product_id; + std::int32_t cooperative_level_hresult; + std::int32_t data_format_hresult; + std::int32_t acquire_hresult; + std::int32_t escape_hresult; + std::int32_t unacquire_hresult; + std::uint8_t acquired; + std::uint8_t inner_command; + std::uint8_t transmitted; + std::uint8_t unchanged; + std::uint8_t rate_limited; + std::uint8_t reserved[3]; + wchar_t product_name[260]; + }; + + RS50_BRIDGE_API std::uint32_t rs50_display_abi_version() noexcept; + + RS50_BRIDGE_API rs50_display_status rs50_display_open( + std::uintptr_t owner_window, rs50_display_handle **handle) noexcept; + + RS50_BRIDGE_API rs50_display_status + rs50_display_query_support(rs50_display_handle *handle, + rs50_display_query_result *result) noexcept; + + RS50_BRIDGE_API rs50_display_status rs50_display_query_layout_j_support( + rs50_display_handle *handle, + rs50_display_query_result *result) noexcept; + + RS50_BRIDGE_API rs50_display_status rs50_display_set_static_layout_j( + rs50_display_handle *handle, + rs50_display_static_layout_j_result *result) noexcept; + + RS50_BRIDGE_API rs50_display_status + rs50_display_begin_layout_j_stream( + rs50_display_handle *handle, + rs50_display_stream_result *result) noexcept; + + RS50_BRIDGE_API rs50_display_status rs50_display_set_layout_j_frame( + rs50_display_handle *handle, + const rs50_display_layout_j_frame *frame, + rs50_display_stream_result *result) noexcept; + + RS50_BRIDGE_API rs50_display_status rs50_display_end_layout_j_stream( + rs50_display_handle *handle, + rs50_display_stream_result *result) noexcept; + + RS50_BRIDGE_API const wchar_t *rs50_display_status_message( + rs50_display_status status) noexcept; + + RS50_BRIDGE_API void rs50_display_close( + rs50_display_handle *handle) noexcept; +} diff --git a/Rs50DirectInputBridge/include/rs50_layout_j_frame.h b/Rs50DirectInputBridge/include/rs50_layout_j_frame.h new file mode 100644 index 0000000..7a26df7 --- /dev/null +++ b/Rs50DirectInputBridge/include/rs50_layout_j_frame.h @@ -0,0 +1,124 @@ +#pragma once + +#include "rs50_display_bridge.h" + +#include +#include +#include +#include + +namespace rs50::layout_j +{ +constexpr std::uint64_t MinimumFrameIntervalMilliseconds = 200; + +enum class GateDecision +{ + Transmit, + Unchanged, + RateLimited +}; + +inline bool IsCanonicalText(const char *text, std::size_t capacity, + std::uint8_t length) noexcept +{ + if (length > capacity) + { + return false; + } + + for (std::size_t index = 0; index < capacity; ++index) + { + unsigned char value = static_cast(text[index]); + if (index < length) + { + if (value < 0x20 || value > 0x7F) + { + return false; + } + } + else if (value != 0) + { + return false; + } + } + + return true; +} + +inline bool IsValidFrame(const rs50_display_layout_j_frame &frame) noexcept +{ + return frame.struct_size == sizeof(rs50_display_layout_j_frame) && + frame.reserved[0] == 0 && frame.reserved[1] == 0 && + IsCanonicalText(frame.row1, std::size(frame.row1), + frame.row1_length) && + IsCanonicalText(frame.row2, std::size(frame.row2), + frame.row2_length) && + IsCanonicalText(frame.row3, std::size(frame.row3), + frame.row3_length) && + IsCanonicalText(frame.row4, std::size(frame.row4), + frame.row4_length); +} + +inline std::array +MakeDriverArguments(const rs50_display_layout_j_frame &frame) +{ + // Physical Build C proved that the Logitech DirectInput adapter maps + // game-facing arguments 1/2/3/4 to visual rows 2/1/4/3. + return { + std::string(frame.row2, frame.row2_length), + std::string(frame.row1, frame.row1_length), + std::string(frame.row4, frame.row4_length), + std::string(frame.row3, frame.row3_length)}; +} + +inline bool FramesEqual(const rs50_display_layout_j_frame &left, + const rs50_display_layout_j_frame &right) noexcept +{ + if (left.row1_length != right.row1_length || + left.row2_length != right.row2_length || + left.row3_length != right.row3_length || + left.row4_length != right.row4_length) + { + return false; + } + + const auto equalText = [](const char *first, const char *second, + std::uint8_t length) noexcept + { + for (std::uint8_t index = 0; index < length; ++index) + { + if (first[index] != second[index]) + { + return false; + } + } + return true; + }; + + return equalText(left.row1, right.row1, left.row1_length) && + equalText(left.row2, right.row2, left.row2_length) && + equalText(left.row3, right.row3, left.row3_length) && + equalText(left.row4, right.row4, left.row4_length); +} + +inline GateDecision DecideFrame( + const rs50_display_layout_j_frame *lastFrame, + std::uint64_t lastTransmissionMilliseconds, + const rs50_display_layout_j_frame &candidate, + std::uint64_t nowMilliseconds) noexcept +{ + if (lastFrame != nullptr && FramesEqual(*lastFrame, candidate)) + { + return GateDecision::Unchanged; + } + + if (lastFrame != nullptr && + nowMilliseconds - lastTransmissionMilliseconds < + MinimumFrameIntervalMilliseconds) + { + return GateDecision::RateLimited; + } + + return GateDecision::Transmit; +} +} // namespace rs50::layout_j diff --git a/Rs50DirectInputBridge/src/rs50_display_bridge.cpp b/Rs50DirectInputBridge/src/rs50_display_bridge.cpp new file mode 100644 index 0000000..7513eb6 --- /dev/null +++ b/Rs50DirectInputBridge/src/rs50_display_bridge.cpp @@ -0,0 +1,1093 @@ +#define DIRECTINPUT_VERSION 0x0800 + +#include "rs50_display_bridge.h" +#include "rs50_layout_j_frame.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "advapi32.lib") +#pragma comment(lib, "bcrypt.lib") +#pragma comment(lib, "dinput8.lib") +#pragma comment(lib, "dxguid.lib") +#pragma comment(lib, "wintrust.lib") + +namespace +{ +constexpr std::uint32_t AbiVersion = 6; +constexpr std::uint16_t LogitechVendorId = 0x046D; +constexpr std::uint16_t Rs50ProductId = 0xC276; +constexpr DWORD DisplayEscapeCommand = 4; +constexpr std::uint32_t DisplayRequestVersion = 1; +constexpr std::uint8_t QueryDisplaySupportCommand = 2; +constexpr std::uint8_t QueryLayoutJSupportCommand = 12; +constexpr std::uint8_t StaticLayoutJCommand = 22; +constexpr std::uint8_t OutputSentinel = 0xA5; +constexpr DWORD GeneralSupportOutputCapacity = 1; +constexpr DWORD LayoutJSupportOutputCapacity = 10; + +constexpr wchar_t DriverRegistryPath[] = + L"CLSID\\{62B43F0E-E7DB-4329-8C13-A966D84A289F}\\InprocServer32"; + +constexpr GUID ExpectedForceFeedbackDriver = { + 0x62B43F0E, + 0xE7DB, + 0x4329, + {0x8C, 0x13, 0xA9, 0x66, 0xD8, 0x4A, 0x28, 0x9F}}; + +constexpr std::array ExpectedDriverSha256 = { + 0x17, 0xAB, 0x8F, 0xBB, 0x23, 0xFD, 0x54, 0x9C, 0xCC, 0xCD, 0xB7, + 0x2A, 0x50, 0x2C, 0x3B, 0xDC, 0xD9, 0x84, 0xF8, 0x0B, 0x6C, 0x40, + 0xE4, 0x80, 0x27, 0xC2, 0x55, 0x12, 0xD0, 0x40, 0x5A, 0x7F}; + +#pragma pack(push, 4) +struct DisplayRequest +{ + std::uint32_t size; + std::uint32_t version; + std::uint8_t command; + std::uint8_t reserved[3]; +}; + +struct StaticLayoutJRequest +{ + DisplayRequest header; + std::string firstText; + std::string secondText; + std::string thirdText; + std::string fourthText; +}; +#pragma pack(pop) + +static_assert(sizeof(DisplayRequest) == 12); +static_assert(offsetof(DisplayRequest, command) == 8); +static_assert(sizeof(std::string) == 32); +static_assert(sizeof(StaticLayoutJRequest) == 140); +static_assert(offsetof(StaticLayoutJRequest, firstText) == 12); +static_assert(offsetof(StaticLayoutJRequest, secondText) == 44); +static_assert(offsetof(StaticLayoutJRequest, thirdText) == 76); +static_assert(offsetof(StaticLayoutJRequest, fourthText) == 108); +static_assert(sizeof(DIEFFESCAPE) == 40); +static_assert(offsetof(DIEFFESCAPE, dwCommand) == 4); +static_assert(sizeof(rs50_display_query_result) == 576); +static_assert(offsetof(rs50_display_query_result, product_name) == 56); +static_assert(sizeof(rs50_display_static_layout_j_result) == 556); +static_assert( + offsetof(rs50_display_static_layout_j_result, product_name) == 36); +static_assert(sizeof(rs50_display_layout_j_frame) == 68); +static_assert(offsetof(rs50_display_layout_j_frame, row1) == 8); +static_assert(offsetof(rs50_display_layout_j_frame, row2) == 27); +static_assert(offsetof(rs50_display_layout_j_frame, row3) == 37); +static_assert(offsetof(rs50_display_layout_j_frame, row4) == 56); +static_assert(sizeof(rs50_display_stream_result) == 560); +static_assert(offsetof(rs50_display_stream_result, product_name) == 40); + +template class ComPointer final +{ + public: + ComPointer() = default; + + ~ComPointer() + { + Reset(); + } + + ComPointer(const ComPointer &) = delete; + ComPointer &operator=(const ComPointer &) = delete; + + ComPointer(ComPointer &&other) noexcept + : value_(std::exchange(other.value_, nullptr)) + { + } + + ComPointer &operator=(ComPointer &&other) noexcept + { + if (this != &other) + { + Reset(); + value_ = std::exchange(other.value_, nullptr); + } + + return *this; + } + + T *Get() const noexcept + { + return value_; + } + + T **Put() noexcept + { + Reset(); + return &value_; + } + + void Reset() noexcept + { + if (value_ != nullptr) + { + value_->Release(); + value_ = nullptr; + } + } + + private: + T *value_ = nullptr; +}; + +class FileHandle final +{ + public: + explicit FileHandle(HANDLE value) noexcept : value_(value) + { + } + + ~FileHandle() + { + if (value_ != INVALID_HANDLE_VALUE) + { + CloseHandle(value_); + } + } + + FileHandle(const FileHandle &) = delete; + FileHandle &operator=(const FileHandle &) = delete; + + HANDLE Get() const noexcept + { + return value_; + } + + private: + HANDLE value_; +}; + +class AlgorithmHandle final +{ + public: + ~AlgorithmHandle() + { + if (value != nullptr) + { + BCryptCloseAlgorithmProvider(value, 0); + } + } + + BCRYPT_ALG_HANDLE value = nullptr; +}; + +class HashHandle final +{ + public: + ~HashHandle() + { + if (value != nullptr) + { + BCryptDestroyHash(value); + } + } + + BCRYPT_HASH_HANDLE value = nullptr; +}; + +class ExclusiveSrwLock final +{ + public: + explicit ExclusiveSrwLock(SRWLOCK &lock) noexcept : lock_(lock) + { + AcquireSRWLockExclusive(&lock_); + } + + ~ExclusiveSrwLock() + { + ReleaseSRWLockExclusive(&lock_); + } + + ExclusiveSrwLock(const ExclusiveSrwLock &) = delete; + ExclusiveSrwLock &operator=(const ExclusiveSrwLock &) = delete; + + private: + SRWLOCK &lock_; +}; + +bool IsSuccessful(NTSTATUS status) noexcept +{ + return status >= 0; +} + +bool ReadRegisteredDriverPath(std::wstring &path) +{ + DWORD requiredBytes = 0; + LSTATUS status = RegGetValueW(HKEY_CLASSES_ROOT, DriverRegistryPath, + nullptr, RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ, + nullptr, nullptr, &requiredBytes); + + if (status != ERROR_SUCCESS || requiredBytes < sizeof(wchar_t)) + { + return false; + } + + std::vector buffer(requiredBytes / sizeof(wchar_t)); + status = RegGetValueW(HKEY_CLASSES_ROOT, DriverRegistryPath, nullptr, + RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ, nullptr, + buffer.data(), &requiredBytes); + + if (status != ERROR_SUCCESS || buffer.empty() || buffer[0] == L'\0') + { + return false; + } + + path.assign(buffer.data()); + return true; +} + +bool ComputeSha256(const std::wstring &path, + std::array &digest) +{ + FileHandle file(CreateFileW( + path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_SEQUENTIAL_SCAN, nullptr)); + + if (file.Get() == INVALID_HANDLE_VALUE) + { + return false; + } + + AlgorithmHandle algorithm; + if (!IsSuccessful(BCryptOpenAlgorithmProvider( + &algorithm.value, BCRYPT_SHA256_ALGORITHM, nullptr, 0))) + { + return false; + } + + DWORD objectLength = 0; + DWORD copied = 0; + if (!IsSuccessful(BCryptGetProperty(algorithm.value, BCRYPT_OBJECT_LENGTH, + reinterpret_cast(&objectLength), + sizeof(objectLength), &copied, 0)) || + objectLength == 0) + { + return false; + } + + std::vector hashObject(objectLength); + HashHandle hash; + if (!IsSuccessful(BCryptCreateHash( + algorithm.value, &hash.value, hashObject.data(), + static_cast(hashObject.size()), nullptr, 0, 0))) + { + return false; + } + + std::array input{}; + for (;;) + { + DWORD bytesRead = 0; + if (!ReadFile(file.Get(), input.data(), + static_cast(input.size()), &bytesRead, nullptr)) + { + return false; + } + + if (bytesRead == 0) + { + break; + } + + if (!IsSuccessful( + BCryptHashData(hash.value, input.data(), bytesRead, 0))) + { + return false; + } + } + + return IsSuccessful(BCryptFinishHash(hash.value, digest.data(), + static_cast(digest.size()), 0)); +} + +bool HasValidAuthenticodeSignature(const std::wstring &path) +{ + WINTRUST_FILE_INFO fileInfo{}; + fileInfo.cbStruct = sizeof(fileInfo); + fileInfo.pcwszFilePath = path.c_str(); + + WINTRUST_DATA trustData{}; + trustData.cbStruct = sizeof(trustData); + trustData.dwUIChoice = WTD_UI_NONE; + trustData.fdwRevocationChecks = WTD_REVOKE_NONE; + trustData.dwUnionChoice = WTD_CHOICE_FILE; + trustData.pFile = &fileInfo; + trustData.dwStateAction = WTD_STATEACTION_VERIFY; + trustData.dwProvFlags = + WTD_CACHE_ONLY_URL_RETRIEVAL | WTD_REVOCATION_CHECK_NONE; + + GUID action = WINTRUST_ACTION_GENERIC_VERIFY_V2; + LONG result = WinVerifyTrust(nullptr, &action, &trustData); + + trustData.dwStateAction = WTD_STATEACTION_CLOSE; + WinVerifyTrust(nullptr, &action, &trustData); + return result == ERROR_SUCCESS; +} + +rs50_display_status ValidateInstalledDriver() +{ + std::wstring driverPath; + if (!ReadRegisteredDriverPath(driverPath)) + { + return RS50_DISPLAY_DRIVER_REGISTRATION_MISSING; + } + + std::array digest{}; + if (!ComputeSha256(driverPath, digest) || digest != ExpectedDriverSha256) + { + return RS50_DISPLAY_DRIVER_HASH_MISMATCH; + } + + if (!HasValidAuthenticodeSignature(driverPath)) + { + return RS50_DISPLAY_DRIVER_SIGNATURE_INVALID; + } + + return RS50_DISPLAY_OK; +} + +bool IsValidOwnerWindow(HWND window) +{ + if (window == nullptr || !IsWindow(window) || + GetAncestor(window, GA_ROOT) != window) + { + return false; + } + + DWORD processId = 0; + GetWindowThreadProcessId(window, &processId); + return processId == GetCurrentProcessId(); +} + +void CopySanitizedProductName(wchar_t (&destination)[260], + const wchar_t *source) noexcept +{ + std::size_t index = 0; + for (; index + 1 < std::size(destination) && source[index] != L'\0'; + ++index) + { + wchar_t character = source[index]; + destination[index] = + character >= 0x20 && character <= 0x7E ? character : L'?'; + } + + destination[index] = L'\0'; +} + +struct Candidate +{ + ComPointer device; + DIDEVICEINSTANCEW instance{}; +}; + +struct EnumerationContext +{ + IDirectInput8W *directInput = nullptr; + std::vector candidates; + bool internalFailure = false; +}; + +BOOL CALLBACK EnumerateDevices(const DIDEVICEINSTANCEW *instance, + void *contextValue) +{ + auto &context = *static_cast(contextValue); + + ComPointer device; + HRESULT result = context.directInput->CreateDevice(instance->guidInstance, + device.Put(), nullptr); + + if (FAILED(result)) + { + return DIENUM_CONTINUE; + } + + DIPROPDWORD vidPid{}; + vidPid.diph.dwSize = sizeof(vidPid); + vidPid.diph.dwHeaderSize = sizeof(vidPid.diph); + vidPid.diph.dwHow = DIPH_DEVICE; + + result = device.Get()->GetProperty(DIPROP_VIDPID, &vidPid.diph); + + if (FAILED(result)) + { + return DIENUM_CONTINUE; + } + + std::uint16_t vendor = LOWORD(vidPid.dwData); + std::uint16_t product = HIWORD(vidPid.dwData); + + if (vendor != LogitechVendorId || product != Rs50ProductId || + !IsEqualGUID(instance->guidFFDriver, ExpectedForceFeedbackDriver)) + { + return DIENUM_CONTINUE; + } + + try + { + Candidate candidate; + candidate.device = std::move(device); + candidate.instance = *instance; + context.candidates.push_back(std::move(candidate)); + } + catch (...) + { + context.internalFailure = true; + return DIENUM_STOP; + } + + return DIENUM_CONTINUE; +} +} // namespace + +struct rs50_display_handle +{ + ComPointer directInput; + ComPointer device; + DIDEVICEINSTANCEW instance{}; + HRESULT cooperativeLevelResult = E_UNEXPECTED; + SRWLOCK queryLock = SRWLOCK_INIT; + bool operationAttempted = false; + bool acquired = false; + bool streamStarted = false; + bool streamFailed = false; + bool hasLastFrame = false; + rs50_display_layout_j_frame lastFrame{}; + std::uint64_t lastTransmissionMilliseconds = 0; +}; + +namespace +{ +struct EscapeLifecycleResult +{ + HRESULT dataFormat = E_UNEXPECTED; + HRESULT acquire = E_UNEXPECTED; + HRESULT escape = E_UNEXPECTED; + HRESULT unacquire = E_UNEXPECTED; + DWORD outputCapacity = 0; + bool acquired = false; +}; + +rs50_display_status AcquireDevice(rs50_display_handle *handle, + EscapeLifecycleResult &result) noexcept +{ + result.dataFormat = handle->device.Get()->SetDataFormat(&c_dfDIJoystick2); + if (FAILED(result.dataFormat)) + { + return RS50_DISPLAY_DATA_FORMAT_FAILED; + } + + result.acquire = handle->device.Get()->Acquire(); + if (FAILED(result.acquire)) + { + return RS50_DISPLAY_ACQUIRE_FAILED; + } + + handle->acquired = true; + result.acquired = true; + return RS50_DISPLAY_OK; +} + +rs50_display_status ReleaseDevice(rs50_display_handle *handle, + HRESULT &unacquireResult) noexcept +{ + unacquireResult = handle->device.Get()->Unacquire(); + + if (SUCCEEDED(unacquireResult)) + { + handle->acquired = false; + return RS50_DISPLAY_OK; + } + + return RS50_DISPLAY_UNACQUIRE_FAILED; +} + +HRESULT PerformEscape(rs50_display_handle *handle, void *inputBuffer, + DWORD inputCapacity, void *outputBuffer, + DWORD &outputCapacity) noexcept +{ + DIEFFESCAPE escape{}; + escape.dwSize = sizeof(escape); + escape.dwCommand = DisplayEscapeCommand; + escape.lpvInBuffer = inputBuffer; + escape.cbInBuffer = inputCapacity; + escape.lpvOutBuffer = outputBuffer; + escape.cbOutBuffer = outputCapacity; + + HRESULT result = handle->device.Get()->Escape(&escape); + outputCapacity = escape.cbOutBuffer; + return result; +} + +rs50_display_status ExecuteEscape(rs50_display_handle *handle, + void *inputBuffer, DWORD inputCapacity, + void *outputBuffer, DWORD outputCapacity, + EscapeLifecycleResult &result) noexcept +{ + ExclusiveSrwLock lock(handle->queryLock); + + if (handle->operationAttempted) + { + return RS50_DISPLAY_OPERATION_ALREADY_ATTEMPTED; + } + + handle->operationAttempted = true; + result.outputCapacity = outputCapacity; + + rs50_display_status status = AcquireDevice(handle, result); + if (status != RS50_DISPLAY_OK) + { + return status; + } + + result.escape = + PerformEscape(handle, inputBuffer, inputCapacity, outputBuffer, + result.outputCapacity); + rs50_display_status releaseStatus = + ReleaseDevice(handle, result.unacquire); + + if (releaseStatus != RS50_DISPLAY_OK) + { + return releaseStatus; + } + + return FAILED(result.escape) ? RS50_DISPLAY_ESCAPE_FAILED + : RS50_DISPLAY_OK; +} + +rs50_display_status ExecuteQuery(rs50_display_handle *handle, + rs50_display_query_result *result, + std::uint8_t innerCommand, + DWORD outputCapacity) noexcept +{ + if (handle == nullptr || result == nullptr || + result->struct_size != sizeof(rs50_display_query_result) || + (innerCommand != QueryDisplaySupportCommand && + innerCommand != QueryLayoutJSupportCommand) || + (outputCapacity != GeneralSupportOutputCapacity && + outputCapacity != LayoutJSupportOutputCapacity)) + { + return RS50_DISPLAY_INVALID_ARGUMENT; + } + + rs50_display_query_result local{}; + local.struct_size = sizeof(local); + local.vendor_id = LogitechVendorId; + local.product_id = Rs50ProductId; + local.cooperative_level_hresult = handle->cooperativeLevelResult; + local.data_format_hresult = E_UNEXPECTED; + local.acquire_hresult = E_UNEXPECTED; + local.escape_hresult = E_UNEXPECTED; + local.unacquire_hresult = E_UNEXPECTED; + local.output_capacity_before = outputCapacity; + local.output_capacity_after = outputCapacity; + local.acquired = 0; + local.output_value = OutputSentinel; + local.inner_command = innerCommand; + for (std::uint8_t &value : local.output_bytes) + { + value = OutputSentinel; + } + + CopySanitizedProductName(local.product_name, + handle->instance.tszProductName); + + DisplayRequest request{}; + request.size = sizeof(request); + request.version = DisplayRequestVersion; + request.command = innerCommand; + + EscapeLifecycleResult lifecycle; + rs50_display_status status = + ExecuteEscape(handle, &request, sizeof(request), local.output_bytes, + outputCapacity, lifecycle); + + local.data_format_hresult = lifecycle.dataFormat; + local.acquire_hresult = lifecycle.acquire; + local.escape_hresult = lifecycle.escape; + local.unacquire_hresult = lifecycle.unacquire; + local.output_capacity_after = lifecycle.outputCapacity; + local.acquired = lifecycle.acquired ? 1 : 0; + local.output_value = local.output_bytes[0]; + local.supported = local.output_value == 1 ? 1 : 0; + *result = local; + + if (status != RS50_DISPLAY_OK) + { + return status; + } + + if (local.output_capacity_after != outputCapacity || + local.output_value > 1) + { + return RS50_DISPLAY_QUERY_OUTPUT_INVALID; + } + + for (DWORD index = 1; index < outputCapacity; ++index) + { + if (local.output_bytes[index] != OutputSentinel) + { + return RS50_DISPLAY_QUERY_OUTPUT_INVALID; + } + } + + return RS50_DISPLAY_OK; +} + +rs50_display_status ExecuteStaticLayoutJ( + rs50_display_handle *handle, + rs50_display_static_layout_j_result *result) noexcept +{ + if (handle == nullptr || result == nullptr || + result->struct_size != sizeof(rs50_display_static_layout_j_result)) + { + return RS50_DISPLAY_INVALID_ARGUMENT; + } + + rs50_display_static_layout_j_result local{}; + local.struct_size = sizeof(local); + local.vendor_id = LogitechVendorId; + local.product_id = Rs50ProductId; + local.cooperative_level_hresult = handle->cooperativeLevelResult; + local.data_format_hresult = E_UNEXPECTED; + local.acquire_hresult = E_UNEXPECTED; + local.escape_hresult = E_UNEXPECTED; + local.unacquire_hresult = E_UNEXPECTED; + local.inner_command = StaticLayoutJCommand; + CopySanitizedProductName(local.product_name, + handle->instance.tszProductName); + + try + { + StaticLayoutJRequest request{ + {sizeof(StaticLayoutJRequest), DisplayRequestVersion, + StaticLayoutJCommand, {}}, + "LOGIDYNAMICDASH", + "RS50", + "OLED LINK", + "TEST 1"}; + + EscapeLifecycleResult lifecycle; + rs50_display_status status = + ExecuteEscape(handle, &request, sizeof(request), nullptr, 0, + lifecycle); + + local.data_format_hresult = lifecycle.dataFormat; + local.acquire_hresult = lifecycle.acquire; + local.escape_hresult = lifecycle.escape; + local.unacquire_hresult = lifecycle.unacquire; + local.acquired = lifecycle.acquired ? 1 : 0; + *result = local; + return status; + } + catch (...) + { + *result = local; + return RS50_DISPLAY_INTERNAL_ERROR; + } +} + +rs50_display_stream_result +CreateStreamResult(const rs50_display_handle *handle) noexcept +{ + rs50_display_stream_result result{}; + result.struct_size = sizeof(result); + result.vendor_id = LogitechVendorId; + result.product_id = Rs50ProductId; + result.cooperative_level_hresult = handle->cooperativeLevelResult; + result.data_format_hresult = E_UNEXPECTED; + result.acquire_hresult = E_UNEXPECTED; + result.escape_hresult = E_UNEXPECTED; + result.unacquire_hresult = E_UNEXPECTED; + result.inner_command = StaticLayoutJCommand; + CopySanitizedProductName(result.product_name, + handle->instance.tszProductName); + return result; +} + +rs50_display_status BeginLayoutJStream( + rs50_display_handle *handle, + rs50_display_stream_result *result) noexcept +{ + if (handle == nullptr || result == nullptr || + result->struct_size != sizeof(rs50_display_stream_result)) + { + return RS50_DISPLAY_INVALID_ARGUMENT; + } + + rs50_display_stream_result local = CreateStreamResult(handle); + ExclusiveSrwLock lock(handle->queryLock); + + if (handle->streamStarted) + { + *result = local; + return RS50_DISPLAY_SESSION_ALREADY_STARTED; + } + + if (handle->operationAttempted) + { + *result = local; + return RS50_DISPLAY_OPERATION_ALREADY_ATTEMPTED; + } + + handle->operationAttempted = true; + EscapeLifecycleResult lifecycle; + rs50_display_status status = AcquireDevice(handle, lifecycle); + local.data_format_hresult = lifecycle.dataFormat; + local.acquire_hresult = lifecycle.acquire; + local.acquired = lifecycle.acquired ? 1 : 0; + + if (status == RS50_DISPLAY_OK) + { + handle->streamStarted = true; + handle->streamFailed = false; + handle->hasLastFrame = false; + } + + *result = local; + return status; +} + +rs50_display_status SetLayoutJFrame( + rs50_display_handle *handle, + const rs50_display_layout_j_frame *frame, + rs50_display_stream_result *result) noexcept +{ + if (handle == nullptr || frame == nullptr || result == nullptr || + result->struct_size != sizeof(rs50_display_stream_result)) + { + return RS50_DISPLAY_INVALID_ARGUMENT; + } + + rs50_display_stream_result local = CreateStreamResult(handle); + if (!rs50::layout_j::IsValidFrame(*frame)) + { + *result = local; + return RS50_DISPLAY_FRAME_INVALID; + } + + ExclusiveSrwLock lock(handle->queryLock); + local.acquired = handle->acquired ? 1 : 0; + + if (!handle->streamStarted || !handle->acquired) + { + *result = local; + return RS50_DISPLAY_SESSION_NOT_STARTED; + } + + if (handle->streamFailed) + { + *result = local; + return RS50_DISPLAY_SESSION_FAILED; + } + + std::uint64_t nowMilliseconds = GetTickCount64(); + const rs50_display_layout_j_frame *lastFrame = + handle->hasLastFrame ? &handle->lastFrame : nullptr; + rs50::layout_j::GateDecision decision = rs50::layout_j::DecideFrame( + lastFrame, handle->lastTransmissionMilliseconds, *frame, + nowMilliseconds); + + if (decision == rs50::layout_j::GateDecision::Unchanged) + { + local.unchanged = 1; + *result = local; + return RS50_DISPLAY_OK; + } + + if (decision == rs50::layout_j::GateDecision::RateLimited) + { + local.rate_limited = 1; + *result = local; + return RS50_DISPLAY_OK; + } + + try + { + std::array arguments = + rs50::layout_j::MakeDriverArguments(*frame); + StaticLayoutJRequest request{ + {sizeof(StaticLayoutJRequest), DisplayRequestVersion, + StaticLayoutJCommand, {}}, + std::move(arguments[0]), + std::move(arguments[1]), + std::move(arguments[2]), + std::move(arguments[3])}; + + DWORD outputCapacity = 0; + local.escape_hresult = + PerformEscape(handle, &request, sizeof(request), nullptr, + outputCapacity); + + if (FAILED(local.escape_hresult)) + { + handle->streamFailed = true; + *result = local; + return RS50_DISPLAY_ESCAPE_FAILED; + } + + handle->lastFrame = *frame; + handle->lastTransmissionMilliseconds = nowMilliseconds; + handle->hasLastFrame = true; + local.transmitted = 1; + *result = local; + return RS50_DISPLAY_OK; + } + catch (...) + { + handle->streamFailed = true; + *result = local; + return RS50_DISPLAY_INTERNAL_ERROR; + } +} + +rs50_display_status EndLayoutJStream( + rs50_display_handle *handle, + rs50_display_stream_result *result) noexcept +{ + if (handle == nullptr || result == nullptr || + result->struct_size != sizeof(rs50_display_stream_result)) + { + return RS50_DISPLAY_INVALID_ARGUMENT; + } + + rs50_display_stream_result local = CreateStreamResult(handle); + ExclusiveSrwLock lock(handle->queryLock); + local.acquired = handle->acquired ? 1 : 0; + + if (!handle->streamStarted || !handle->acquired) + { + *result = local; + return RS50_DISPLAY_SESSION_NOT_STARTED; + } + + HRESULT unacquireResult = E_UNEXPECTED; + rs50_display_status status = + ReleaseDevice(handle, unacquireResult); + local.unacquire_hresult = unacquireResult; + if (status == RS50_DISPLAY_OK) + { + handle->streamStarted = false; + local.acquired = 0; + } + + *result = local; + return status; +} +} // namespace + +std::uint32_t rs50_display_abi_version() noexcept +{ + return AbiVersion; +} + +rs50_display_status rs50_display_open( + std::uintptr_t ownerWindowValue, + rs50_display_handle **outputHandle) noexcept +{ + if (outputHandle == nullptr) + { + return RS50_DISPLAY_INVALID_ARGUMENT; + } + + *outputHandle = nullptr; + HWND ownerWindow = reinterpret_cast(ownerWindowValue); + if (!IsValidOwnerWindow(ownerWindow)) + { + return RS50_DISPLAY_INVALID_OWNER_WINDOW; + } + + try + { + rs50_display_status driverStatus = ValidateInstalledDriver(); + if (driverStatus != RS50_DISPLAY_OK) + { + return driverStatus; + } + + std::unique_ptr handle(new (std::nothrow) + rs50_display_handle()); + if (!handle) + { + return RS50_DISPLAY_INTERNAL_ERROR; + } + + HRESULT result = DirectInput8Create( + GetModuleHandleW(nullptr), DIRECTINPUT_VERSION, IID_IDirectInput8W, + reinterpret_cast(handle->directInput.Put()), nullptr); + + if (FAILED(result)) + { + return RS50_DISPLAY_DIRECTINPUT_CREATE_FAILED; + } + + EnumerationContext context; + context.directInput = handle->directInput.Get(); + result = handle->directInput.Get()->EnumDevices( + DI8DEVCLASS_GAMECTRL, EnumerateDevices, &context, + DIEDFL_ATTACHEDONLY); + + if (FAILED(result)) + { + return RS50_DISPLAY_ENUMERATION_FAILED; + } + + if (context.internalFailure) + { + return RS50_DISPLAY_INTERNAL_ERROR; + } + + if (context.candidates.empty()) + { + return RS50_DISPLAY_DEVICE_NOT_FOUND; + } + + if (context.candidates.size() != 1) + { + return RS50_DISPLAY_DEVICE_AMBIGUOUS; + } + + handle->device = std::move(context.candidates[0].device); + handle->instance = context.candidates[0].instance; + handle->cooperativeLevelResult = + handle->device.Get()->SetCooperativeLevel( + ownerWindow, DISCL_EXCLUSIVE | DISCL_FOREGROUND); + + if (FAILED(handle->cooperativeLevelResult)) + { + return RS50_DISPLAY_COOPERATIVE_LEVEL_FAILED; + } + + *outputHandle = handle.release(); + return RS50_DISPLAY_OK; + } + catch (...) + { + return RS50_DISPLAY_INTERNAL_ERROR; + } +} + +rs50_display_status rs50_display_query_support( + rs50_display_handle *handle, rs50_display_query_result *result) noexcept +{ + return ExecuteQuery(handle, result, QueryDisplaySupportCommand, + GeneralSupportOutputCapacity); +} + +rs50_display_status rs50_display_query_layout_j_support( + rs50_display_handle *handle, rs50_display_query_result *result) noexcept +{ + return ExecuteQuery(handle, result, QueryLayoutJSupportCommand, + LayoutJSupportOutputCapacity); +} + +rs50_display_status rs50_display_set_static_layout_j( + rs50_display_handle *handle, + rs50_display_static_layout_j_result *result) noexcept +{ + return ExecuteStaticLayoutJ(handle, result); +} + +rs50_display_status rs50_display_begin_layout_j_stream( + rs50_display_handle *handle, + rs50_display_stream_result *result) noexcept +{ + return BeginLayoutJStream(handle, result); +} + +rs50_display_status rs50_display_set_layout_j_frame( + rs50_display_handle *handle, + const rs50_display_layout_j_frame *frame, + rs50_display_stream_result *result) noexcept +{ + return SetLayoutJFrame(handle, frame, result); +} + +rs50_display_status rs50_display_end_layout_j_stream( + rs50_display_handle *handle, + rs50_display_stream_result *result) noexcept +{ + return EndLayoutJStream(handle, result); +} + +const wchar_t *rs50_display_status_message(rs50_display_status status) noexcept +{ + switch (status) + { + case RS50_DISPLAY_OK: + return L"OK"; + case RS50_DISPLAY_INVALID_ARGUMENT: + return L"Invalid argument or ABI size"; + case RS50_DISPLAY_INVALID_OWNER_WINDOW: + return L"Owner must be a top-level window owned by this process"; + case RS50_DISPLAY_DRIVER_REGISTRATION_MISSING: + return L"Expected Logitech force-feedback driver is not registered"; + case RS50_DISPLAY_DRIVER_HASH_MISMATCH: + return L"Installed Logitech driver hash differs from the audited build"; + case RS50_DISPLAY_DRIVER_SIGNATURE_INVALID: + return L"Installed Logitech driver signature is not valid"; + case RS50_DISPLAY_DIRECTINPUT_CREATE_FAILED: + return L"DirectInput8Create failed"; + case RS50_DISPLAY_ENUMERATION_FAILED: + return L"DirectInput device enumeration failed"; + case RS50_DISPLAY_DEVICE_NOT_FOUND: + return L"Exactly supported RS50 device was not found"; + case RS50_DISPLAY_DEVICE_AMBIGUOUS: + return L"More than one matching RS50 device was found"; + case RS50_DISPLAY_COOPERATIVE_LEVEL_FAILED: + return L"Exclusive foreground cooperative level failed"; + case RS50_DISPLAY_ACQUIRE_FAILED: + return L"Exclusive foreground device acquisition failed"; + case RS50_DISPLAY_DATA_FORMAT_FAILED: + return L"Standard joystick data format setup failed"; + case RS50_DISPLAY_UNACQUIRE_FAILED: + return L"Device release failed after the support query"; + case RS50_DISPLAY_QUERY_ALREADY_ATTEMPTED: + return L"Support query was already attempted on this handle"; + case RS50_DISPLAY_OPERATION_ALREADY_ATTEMPTED: + return L"A display operation was already attempted on this handle"; + case RS50_DISPLAY_ESCAPE_FAILED: + return L"Documented display operation failed"; + case RS50_DISPLAY_QUERY_OUTPUT_INVALID: + return L"Support query returned an unexpected output shape or value"; + case RS50_DISPLAY_SESSION_NOT_STARTED: + return L"Layout J stream session is not active"; + case RS50_DISPLAY_SESSION_ALREADY_STARTED: + return L"Layout J stream session is already active"; + case RS50_DISPLAY_FRAME_INVALID: + return L"Layout J frame is not canonical printable ASCII"; + case RS50_DISPLAY_SESSION_FAILED: + return L"Layout J stream stopped after an earlier failure"; + default: + return L"Internal bridge error"; + } +} + +void rs50_display_close(rs50_display_handle *handle) noexcept +{ + if (handle != nullptr && handle->acquired && + handle->device.Get() != nullptr) + { + HRESULT ignored = E_UNEXPECTED; + ReleaseDevice(handle, ignored); + } + + delete handle; +} diff --git a/Rs50DirectInputQuery/Rs50DirectInputQuery.vcxproj b/Rs50DirectInputQuery/Rs50DirectInputQuery.vcxproj new file mode 100644 index 0000000..77cb213 --- /dev/null +++ b/Rs50DirectInputQuery/Rs50DirectInputQuery.vcxproj @@ -0,0 +1,76 @@ + + + + + Debug + x64 + + + Release + x64 + + + + 18.0 + Win32Proj + {7FF40D47-BA2A-4AE1-A21B-546FA79159D6} + Rs50DirectInputQuery + 10.0.26100.0 + + + + Application + true + v145 + Unicode + + + Application + false + v145 + true + Unicode + + + + $(MSBuildThisFileDirectory)..\artifacts\native\$(Configuration)\ + $(MSBuildThisFileDirectory)obj\$(Configuration)\ + Rs50DirectInputQuery + false + + + + $(MSBuildThisFileDirectory)..\Rs50DirectInputBridge\include;%(AdditionalIncludeDirectories) + stdcpp20 + WIN32_LEAN_AND_MEAN;NOMINMAX;%(PreprocessorDefinitions) + MultiThreadedDebugDLL + Level4 + true + + + + + $(MSBuildThisFileDirectory)..\Rs50DirectInputBridge\include;%(AdditionalIncludeDirectories) + true + true + stdcpp20 + WIN32_LEAN_AND_MEAN;NOMINMAX;NDEBUG;%(PreprocessorDefinitions) + MultiThreadedDLL + Level4 + true + + + true + true + + + + + + + + {6D37AF52-F9E3-4477-91AD-A8E2B76DD3BD} + + + + diff --git a/Rs50DirectInputQuery/src/main.cpp b/Rs50DirectInputQuery/src/main.cpp new file mode 100644 index 0000000..89ebdcd --- /dev/null +++ b/Rs50DirectInputQuery/src/main.cpp @@ -0,0 +1,245 @@ +#include "rs50_display_bridge.h" + +#include + +#include +#include +#include +#include + +namespace +{ +constexpr wchar_t WindowClassName[] = L"LogiDynamicDash.Rs50DirectInputQuery"; + +enum class QueryMode +{ + GeneralSupport, + LayoutJSupport, + StaticLayoutJ +}; + +LRESULT CALLBACK WindowProcedure(HWND window, UINT message, WPARAM wParam, + LPARAM lParam) +{ + return DefWindowProcW(window, message, wParam, lParam); +} + +HWND CreateOwnerWindow() +{ + WNDCLASSW windowClass{}; + windowClass.lpfnWndProc = WindowProcedure; + windowClass.hInstance = GetModuleHandleW(nullptr); + windowClass.lpszClassName = WindowClassName; + + if (RegisterClassW(&windowClass) == 0 && + GetLastError() != ERROR_CLASS_ALREADY_EXISTS) + { + return nullptr; + } + + return CreateWindowExW(0, WindowClassName, L"RS50 display support query", + WS_OVERLAPPED, CW_USEDEFAULT, CW_USEDEFAULT, 320, + 200, nullptr, nullptr, windowClass.hInstance, + nullptr); +} + +void PrintUsage() +{ + std::wcout + << L"This executable contains one fixed Layout J setter and no " + << L"arbitrary display API.\n" + << L"It will transmit only when every mode-specific confirmation " + << L"argument is present.\n\n" + << L"Safe metadata mode:\n" + << L" Rs50DirectInputQuery.exe --describe\n\n" + << L"Transmitting mode (requires explicit approval and capture):\n" + << L" Rs50DirectInputQuery.exe " + << L"--query-display-support --confirm-standard-data-format " + << L"--confirm-exclusive-acquire " << L"--confirm-transmit-query\n" + << L" Rs50DirectInputQuery.exe " + << L"--query-layout-j-support --confirm-standard-data-format " + << L"--confirm-exclusive-acquire " + << L"--confirm-transmit-layout-query\n" + << L" Rs50DirectInputQuery.exe " + << L"--set-static-layout-j --confirm-standard-data-format " + << L"--confirm-exclusive-acquire " + << L"--confirm-layout-j-static-text " + << L"--confirm-transmit-static-setter\n"; +} + +void PrintUtcTimestamp(std::wstring_view label) +{ + SYSTEMTIME utc{}; + GetSystemTime(&utc); + + std::wcout << label << L": " << std::setfill(L'0') << std::setw(4) + << utc.wYear << L"-" << std::setw(2) << utc.wMonth << L"-" + << std::setw(2) << utc.wDay << L"T" << std::setw(2) << utc.wHour + << L":" << std::setw(2) << utc.wMinute << L":" << std::setw(2) + << utc.wSecond << L"." << std::setw(3) << utc.wMilliseconds + << L"Z\n"; +} +} // namespace + +int wmain(int argumentCount, wchar_t *arguments[]) +{ + if (argumentCount == 2 && std::wstring_view(arguments[1]) == L"--describe") + { + std::wcout << L"RS50 guarded DirectInput bridge ABI " + << rs50_display_abi_version() + << L"\nNo hardware was enumerated or opened.\n"; + return 0; + } + + bool commonArgumentsValid = + argumentCount == 5 && + std::wstring_view(arguments[2]) == L"--confirm-standard-data-format" && + std::wstring_view(arguments[3]) == L"--confirm-exclusive-acquire"; + bool isGeneralQuery = + commonArgumentsValid && + std::wstring_view(arguments[1]) == L"--query-display-support" && + std::wstring_view(arguments[4]) == L"--confirm-transmit-query"; + bool isLayoutJQuery = + commonArgumentsValid && + std::wstring_view(arguments[1]) == L"--query-layout-j-support" && + std::wstring_view(arguments[4]) == L"--confirm-transmit-layout-query"; + bool isStaticLayoutJ = + argumentCount == 6 && + std::wstring_view(arguments[1]) == L"--set-static-layout-j" && + std::wstring_view(arguments[2]) == L"--confirm-standard-data-format" && + std::wstring_view(arguments[3]) == L"--confirm-exclusive-acquire" && + std::wstring_view(arguments[4]) == + L"--confirm-layout-j-static-text" && + std::wstring_view(arguments[5]) == + L"--confirm-transmit-static-setter"; + + if (!isGeneralQuery && !isLayoutJQuery && !isStaticLayoutJ) + { + PrintUsage(); + return 2; + } + + QueryMode queryMode = isStaticLayoutJ + ? QueryMode::StaticLayoutJ + : (isLayoutJQuery ? QueryMode::LayoutJSupport + : QueryMode::GeneralSupport); + + HWND ownerWindow = CreateOwnerWindow(); + if (ownerWindow == nullptr) + { + std::wcerr << L"Could not create the process-owned window.\n"; + return 3; + } + + ShowWindow(ownerWindow, SW_SHOWNORMAL); + UpdateWindow(ownerWindow); + SetForegroundWindow(ownerWindow); + + if (GetForegroundWindow() != ownerWindow) + { + std::wcerr << L"Could not make the process-owned window foreground.\n"; + DestroyWindow(ownerWindow); + return 3; + } + + rs50_display_handle *handle = nullptr; + rs50_display_status status = rs50_display_open( + reinterpret_cast(ownerWindow), &handle); + + if (status != RS50_DISPLAY_OK) + { + std::wcerr << L"Open failed: " << rs50_display_status_message(status) + << L"\n"; + DestroyWindow(ownerWindow); + return 4; + } + + if (queryMode == QueryMode::StaticLayoutJ) + { + rs50_display_static_layout_j_result result{}; + result.struct_size = sizeof(result); + PrintUtcTimestamp(L"Static setter call started UTC"); + status = rs50_display_set_static_layout_j(handle, &result); + PrintUtcTimestamp(L"Static setter call completed UTC"); + + std::wcout + << L"Operation: fixed Layout J static text\n" + << L"Text: LOGIDYNAMICDASH | RS50 | OLED LINK | TEST 1\n" + << L"Product: " << result.product_name << L"\n" + << L"VID: 0x" << std::hex << result.vendor_id << L" PID: 0x" + << result.product_id << std::dec << L"\n" + << L"Acquired: " << static_cast(result.acquired) + << L"\nCooperative HRESULT: 0x" << std::hex + << static_cast( + result.cooperative_level_hresult) + << L"\nData format HRESULT: 0x" + << static_cast(result.data_format_hresult) + << L"\nAcquire HRESULT: 0x" + << static_cast(result.acquire_hresult) + << L"\nEscape HRESULT: 0x" + << static_cast(result.escape_hresult) + << L"\nUnacquire HRESULT: 0x" + << static_cast(result.unacquire_hresult) + << std::dec << L"\nInner command: " + << static_cast(result.inner_command) << L"\n"; + } + else + { + rs50_display_query_result result{}; + result.struct_size = sizeof(result); + PrintUtcTimestamp(L"Query call started UTC"); + status = queryMode == QueryMode::LayoutJSupport + ? rs50_display_query_layout_j_support(handle, &result) + : rs50_display_query_support(handle, &result); + PrintUtcTimestamp(L"Query call completed UTC"); + + std::wcout << L"Query: " + << (queryMode == QueryMode::LayoutJSupport + ? L"Layout J support" + : L"General display support") + << L"\nProduct: " << result.product_name << L"\n" + << L"VID: 0x" << std::hex << result.vendor_id << L" PID: 0x" + << result.product_id << std::dec << L"\n" + << L"Acquired: " << static_cast(result.acquired) + << L"\n" + << L"Cooperative HRESULT: 0x" << std::hex + << static_cast(result.cooperative_level_hresult) + << L"\nData format HRESULT: 0x" + << static_cast(result.data_format_hresult) + << L"\nAcquire HRESULT: 0x" + << static_cast(result.acquire_hresult) + << L"\nEscape HRESULT: 0x" + << static_cast(result.escape_hresult) << std::dec + << L"\nUnacquire HRESULT: 0x" << std::hex + << static_cast(result.unacquire_hresult) + << std::dec << L"\nOutput capacity: " + << result.output_capacity_before << L" -> " + << result.output_capacity_after << L"\nInner command: " + << static_cast(result.inner_command) + << L"\nOutput byte: 0x" << std::hex + << static_cast(result.output_value) + << L"\nOutput bytes:"; + + std::uint32_t displayedOutputSize = result.output_capacity_before < 12 + ? result.output_capacity_before + : 12; + for (std::uint32_t index = 0; index < displayedOutputSize; ++index) + { + std::wcout << L" " << std::setw(2) + << static_cast(result.output_bytes[index]); + } + + std::wcout << std::dec << L"\nSupported: " + << static_cast(result.supported) << L"\n"; + } + + if (status != RS50_DISPLAY_OK) + { + std::wcerr << L"Query failed: " << rs50_display_status_message(status) + << L"\n"; + } + + rs50_display_close(handle); + DestroyWindow(ownerWindow); + return status == RS50_DISPLAY_OK ? 0 : 5; +} diff --git a/Rs50SharedHidppBoundedStream/BuildHBoundedStreamProgram.cs b/Rs50SharedHidppBoundedStream/BuildHBoundedStreamProgram.cs new file mode 100644 index 0000000..fe39f92 --- /dev/null +++ b/Rs50SharedHidppBoundedStream/BuildHBoundedStreamProgram.cs @@ -0,0 +1,116 @@ +using LogiDynamicDash.Hidpp; +using Rs50SharedHidppTransport; + +namespace Rs50SharedHidppBoundedStream; + +internal static class BuildHBoundedStreamProgram +{ + private static readonly string[] ArmingArguments = + [ + "--arm-rs50-shared-hidpp-bounded-stream", + "--confirm-ghub-closed", + "--confirm-iracing-closed", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running", + "--confirm-one-hz-five-fixed-frames" + ]; + + private const string Line1 = "RS50 SHARED HIDPP"; + private const string Line2 = "BUILD H"; + private const string Line4 = "1 HZ"; + + private static int Main(string[] arguments) => + Run( + arguments, + Rs50HidppDeviceExchange.Open, + new BuildHSystemDelay(), + Console.Out, + Console.Error); + + internal static int Run( + string[] arguments, + Func exchangeFactory, + IBuildHDelay delay, + TextWriter output, + TextWriter error) + { + ArgumentNullException.ThrowIfNull(arguments); + ArgumentNullException.ThrowIfNull(exchangeFactory); + ArgumentNullException.ThrowIfNull(delay); + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(error); + + if (!arguments.SequenceEqual( + ArmingArguments, + StringComparer.Ordinal)) + { + PrintUsage(error); + return 2; + } + + try + { + using IRs50HidppDisplayExchange exchange = + exchangeFactory(); + + byte[] discoveryResponse = exchange.Exchange( + Rs50HidppDisplayProtocol.CreateDiscovery()); + byte runtimeIndex = + Rs50HidppDisplayProtocol.ParseDiscoveryResponse( + discoveryResponse); + + SendFrame(exchange, runtimeIndex, "FRAME 1 OF 5"); + delay.WaitOneSecond(); + SendFrame(exchange, runtimeIndex, "FRAME 2 OF 5"); + delay.WaitOneSecond(); + SendFrame(exchange, runtimeIndex, "FRAME 3 OF 5"); + delay.WaitOneSecond(); + SendFrame(exchange, runtimeIndex, "FRAME 4 OF 5"); + delay.WaitOneSecond(); + SendFrame(exchange, runtimeIndex, "FRAME 5 OF 5"); + + output.WriteLine( + "Build H completed: five fixed Layout J frames were " + + "acknowledged at 1 Hz and the HID streams were closed."); + return 0; + } + catch (Exception exception) + { + error.WriteLine( + $"Build H failed closed: {exception.Message}"); + return 1; + } + } + + private static void SendFrame( + IRs50HidppDisplayExchange exchange, + byte runtimeIndex, + string line3) + { + Rs50HidppDisplayTransaction frame = + Rs50HidppDisplayProtocol.CreateLayoutJ( + runtimeIndex, + Line1, + Line2, + line3, + Line4); + byte[] acknowledgement = exchange.Exchange(frame); + Rs50HidppDisplayProtocol.ParseLayoutJAcknowledgement( + runtimeIndex, + acknowledgement); + } + + private static void PrintUsage(TextWriter error) + { + error.WriteLine( + "Build H is a physical RS50 bounded-stream tool. It sends " + + "exactly five fixed Layout J frames at 1 Hz after discovery."); + error.WriteLine( + "Run it only for a separately authorized stationary capture " + + "with G HUB and iRacing closed:"); + error.WriteLine( + " Rs50SharedHidppBoundedStream.exe " + + string.Join(' ', ArmingArguments)); + } +} diff --git a/Rs50SharedHidppBoundedStream/BuildHDelay.cs b/Rs50SharedHidppBoundedStream/BuildHDelay.cs new file mode 100644 index 0000000..0efd6a9 --- /dev/null +++ b/Rs50SharedHidppBoundedStream/BuildHDelay.cs @@ -0,0 +1,12 @@ +namespace Rs50SharedHidppBoundedStream; + +internal interface IBuildHDelay +{ + void WaitOneSecond(); +} + +internal sealed class BuildHSystemDelay : IBuildHDelay +{ + public void WaitOneSecond() => + Thread.Sleep(TimeSpan.FromSeconds(1)); +} diff --git a/Rs50SharedHidppBoundedStream/Rs50SharedHidppBoundedStream.csproj b/Rs50SharedHidppBoundedStream/Rs50SharedHidppBoundedStream.csproj new file mode 100644 index 0000000..f77e1d5 --- /dev/null +++ b/Rs50SharedHidppBoundedStream/Rs50SharedHidppBoundedStream.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/Rs50SharedHidppCoexistence.Verification/Program.cs b/Rs50SharedHidppCoexistence.Verification/Program.cs new file mode 100644 index 0000000..73d617a --- /dev/null +++ b/Rs50SharedHidppCoexistence.Verification/Program.cs @@ -0,0 +1,204 @@ +using System.Text; +using LogiDynamicDash.Hidpp; +using Rs50SharedHidppCoexistence; + +string[] validArguments = +[ + "--arm-rs50-shared-hidpp-coexistence", + "--confirm-ghub-closed", + "--confirm-iracing-running", + "--confirm-car-stationary-in-pits", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running", + "--confirm-one-hz-five-fixed-frames" +]; + +VerifyInvalidArguments(); +VerifyFixedSequence(); +VerifyFrameFailure(); +VerifyDelayFailure(); + +Console.WriteLine( + "Build I fake-only verification passed: arming, five fixed frames, " + + "timing, fail-closed behavior, and disposal."); +return; + +void VerifyInvalidArguments() +{ + bool opened = false; + int exitCode = BuildICoexistenceProgram.Run( + [], + () => + { + opened = true; + throw new InvalidOperationException(); + }, + new RecordingDelay(), + TextWriter.Null, + TextWriter.Null); + + Require(exitCode == 2, "Invalid arguments must return exit code 2."); + Require(!opened, "Invalid arguments must not open an exchange."); +} + +void VerifyFixedSequence() +{ + var exchange = new RecordingExchange(); + var delay = new RecordingDelay(); + + int exitCode = BuildICoexistenceProgram.Run( + validArguments, + () => exchange, + delay, + TextWriter.Null, + TextWriter.Null); + + Require(exitCode == 0, "The fixed sequence must succeed."); + Require(exchange.Disposed, "The successful exchange must be disposed."); + Require(delay.WaitCount == 4, "The sequence must wait exactly four times."); + Require( + exchange.Transactions.Count == 6, + "The sequence must contain one discovery and five setters."); + + for (int index = 1; index <= 5; index++) + { + byte[] report = exchange.Transactions[index].Request.ToArray(); + Require( + ReadText(report, 5, 19) == "IRACING COEXIST", + "Line 1 changed."); + Require( + ReadText(report, 24, 10) == "BUILD I", + "Line 2 changed."); + Require( + ReadText(report, 34, 19) == $"FRAME {index} OF 5", + "The frame counter changed."); + Require( + ReadText(report, 53, 10) == "1 HZ", + "Line 4 changed."); + } +} + +void VerifyFrameFailure() +{ + var exchange = new RecordingExchange + { + FailingLayoutNumber = 3 + }; + var delay = new RecordingDelay(); + + int exitCode = BuildICoexistenceProgram.Run( + validArguments, + () => exchange, + delay, + TextWriter.Null, + TextWriter.Null); + + Require(exitCode == 1, "A frame failure must return exit code 1."); + Require(exchange.Disposed, "A failed exchange must be disposed."); + Require( + exchange.Transactions.Count == 4, + "No frame may follow the third-frame failure."); + Require(delay.WaitCount == 2, "No delay may follow a frame failure."); +} + +void VerifyDelayFailure() +{ + var exchange = new RecordingExchange(); + var delay = new RecordingDelay + { + Failure = new IOException("delay rejected") + }; + + int exitCode = BuildICoexistenceProgram.Run( + validArguments, + () => exchange, + delay, + TextWriter.Null, + TextWriter.Null); + + Require(exitCode == 1, "A delay failure must return exit code 1."); + Require(exchange.Disposed, "A delay failure must dispose the exchange."); + Require( + exchange.Transactions.Count == 2, + "No second frame may follow a delay failure."); +} + +static void Require(bool condition, string message) +{ + if (!condition) + { + throw new InvalidOperationException(message); + } +} + +static string ReadText(byte[] report, int offset, int length) +{ + ReadOnlySpan field = report.AsSpan(offset, length); + int terminator = field.IndexOf((byte)0); + if (terminator >= 0) + { + field = field[..terminator]; + } + + return Encoding.ASCII.GetString(field); +} + +sealed class RecordingDelay : IBuildIDelay +{ + public Exception? Failure { get; init; } + + public int WaitCount { get; private set; } + + public void WaitOneSecond() + { + WaitCount++; + if (Failure is not null) + { + throw Failure; + } + } +} + +sealed class RecordingExchange : IRs50HidppDisplayExchange +{ + private int layoutCount; + + public int? FailingLayoutNumber { get; init; } + + public List Transactions { get; } = []; + + public bool Disposed { get; private set; } + + public byte[] Exchange(Rs50HidppDisplayTransaction transaction) + { + Transactions.Add(transaction); + if (transaction.Kind == + Rs50HidppDisplayTransactionKind.DiscoverDisplayFeature) + { + byte[] discovery = new byte[64]; + discovery[0] = 0x12; + discovery[1] = 0xFF; + discovery[2] = 0x00; + discovery[3] = 0x0A; + discovery[4] = 0x12; + return discovery; + } + + layoutCount++; + if (layoutCount == FailingLayoutNumber) + { + return new byte[64]; + } + + byte[] acknowledgement = new byte[64]; + acknowledgement[0] = 0x12; + acknowledgement[1] = 0xFF; + acknowledgement[2] = 0x12; + acknowledgement[3] = 0x3A; + return acknowledgement; + } + + public void Dispose() => + Disposed = true; +} diff --git a/Rs50SharedHidppCoexistence.Verification/Rs50SharedHidppCoexistence.Verification.csproj b/Rs50SharedHidppCoexistence.Verification/Rs50SharedHidppCoexistence.Verification.csproj new file mode 100644 index 0000000..3a5d9cd --- /dev/null +++ b/Rs50SharedHidppCoexistence.Verification/Rs50SharedHidppCoexistence.Verification.csproj @@ -0,0 +1,17 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + diff --git a/Rs50SharedHidppCoexistence/BuildICoexistenceProgram.cs b/Rs50SharedHidppCoexistence/BuildICoexistenceProgram.cs new file mode 100644 index 0000000..099f835 --- /dev/null +++ b/Rs50SharedHidppCoexistence/BuildICoexistenceProgram.cs @@ -0,0 +1,117 @@ +using LogiDynamicDash.Hidpp; +using Rs50SharedHidppTransport; + +namespace Rs50SharedHidppCoexistence; + +internal static class BuildICoexistenceProgram +{ + private static readonly string[] ArmingArguments = + [ + "--arm-rs50-shared-hidpp-coexistence", + "--confirm-ghub-closed", + "--confirm-iracing-running", + "--confirm-car-stationary-in-pits", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running", + "--confirm-one-hz-five-fixed-frames" + ]; + + private const string Line1 = "IRACING COEXIST"; + private const string Line2 = "BUILD I"; + private const string Line4 = "1 HZ"; + + private static int Main(string[] arguments) => + Run( + arguments, + Rs50HidppDeviceExchange.Open, + new BuildISystemDelay(), + Console.Out, + Console.Error); + + internal static int Run( + string[] arguments, + Func exchangeFactory, + IBuildIDelay delay, + TextWriter output, + TextWriter error) + { + ArgumentNullException.ThrowIfNull(arguments); + ArgumentNullException.ThrowIfNull(exchangeFactory); + ArgumentNullException.ThrowIfNull(delay); + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(error); + + if (!arguments.SequenceEqual( + ArmingArguments, + StringComparer.Ordinal)) + { + PrintUsage(error); + return 2; + } + + try + { + using IRs50HidppDisplayExchange exchange = + exchangeFactory(); + + byte[] discoveryResponse = exchange.Exchange( + Rs50HidppDisplayProtocol.CreateDiscovery()); + byte runtimeIndex = + Rs50HidppDisplayProtocol.ParseDiscoveryResponse( + discoveryResponse); + + SendFrame(exchange, runtimeIndex, "FRAME 1 OF 5"); + delay.WaitOneSecond(); + SendFrame(exchange, runtimeIndex, "FRAME 2 OF 5"); + delay.WaitOneSecond(); + SendFrame(exchange, runtimeIndex, "FRAME 3 OF 5"); + delay.WaitOneSecond(); + SendFrame(exchange, runtimeIndex, "FRAME 4 OF 5"); + delay.WaitOneSecond(); + SendFrame(exchange, runtimeIndex, "FRAME 5 OF 5"); + + output.WriteLine( + "Build I completed: five fixed coexistence frames were " + + "acknowledged at 1 Hz and the HID streams were closed."); + return 0; + } + catch (Exception exception) + { + error.WriteLine( + $"Build I failed closed: {exception.Message}"); + return 1; + } + } + + private static void SendFrame( + IRs50HidppDisplayExchange exchange, + byte runtimeIndex, + string line3) + { + Rs50HidppDisplayTransaction frame = + Rs50HidppDisplayProtocol.CreateLayoutJ( + runtimeIndex, + Line1, + Line2, + line3, + Line4); + byte[] acknowledgement = exchange.Exchange(frame); + Rs50HidppDisplayProtocol.ParseLayoutJAcknowledgement( + runtimeIndex, + acknowledgement); + } + + private static void PrintUsage(TextWriter error) + { + error.WriteLine( + "Build I is a stationary RS50/iRacing coexistence tool. It " + + "sends exactly five fixed Layout J frames at 1 Hz."); + error.WriteLine( + "Run it only for a separately authorized captured trial with " + + "the car stopped in the pits:"); + error.WriteLine( + " Rs50SharedHidppCoexistence.exe " + + string.Join(' ', ArmingArguments)); + } +} diff --git a/Rs50SharedHidppCoexistence/BuildIDelay.cs b/Rs50SharedHidppCoexistence/BuildIDelay.cs new file mode 100644 index 0000000..e543739 --- /dev/null +++ b/Rs50SharedHidppCoexistence/BuildIDelay.cs @@ -0,0 +1,12 @@ +namespace Rs50SharedHidppCoexistence; + +internal interface IBuildIDelay +{ + void WaitOneSecond(); +} + +internal sealed class BuildISystemDelay : IBuildIDelay +{ + public void WaitOneSecond() => + Thread.Sleep(TimeSpan.FromSeconds(1)); +} diff --git a/Rs50SharedHidppCoexistence/Rs50SharedHidppCoexistence.csproj b/Rs50SharedHidppCoexistence/Rs50SharedHidppCoexistence.csproj new file mode 100644 index 0000000..0b8a2f4 --- /dev/null +++ b/Rs50SharedHidppCoexistence/Rs50SharedHidppCoexistence.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/Rs50SharedHidppLayoutGallery/BuildKDelay.cs b/Rs50SharedHidppLayoutGallery/BuildKDelay.cs new file mode 100644 index 0000000..e98f85c --- /dev/null +++ b/Rs50SharedHidppLayoutGallery/BuildKDelay.cs @@ -0,0 +1,12 @@ +namespace Rs50SharedHidppLayoutGallery; + +internal interface IBuildKDelay +{ + void WaitThreeSeconds(); +} + +internal sealed class BuildKSystemDelay : IBuildKDelay +{ + public void WaitThreeSeconds() => + Thread.Sleep(TimeSpan.FromSeconds(3)); +} diff --git a/Rs50SharedHidppLayoutGallery/BuildKLayoutGalleryProgram.cs b/Rs50SharedHidppLayoutGallery/BuildKLayoutGalleryProgram.cs new file mode 100644 index 0000000..d775386 --- /dev/null +++ b/Rs50SharedHidppLayoutGallery/BuildKLayoutGalleryProgram.cs @@ -0,0 +1,189 @@ +using LogiDynamicDash.Hidpp; +using Rs50SharedHidppTransport; + +namespace Rs50SharedHidppLayoutGallery; + +internal static class BuildKLayoutGalleryProgram +{ + private static readonly string[] ArmingArguments = + [ + "--arm-rs50-shared-hidpp-layout-gallery", + "--confirm-ghub-closed", + "--confirm-iracing-closed", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running", + "--confirm-video-recording", + "--confirm-ten-layouts-three-seconds-each" + ]; + + private static int Main(string[] arguments) => + Run( + arguments, + Rs50HidppDeviceExchange.Open, + new BuildKSystemDelay(), + Console.Out, + Console.Error); + + internal static int Run( + string[] arguments, + Func exchangeFactory, + IBuildKDelay delay, + TextWriter output, + TextWriter error) + { + ArgumentNullException.ThrowIfNull(arguments); + ArgumentNullException.ThrowIfNull(exchangeFactory); + ArgumentNullException.ThrowIfNull(delay); + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(error); + + if (!arguments.SequenceEqual( + ArmingArguments, + StringComparer.Ordinal)) + { + PrintUsage(error); + return 2; + } + + try + { + using IRs50HidppDisplayExchange exchange = + exchangeFactory(); + + byte[] discoveryResponse = exchange.Exchange( + Rs50HidppDisplayProtocol.CreateDiscovery()); + byte runtimeIndex = + Rs50HidppDisplayProtocol.ParseDiscoveryResponse( + discoveryResponse); + + Show( + "A", + exchange, + Rs50HidppDisplayProtocol.CreateLayoutA(runtimeIndex), + output); + delay.WaitThreeSeconds(); + Show( + "B", + exchange, + Rs50HidppDisplayProtocol.CreateLayoutB(runtimeIndex), + output); + delay.WaitThreeSeconds(); + Show( + "C", + exchange, + Rs50HidppDisplayProtocol.CreateLayoutC( + runtimeIndex, + mainGaugeValue: 128), + output); + delay.WaitThreeSeconds(); + Show( + "D", + exchange, + Rs50HidppDisplayProtocol.CreateLayoutD( + runtimeIndex, + mainGaugeValue: 64, + thinIndicatorValue: 191, + "LAYOUT D"), + output); + delay.WaitThreeSeconds(); + Show( + "E", + exchange, + Rs50HidppDisplayProtocol.CreateLayoutE( + runtimeIndex, + mainGaugeValue: 64, + thinIndicatorValue: 191, + rightText: "E1", + leftText: "LAYOUTE"), + output); + delay.WaitThreeSeconds(); + Show( + "F", + exchange, + Rs50HidppDisplayProtocol.CreateLayoutF( + runtimeIndex, + "F", + "123"), + output); + delay.WaitThreeSeconds(); + Show( + "G", + exchange, + Rs50HidppDisplayProtocol.CreateLayoutG( + runtimeIndex, + "G", + "456"), + output); + delay.WaitThreeSeconds(); + Show( + "H", + exchange, + Rs50HidppDisplayProtocol.CreateLayoutH( + runtimeIndex, + "LAYOUT H WIDE TEST", + "H SECOND"), + output); + delay.WaitThreeSeconds(); + Show( + "I", + exchange, + Rs50HidppDisplayProtocol.CreateLayoutI( + runtimeIndex, + "LAYOUT I TOP", + "I SECOND", + "LAYOUT I LOWER", + "I FOURTH"), + output); + delay.WaitThreeSeconds(); + Show( + "J", + exchange, + Rs50HidppDisplayProtocol.CreateLayoutJ( + runtimeIndex, + "LAYOUT J TOP", + "J SECOND", + "LAYOUT J LOWER", + "J FOURTH"), + output); + delay.WaitThreeSeconds(); + + output.WriteLine( + "Build K completed: layouts A-J were each acknowledged; " + + "the HID streams were closed."); + return 0; + } + catch (Exception exception) + { + error.WriteLine( + $"Build K failed closed: {exception.Message}"); + return 1; + } + } + + private static void Show( + string layoutName, + IRs50HidppDisplayExchange exchange, + Rs50HidppDisplayTransaction transaction, + TextWriter output) + { + output.WriteLine($"Build K showing Layout {layoutName}."); + output.Flush(); + byte[] acknowledgement = exchange.Exchange(transaction); + Rs50HidppDisplayProtocol.ParseLayoutAcknowledgement( + transaction.Request.Span[2], + acknowledgement); + } + + private static void PrintUsage(TextWriter error) + { + error.WriteLine( + "Build K is a fixed RS50 A-J visual-capability gallery."); + error.WriteLine( + "It requires G HUB and iRacing closed, USBPcap, video, and a " + + "separate physical authorization:"); + error.WriteLine( + " Rs50SharedHidppLayoutGallery.exe " + + string.Join(' ', ArmingArguments)); + } +} diff --git a/Rs50SharedHidppLayoutGallery/Rs50SharedHidppLayoutGallery.csproj b/Rs50SharedHidppLayoutGallery/Rs50SharedHidppLayoutGallery.csproj new file mode 100644 index 0000000..f77e1d5 --- /dev/null +++ b/Rs50SharedHidppLayoutGallery/Rs50SharedHidppLayoutGallery.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/Rs50SharedHidppOneShot/BuildGOneShotProgram.cs b/Rs50SharedHidppOneShot/BuildGOneShotProgram.cs new file mode 100644 index 0000000..3e4d443 --- /dev/null +++ b/Rs50SharedHidppOneShot/BuildGOneShotProgram.cs @@ -0,0 +1,97 @@ +using LogiDynamicDash.Hidpp; +using Rs50SharedHidppTransport; + +namespace Rs50SharedHidppOneShot; + +internal static class BuildGOneShotProgram +{ + private static readonly string[] ArmingArguments = + [ + "--arm-rs50-shared-hidpp", + "--confirm-ghub-closed", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running", + "--confirm-one-fixed-frame" + ]; + + private const string Line1 = "RS50 SHARED HIDPP"; + private const string Line2 = "BUILD G"; + private const string Line3 = "ONE SHOT ONLY"; + private const string Line4 = "USBPCAP"; + + private static int Main(string[] arguments) => + Run( + arguments, + Rs50HidppDeviceExchange.Open, + Console.Out, + Console.Error); + + internal static int Run( + string[] arguments, + Func exchangeFactory, + TextWriter output, + TextWriter error) + { + ArgumentNullException.ThrowIfNull(arguments); + ArgumentNullException.ThrowIfNull(exchangeFactory); + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(error); + + if (!arguments.SequenceEqual( + ArmingArguments, + StringComparer.Ordinal)) + { + PrintUsage(error); + return 2; + } + + try + { + using IRs50HidppDisplayExchange exchange = + exchangeFactory(); + + byte[] discoveryResponse = exchange.Exchange( + Rs50HidppDisplayProtocol.CreateDiscovery()); + byte runtimeIndex = + Rs50HidppDisplayProtocol.ParseDiscoveryResponse( + discoveryResponse); + + Rs50HidppDisplayTransaction frame = + Rs50HidppDisplayProtocol.CreateLayoutJ( + runtimeIndex, + Line1, + Line2, + Line3, + Line4); + byte[] acknowledgement = exchange.Exchange(frame); + Rs50HidppDisplayProtocol.ParseLayoutJAcknowledgement( + runtimeIndex, + acknowledgement); + + output.WriteLine( + "Build G completed: one fixed Layout J frame was " + + "acknowledged and the HID streams were closed."); + return 0; + } + catch (Exception exception) + { + error.WriteLine( + $"Build G failed closed: {exception.Message}"); + return 1; + } + } + + private static void PrintUsage(TextWriter error) + { + error.WriteLine( + "Build G is a physical RS50 one-shot tool. It sends exactly " + + "one fixed Layout J frame after feature discovery."); + error.WriteLine( + "Run it only for a separately authorized stationary capture, " + + "with G HUB closed and USBPcap already recording:"); + error.WriteLine( + " Rs50SharedHidppOneShot.exe " + + string.Join(' ', ArmingArguments)); + } +} diff --git a/Rs50SharedHidppOneShot/Rs50SharedHidppOneShot.csproj b/Rs50SharedHidppOneShot/Rs50SharedHidppOneShot.csproj new file mode 100644 index 0000000..f77e1d5 --- /dev/null +++ b/Rs50SharedHidppOneShot/Rs50SharedHidppOneShot.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/Rs50SharedHidppProtocol/IRs50HidppDisplayExchange.cs b/Rs50SharedHidppProtocol/IRs50HidppDisplayExchange.cs new file mode 100644 index 0000000..0a0d23a --- /dev/null +++ b/Rs50SharedHidppProtocol/IRs50HidppDisplayExchange.cs @@ -0,0 +1,10 @@ +namespace LogiDynamicDash.Hidpp; + +/// +/// Testable boundary for the two typed Display Game Data transactions. +/// Build E intentionally provides no physical implementation. +/// +internal interface IRs50HidppDisplayExchange : IDisposable +{ + byte[] Exchange(Rs50HidppDisplayTransaction transaction); +} diff --git a/Rs50SharedHidppProtocol/Rs50HidppDisplayProtocol.cs b/Rs50SharedHidppProtocol/Rs50HidppDisplayProtocol.cs new file mode 100644 index 0000000..61a3dc6 --- /dev/null +++ b/Rs50SharedHidppProtocol/Rs50HidppDisplayProtocol.cs @@ -0,0 +1,417 @@ +using System.Text; + +namespace LogiDynamicDash.Hidpp; + +/// +/// Exact offline codec for the recovered feature-0x8130 transactions. +/// It contains no device enumeration, stream, handle, read, or write API. +/// +internal static class Rs50HidppDisplayProtocol +{ + internal const ushort DisplayFeatureId = 0x8130; + internal const byte SoftwareId = 0x0A; + internal const byte BaseDeviceIndex = 0xFF; + internal const int ShortReportLength = 7; + internal const int VeryLongReportLength = 64; + + private const byte ShortReportId = 0x10; + private const byte VeryLongReportId = 0x12; + private const byte RootFeatureIndex = 0x00; + private const byte RootGetFeatureFunction = 0x00; + private const byte SetLayoutFunction = 0x03; + internal const byte LayoutJIndex = 0x09; + + internal static Rs50HidppDisplayTransaction CreateDiscovery() + { + byte[] request = + [ + ShortReportId, + BaseDeviceIndex, + RootFeatureIndex, + EncodeFunction(RootGetFeatureFunction), + (byte)(DisplayFeatureId >> 8), + (byte)(DisplayFeatureId & 0xFF), + 0 + ]; + + return Rs50HidppDisplayTransaction.CreateDiscovery(request); + } + + internal static byte ParseDiscoveryResponse(ReadOnlySpan response) + { + ValidateLength(response); + ThrowIfError( + response, + RootFeatureIndex, + EncodeFunction(RootGetFeatureFunction)); + ValidateHeader( + response, + RootFeatureIndex, + EncodeFunction(RootGetFeatureFunction)); + + byte runtimeIndex = response[4]; + byte featureFlags = response[5]; + byte featureVersion = response[6]; + + if (runtimeIndex is < 0x02 or >= 0xFF) + { + throw new Rs50HidppProtocolException( + $"Display feature returned invalid runtime index " + + $"0x{runtimeIndex:X2}."); + } + + if (featureFlags != 0) + { + throw new Rs50HidppProtocolException( + $"Display feature is not public (flags 0x{featureFlags:X2})."); + } + + if (featureVersion != 0) + { + throw new Rs50HidppProtocolException( + $"Unsupported Display Game Data version {featureVersion}."); + } + + RequireZero(response[7..], "discovery response padding"); + return runtimeIndex; + } + + internal static Rs50HidppDisplayTransaction CreateLayoutA( + byte runtimeIndex) => + CreateLayoutRequest( + runtimeIndex, + layoutIndex: 0, + Rs50HidppDisplayTransactionKind.SetLayoutA); + + internal static Rs50HidppDisplayTransaction CreateLayoutB( + byte runtimeIndex) => + CreateLayoutRequest( + runtimeIndex, + layoutIndex: 1, + Rs50HidppDisplayTransactionKind.SetLayoutB); + + internal static Rs50HidppDisplayTransaction CreateLayoutC( + byte runtimeIndex, + byte mainGaugeValue) + { + byte[] request = CreateLayoutRequestBytes( + runtimeIndex, + layoutIndex: 2); + request[5] = mainGaugeValue; + return Rs50HidppDisplayTransaction.CreateLayout( + Rs50HidppDisplayTransactionKind.SetLayoutC, + request); + } + + internal static Rs50HidppDisplayTransaction CreateLayoutD( + byte runtimeIndex, + byte mainGaugeValue, + byte thinIndicatorValue, + string text) + { + ValidateText(text, 11, nameof(text)); + byte[] request = CreateLayoutRequestBytes( + runtimeIndex, + layoutIndex: 3); + request[5] = mainGaugeValue; + request[6] = thinIndicatorValue; + WriteAscii(request.AsSpan(7, 11), text); + return Rs50HidppDisplayTransaction.CreateLayout( + Rs50HidppDisplayTransactionKind.SetLayoutD, + request); + } + + internal static Rs50HidppDisplayTransaction CreateLayoutE( + byte runtimeIndex, + byte mainGaugeValue, + byte thinIndicatorValue, + string rightText, + string leftText) + { + ValidateText(rightText, 3, nameof(rightText)); + ValidateText(leftText, 7, nameof(leftText)); + byte[] request = CreateLayoutRequestBytes( + runtimeIndex, + layoutIndex: 4); + request[5] = mainGaugeValue; + request[6] = thinIndicatorValue; + WriteAscii(request.AsSpan(7, 3), rightText); + WriteAscii(request.AsSpan(10, 7), leftText); + return Rs50HidppDisplayTransaction.CreateLayout( + Rs50HidppDisplayTransactionKind.SetLayoutE, + request); + } + + internal static Rs50HidppDisplayTransaction CreateLayoutF( + byte runtimeIndex, + string leftText, + string rightText) => + CreateTwoTextLayout( + runtimeIndex, + layoutIndex: 5, + Rs50HidppDisplayTransactionKind.SetLayoutF, + leftText, + firstMaximumLength: 1, + rightText, + secondMaximumLength: 3); + + internal static Rs50HidppDisplayTransaction CreateLayoutG( + byte runtimeIndex, + string leftText, + string rightText) => + CreateTwoTextLayout( + runtimeIndex, + layoutIndex: 6, + Rs50HidppDisplayTransactionKind.SetLayoutG, + leftText, + firstMaximumLength: 1, + rightText, + secondMaximumLength: 3); + + internal static Rs50HidppDisplayTransaction CreateLayoutH( + byte runtimeIndex, + string topText, + string bottomText) => + CreateTwoTextLayout( + runtimeIndex, + layoutIndex: 7, + Rs50HidppDisplayTransactionKind.SetLayoutH, + topText, + firstMaximumLength: 21, + bottomText, + secondMaximumLength: 10); + + internal static Rs50HidppDisplayTransaction CreateLayoutI( + byte runtimeIndex, + string line1, + string line2, + string line3, + string line4) => + CreateFourTextLayout( + runtimeIndex, + layoutIndex: 8, + Rs50HidppDisplayTransactionKind.SetLayoutI, + line1, + line2, + line3, + line4); + + internal static Rs50HidppDisplayTransaction CreateLayoutJ( + byte runtimeIndex, + string line1, + string line2, + string line3, + string line4) + { + return CreateFourTextLayout( + runtimeIndex, + LayoutJIndex, + Rs50HidppDisplayTransactionKind.SetLayoutJ, + line1, + line2, + line3, + line4); + } + + internal static void ParseLayoutAcknowledgement( + byte runtimeIndex, + ReadOnlySpan response) + { + ValidateRuntimeIndex(runtimeIndex); + ValidateLength(response); + byte function = EncodeFunction(SetLayoutFunction); + ThrowIfError(response, runtimeIndex, function); + ValidateHeader(response, runtimeIndex, function); + RequireZero(response[4..], "layout acknowledgement body"); + } + + internal static void ParseLayoutJAcknowledgement( + byte runtimeIndex, + ReadOnlySpan response) => + ParseLayoutAcknowledgement(runtimeIndex, response); + + private static Rs50HidppDisplayTransaction CreateLayoutRequest( + byte runtimeIndex, + byte layoutIndex, + Rs50HidppDisplayTransactionKind kind) => + Rs50HidppDisplayTransaction.CreateLayout( + kind, + CreateLayoutRequestBytes(runtimeIndex, layoutIndex)); + + private static byte[] CreateLayoutRequestBytes( + byte runtimeIndex, + byte layoutIndex) + { + ValidateRuntimeIndex(runtimeIndex); + if (layoutIndex > LayoutJIndex) + { + throw new ArgumentOutOfRangeException(nameof(layoutIndex)); + } + + byte[] request = new byte[VeryLongReportLength]; + request[0] = VeryLongReportId; + request[1] = BaseDeviceIndex; + request[2] = runtimeIndex; + request[3] = EncodeFunction(SetLayoutFunction); + request[4] = layoutIndex; + return request; + } + + private static Rs50HidppDisplayTransaction CreateTwoTextLayout( + byte runtimeIndex, + byte layoutIndex, + Rs50HidppDisplayTransactionKind kind, + string firstText, + int firstMaximumLength, + string secondText, + int secondMaximumLength) + { + ValidateText( + firstText, + firstMaximumLength, + nameof(firstText)); + ValidateText( + secondText, + secondMaximumLength, + nameof(secondText)); + + byte[] request = CreateLayoutRequestBytes( + runtimeIndex, + layoutIndex); + WriteAscii( + request.AsSpan(5, firstMaximumLength), + firstText); + WriteAscii( + request.AsSpan( + 5 + firstMaximumLength, + secondMaximumLength), + secondText); + return Rs50HidppDisplayTransaction.CreateLayout(kind, request); + } + + private static Rs50HidppDisplayTransaction CreateFourTextLayout( + byte runtimeIndex, + byte layoutIndex, + Rs50HidppDisplayTransactionKind kind, + string line1, + string line2, + string line3, + string line4) + { + ValidateText(line1, 19, nameof(line1)); + ValidateText(line2, 10, nameof(line2)); + ValidateText(line3, 19, nameof(line3)); + ValidateText(line4, 10, nameof(line4)); + + byte[] request = CreateLayoutRequestBytes( + runtimeIndex, + layoutIndex); + WriteAscii(request.AsSpan(5, 19), line1); + WriteAscii(request.AsSpan(24, 10), line2); + WriteAscii(request.AsSpan(34, 19), line3); + WriteAscii(request.AsSpan(53, 10), line4); + return Rs50HidppDisplayTransaction.CreateLayout(kind, request); + } + + private static byte EncodeFunction(byte functionId) => + checked((byte)((functionId << 4) | SoftwareId)); + + private static void ValidateRuntimeIndex(byte runtimeIndex) + { + if (runtimeIndex is < 0x02 or >= 0xFF) + { + throw new ArgumentOutOfRangeException( + nameof(runtimeIndex), + runtimeIndex, + "The discovered runtime index must be between 0x02 and 0xFE."); + } + } + + private static void ValidateLength(ReadOnlySpan response) + { + if (response.Length != VeryLongReportLength) + { + throw new Rs50HidppProtocolException( + $"Expected a {VeryLongReportLength}-byte response, received " + + $"{response.Length}."); + } + } + + private static void ValidateHeader( + ReadOnlySpan response, + byte expectedFeatureIndex, + byte expectedFunction) + { + if (response[0] != VeryLongReportId || + response[1] != BaseDeviceIndex || + response[2] != expectedFeatureIndex || + response[3] != expectedFunction) + { + throw new Rs50HidppProtocolException( + "Response header does not exactly match the request."); + } + } + + private static void ThrowIfError( + ReadOnlySpan response, + byte expectedFeatureIndex, + byte expectedFunction) + { + if (response[0] == VeryLongReportId && + response[1] == BaseDeviceIndex && + response[2] == 0xFF && + response[4] == expectedFeatureIndex && + response[5] == expectedFunction) + { + throw new Rs50HidppProtocolException( + $"HID++ rejected the display request with error " + + $"0x{response[6]:X2}."); + } + } + + private static void RequireZero( + ReadOnlySpan bytes, + string description) + { + if (bytes.IndexOfAnyExcept((byte)0) >= 0) + { + throw new Rs50HidppProtocolException( + $"Unexpected nonzero byte in {description}."); + } + } + + private static void WriteAscii(Span destination, string value) + { + int bytesWritten = Encoding.ASCII.GetBytes(value, destination); + if (bytesWritten != value.Length) + { + throw new InvalidOperationException( + "Display text did not encode to one byte per character."); + } + } + + private static void ValidateText( + string value, + int maximumLength, + string parameterName) + { + ArgumentNullException.ThrowIfNull(value, parameterName); + + if (value.Length > maximumLength) + { + throw new ArgumentException( + $"Text exceeds the layout limit of {maximumLength} " + + "characters.", + parameterName); + } + + if (value.Any(character => + character is < (char)0x20 or > (char)0x7F)) + { + throw new ArgumentException( + "Text contains a character outside the firmware's " + + "recovered display range.", + parameterName); + } + } +} diff --git a/Rs50SharedHidppProtocol/Rs50HidppDisplayTransaction.cs b/Rs50SharedHidppProtocol/Rs50HidppDisplayTransaction.cs new file mode 100644 index 0000000..37be996 --- /dev/null +++ b/Rs50SharedHidppProtocol/Rs50HidppDisplayTransaction.cs @@ -0,0 +1,60 @@ +namespace LogiDynamicDash.Hidpp; + +internal enum Rs50HidppDisplayTransactionKind +{ + DiscoverDisplayFeature, + SetLayoutA, + SetLayoutB, + SetLayoutC, + SetLayoutD, + SetLayoutE, + SetLayoutF, + SetLayoutG, + SetLayoutH, + SetLayoutI, + SetLayoutJ +} + +/// +/// Closed transaction type. Callers cannot supply a feature ID, function ID, +/// device index, report ID, or arbitrary parameters. +/// +internal sealed class Rs50HidppDisplayTransaction +{ + private readonly byte[] request; + + private Rs50HidppDisplayTransaction( + Rs50HidppDisplayTransactionKind kind, + byte[] request) + { + Kind = kind; + this.request = request; + } + + public Rs50HidppDisplayTransactionKind Kind { get; } + + public ReadOnlyMemory Request => + (byte[])request.Clone(); + + internal static Rs50HidppDisplayTransaction CreateDiscovery( + byte[] request) => + new( + Rs50HidppDisplayTransactionKind.DiscoverDisplayFeature, + (byte[])request.Clone()); + + internal static Rs50HidppDisplayTransaction CreateLayout( + Rs50HidppDisplayTransactionKind kind, + byte[] request) + { + if (kind is < Rs50HidppDisplayTransactionKind.SetLayoutA or + > Rs50HidppDisplayTransactionKind.SetLayoutJ) + { + throw new ArgumentOutOfRangeException(nameof(kind)); + } + + return + new( + kind, + (byte[])request.Clone()); + } +} diff --git a/Rs50SharedHidppProtocol/Rs50HidppProtocolException.cs b/Rs50SharedHidppProtocol/Rs50HidppProtocolException.cs new file mode 100644 index 0000000..bdc7c17 --- /dev/null +++ b/Rs50SharedHidppProtocol/Rs50HidppProtocolException.cs @@ -0,0 +1,4 @@ +namespace LogiDynamicDash.Hidpp; + +internal sealed class Rs50HidppProtocolException(string message) + : InvalidOperationException(message); diff --git a/Rs50SharedHidppProtocol/Rs50SharedHidppProtocol.csproj b/Rs50SharedHidppProtocol/Rs50SharedHidppProtocol.csproj new file mode 100644 index 0000000..c80ee02 --- /dev/null +++ b/Rs50SharedHidppProtocol/Rs50SharedHidppProtocol.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/Rs50SharedHidppTelemetryTrial.Verification/Program.cs b/Rs50SharedHidppTelemetryTrial.Verification/Program.cs new file mode 100644 index 0000000..90cb2d9 --- /dev/null +++ b/Rs50SharedHidppTelemetryTrial.Verification/Program.cs @@ -0,0 +1,310 @@ +using System.Text; +using LogiDynamicDash.Hidpp; +using Rs50SharedHidppTelemetryTrial; + +string[] validArguments = +[ + "--arm-rs50-shared-hidpp-telemetry", + "--confirm-ghub-closed", + "--confirm-iracing-running", + "--confirm-car-stationary-in-pits", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running", + "--confirm-video-recording", + "--confirm-10-second-telemetry-trial" +]; + +await VerifyInvalidArguments(); +await VerifyFormattingDeduplicationAndRateLimit(); +await VerifyMovementFailure(); +await VerifyDisconnectAfterTelemetryFailure(); +await VerifyEarlySourceEnd(); +await VerifyProtocolFailure(); + +Console.WriteLine( + "Build J fake-only verification passed: arming, telemetry " + + "formatting, deduplication, 5 Hz rate limit, movement stop, " + + "fail-closed behavior, and disposal."); + +async Task VerifyInvalidArguments() +{ + bool opened = false; + var source = new ScriptedTelemetrySource([], cancelAtEnd: true); + + int exitCode = await BuildJTelemetryTrialProgram.RunAsync( + [], + source, + () => + { + opened = true; + throw new InvalidOperationException(); + }, + new ManualTimeProvider(), + new CancellationToken(canceled: true), + TextWriter.Null, + TextWriter.Null); + + Require(exitCode == 2, "Invalid arguments must return exit code 2."); + Require(!opened, "Invalid arguments must not open an exchange."); + Require(!source.Started, "Invalid arguments must not start telemetry."); +} + +async Task VerifyFormattingDeduplicationAndRateLimit() +{ + using var cancellationSource = new CancellationTokenSource(); + var time = new ManualTimeProvider(); + var source = new ScriptedTelemetrySource( + [ + new(false, false, 0, 0.0f), + new(true, true, 0, 0.0f), + new(true, true, 0, 0.0f), + new(true, true, 1, 0.1f), + new(true, true, 1, 0.1f), + new(true, true, -1, 0.2f) + ], + cancelAtEnd: true, + cancellationSource, + time, + [ + TimeSpan.Zero, + TimeSpan.Zero, + TimeSpan.FromMilliseconds(50), + TimeSpan.FromMilliseconds(100), + TimeSpan.FromMilliseconds(100), + TimeSpan.FromMilliseconds(200) + ]); + var exchange = new RecordingExchange(); + + int exitCode = await BuildJTelemetryTrialProgram.RunAsync( + validArguments, + source, + () => exchange, + time, + cancellationSource.Token, + TextWriter.Null, + TextWriter.Null); + + Require(exitCode == 0, "The stationary script must succeed."); + Require(exchange.Disposed, "The successful exchange must be disposed."); + Require( + exchange.Transactions.Count == 4, + "Expected one discovery and three rate-limited telemetry frames."); + + byte[] neutral = exchange.Transactions[1].Request.ToArray(); + Require(ReadText(neutral, 5, 19) == "SPEED", "Line 1 changed."); + Require(ReadText(neutral, 24, 10) == "0 KMH", "Speed changed."); + Require(ReadText(neutral, 34, 19) == "GEAR", "Line 3 changed."); + Require(ReadText(neutral, 53, 10) == "N", "Neutral changed."); + + byte[] firstGear = exchange.Transactions[2].Request.ToArray(); + Require(ReadText(firstGear, 24, 10) == "0 KMH", "Speed changed."); + Require(ReadText(firstGear, 53, 10) == "1", "First gear changed."); + + byte[] reverse = exchange.Transactions[3].Request.ToArray(); + Require(ReadText(reverse, 24, 10) == "1 KMH", "Rounding changed."); + Require(ReadText(reverse, 53, 10) == "R", "Reverse changed."); +} + +async Task VerifyMovementFailure() +{ + using var cancellationSource = new CancellationTokenSource(); + var exchange = new RecordingExchange(); + var source = new ScriptedTelemetrySource( + [new(true, true, 1, 0.51f)], + cancelAtEnd: false); + + int exitCode = await BuildJTelemetryTrialProgram.RunAsync( + validArguments, + source, + () => exchange, + new ManualTimeProvider(), + cancellationSource.Token, + TextWriter.Null, + TextWriter.Null); + + Require(exitCode == 1, "Movement must fail closed."); + Require(exchange.Disposed, "Movement failure must dispose."); + Require( + exchange.Transactions.Count == 1, + "Movement must stop before a layout setter."); +} + +async Task VerifyDisconnectAfterTelemetryFailure() +{ + var exchange = new RecordingExchange(); + var source = new ScriptedTelemetrySource( + [ + new(true, true, 0, 0.0f), + new(false, null, null, null) + ], + cancelAtEnd: false); + + int exitCode = await BuildJTelemetryTrialProgram.RunAsync( + validArguments, + source, + () => exchange, + new ManualTimeProvider(), + CancellationToken.None, + TextWriter.Null, + TextWriter.Null); + + Require(exitCode == 1, "A telemetry disconnect must fail closed."); + Require(exchange.Disposed, "A disconnect must dispose the exchange."); + Require( + exchange.Transactions.Count == 2, + "A disconnect must not send an additional layout frame."); +} + +async Task VerifyEarlySourceEnd() +{ + var exchange = new RecordingExchange(); + var source = new ScriptedTelemetrySource( + [new(true, true, 0, 0.0f)], + cancelAtEnd: false); + + int exitCode = await BuildJTelemetryTrialProgram.RunAsync( + validArguments, + source, + () => exchange, + new ManualTimeProvider(), + CancellationToken.None, + TextWriter.Null, + TextWriter.Null); + + Require(exitCode == 1, "An early telemetry end must fail closed."); + Require(exchange.Disposed, "An early end must dispose."); +} + +async Task VerifyProtocolFailure() +{ + using var cancellationSource = new CancellationTokenSource(); + var exchange = new RecordingExchange + { + FailingLayoutNumber = 1 + }; + var source = new ScriptedTelemetrySource( + [new(true, true, 0, 0.0f)], + cancelAtEnd: true, + cancellationSource); + + int exitCode = await BuildJTelemetryTrialProgram.RunAsync( + validArguments, + source, + () => exchange, + new ManualTimeProvider(), + cancellationSource.Token, + TextWriter.Null, + TextWriter.Null); + + Require(exitCode == 1, "A bad ACK must fail closed."); + Require(exchange.Disposed, "A bad ACK must dispose."); + Require(exchange.Transactions.Count == 2, "Unexpected retry."); +} + +static void Require(bool condition, string message) +{ + if (!condition) + { + throw new InvalidOperationException(message); + } +} + +static string ReadText(byte[] report, int offset, int length) +{ + ReadOnlySpan field = report.AsSpan(offset, length); + int terminator = field.IndexOf((byte)0); + if (terminator >= 0) + { + field = field[..terminator]; + } + + return Encoding.ASCII.GetString(field); +} + +sealed class ScriptedTelemetrySource( + IReadOnlyList snapshots, + bool cancelAtEnd, + CancellationTokenSource? cancellationSource = null, + ManualTimeProvider? timeProvider = null, + IReadOnlyList? advances = null) + : IBuildJTelemetrySource +{ + public bool Started { get; private set; } + + public Task MonitorAsync( + Action onTelemetry, + CancellationToken cancellationToken) + { + Started = true; + for (int index = 0; index < snapshots.Count; index++) + { + timeProvider?.Advance( + advances is null ? TimeSpan.Zero : advances[index]); + onTelemetry(snapshots[index]); + } + + if (cancelAtEnd) + { + cancellationSource?.Cancel(); + cancellationToken.ThrowIfCancellationRequested(); + } + + return Task.CompletedTask; + } +} + +sealed class ManualTimeProvider : TimeProvider +{ + private long timestamp; + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override long GetTimestamp() => timestamp; + + public void Advance(TimeSpan duration) => + timestamp += duration.Ticks; +} + +sealed class RecordingExchange : IRs50HidppDisplayExchange +{ + private int layoutCount; + + public int? FailingLayoutNumber { get; init; } + + public List Transactions { get; } = []; + + public bool Disposed { get; private set; } + + public byte[] Exchange(Rs50HidppDisplayTransaction transaction) + { + Transactions.Add(transaction); + if (transaction.Kind == + Rs50HidppDisplayTransactionKind.DiscoverDisplayFeature) + { + byte[] discovery = new byte[64]; + discovery[0] = 0x12; + discovery[1] = 0xFF; + discovery[2] = 0x00; + discovery[3] = 0x0A; + discovery[4] = 0x12; + return discovery; + } + + layoutCount++; + if (layoutCount == FailingLayoutNumber) + { + return new byte[64]; + } + + byte[] acknowledgement = new byte[64]; + acknowledgement[0] = 0x12; + acknowledgement[1] = 0xFF; + acknowledgement[2] = 0x12; + acknowledgement[3] = 0x3A; + return acknowledgement; + } + + public void Dispose() => + Disposed = true; +} diff --git a/Rs50SharedHidppTelemetryTrial.Verification/Rs50SharedHidppTelemetryTrial.Verification.csproj b/Rs50SharedHidppTelemetryTrial.Verification/Rs50SharedHidppTelemetryTrial.Verification.csproj new file mode 100644 index 0000000..e160d0b --- /dev/null +++ b/Rs50SharedHidppTelemetryTrial.Verification/Rs50SharedHidppTelemetryTrial.Verification.csproj @@ -0,0 +1,17 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + diff --git a/Rs50SharedHidppTelemetryTrial/BuildJTelemetrySource.cs b/Rs50SharedHidppTelemetryTrial/BuildJTelemetrySource.cs new file mode 100644 index 0000000..2d53c08 --- /dev/null +++ b/Rs50SharedHidppTelemetryTrial/BuildJTelemetrySource.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.Logging.Abstractions; +using SVappsLAB.iRacingTelemetrySDK; + +namespace Rs50SharedHidppTelemetryTrial; + +internal sealed record BuildJTelemetrySnapshot( + bool Connected, + bool? IsOnTrack, + int? Gear, + float? SpeedMetersPerSecond); + +internal interface IBuildJTelemetrySource +{ + Task MonitorAsync( + Action onTelemetry, + CancellationToken cancellationToken); +} + +internal sealed class BuildJIRacingTelemetrySource + : IBuildJTelemetrySource +{ + public async Task MonitorAsync( + Action onTelemetry, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(onTelemetry); + + object stateGate = new(); + bool connected = false; + + await using var client = + TelemetryClient.Create(NullLogger.Instance); + + var handlers = new TelemetryHandlers + { + OnConnectStateChanged = state => + { + lock (stateGate) + { + connected = string.Equals( + state.ToString(), + "CONNECTED", + StringComparison.OrdinalIgnoreCase); + } + + return Task.CompletedTask; + }, + OnTelemetryUpdate = data => + { + bool currentConnected; + lock (stateGate) + { + currentConnected = connected; + } + + onTelemetry( + new( + currentConnected, + data.IsOnTrackCar, + data.Gear, + data.Speed)); + return Task.CompletedTask; + }, + OnError = _ => + Task.FromException( + new IOException( + "The iRacing telemetry source reported an error.")) + }; + + await client.Monitor(handlers, cancellationToken); + } +} diff --git a/Rs50SharedHidppTelemetryTrial/BuildJTelemetryTrialProgram.cs b/Rs50SharedHidppTelemetryTrial/BuildJTelemetryTrialProgram.cs new file mode 100644 index 0000000..d0251b1 --- /dev/null +++ b/Rs50SharedHidppTelemetryTrial/BuildJTelemetryTrialProgram.cs @@ -0,0 +1,247 @@ +using System.Globalization; +using LogiDynamicDash.Hidpp; +using Rs50SharedHidppTransport; +using SVappsLAB.iRacingTelemetrySDK; + +namespace Rs50SharedHidppTelemetryTrial; + +[RequiredTelemetryVars([ + TelemetryVar.IsOnTrackCar, + TelemetryVar.Gear, + TelemetryVar.Speed +])] +internal static class BuildJTelemetryTrialProgram +{ + private static readonly string[] ArmingArguments = + [ + "--arm-rs50-shared-hidpp-telemetry", + "--confirm-ghub-closed", + "--confirm-iracing-running", + "--confirm-car-stationary-in-pits", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running", + "--confirm-video-recording", + "--confirm-10-second-telemetry-trial" + ]; + + private static readonly TimeSpan MinimumInterval = + TimeSpan.FromMilliseconds(200); + + private const float MaximumStationarySpeedMetersPerSecond = 0.5f; + + private static async Task Main(string[] arguments) + { + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.CancelAfter(TimeSpan.FromSeconds(10)); + + return await RunAsync( + arguments, + new BuildJIRacingTelemetrySource(), + () => BuildJRecordedExchange.OpenPhysical( + TimeProvider.System, + Console.Out), + TimeProvider.System, + cancellationSource.Token, + Console.Out, + Console.Error); + } + + internal static async Task RunAsync( + string[] arguments, + IBuildJTelemetrySource telemetrySource, + Func exchangeFactory, + TimeProvider timeProvider, + CancellationToken trialToken, + TextWriter output, + TextWriter error) + { + ArgumentNullException.ThrowIfNull(arguments); + ArgumentNullException.ThrowIfNull(telemetrySource); + ArgumentNullException.ThrowIfNull(exchangeFactory); + ArgumentNullException.ThrowIfNull(timeProvider); + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(error); + + if (!arguments.SequenceEqual( + ArmingArguments, + StringComparer.Ordinal)) + { + PrintUsage(error); + return 2; + } + + try + { + using IRs50HidppDisplayExchange exchange = + exchangeFactory(); + + byte[] discoveryResponse = exchange.Exchange( + Rs50HidppDisplayProtocol.CreateDiscovery()); + byte runtimeIndex = + Rs50HidppDisplayProtocol.ParseDiscoveryResponse( + discoveryResponse); + + object sendGate = new(); + (string Speed, string Gear)? previousFrame = null; + long previousTimestamp = 0; + bool hasTransmitted = false; + bool connectedTelemetrySeen = false; + bool stationaryTelemetrySeen = false; + int transmissionCount = 0; + + void HandleTelemetry(BuildJTelemetrySnapshot snapshot) + { + lock (sendGate) + { + if (!snapshot.Connected) + { + if (connectedTelemetrySeen) + { + throw new IOException( + "iRacing telemetry disconnected during " + + "the trial."); + } + + return; + } + + connectedTelemetrySeen = true; + ValidateStationarySnapshot(snapshot); + stationaryTelemetrySeen = true; + + string speed = FormatSpeed( + snapshot.SpeedMetersPerSecond!.Value); + string gear = FormatGear(snapshot.Gear!.Value); + var currentFrame = (speed, gear); + + if (currentFrame == previousFrame) + { + return; + } + + long timestamp = timeProvider.GetTimestamp(); + if (hasTransmitted && + timeProvider.GetElapsedTime( + previousTimestamp, + timestamp) < MinimumInterval) + { + return; + } + + byte[] acknowledgement = exchange.Exchange( + Rs50HidppDisplayProtocol.CreateLayoutJ( + runtimeIndex, + "SPEED", + speed, + "GEAR", + gear)); + Rs50HidppDisplayProtocol.ParseLayoutJAcknowledgement( + runtimeIndex, + acknowledgement); + + previousFrame = currentFrame; + previousTimestamp = timestamp; + hasTransmitted = true; + transmissionCount++; + } + } + + try + { + await telemetrySource.MonitorAsync( + HandleTelemetry, + trialToken); + } + catch (OperationCanceledException) + when (trialToken.IsCancellationRequested) + { + // Expected at the ten-second trial boundary. + } + + if (!trialToken.IsCancellationRequested) + { + throw new IOException( + "The telemetry source ended before the trial boundary."); + } + + if (!stationaryTelemetrySeen || transmissionCount == 0) + { + throw new IOException( + "No connected stationary telemetry frame was sent."); + } + + output.WriteLine( + $"Build J completed: {transmissionCount} stationary " + + "telemetry frame(s) were acknowledged and the HID " + + "streams were closed."); + return 0; + } + catch (Exception exception) + { + error.WriteLine( + $"Build J failed closed: {exception.Message}"); + return 1; + } + } + + private static void ValidateStationarySnapshot( + BuildJTelemetrySnapshot snapshot) + { + if (snapshot.IsOnTrack is null || + snapshot.Gear is null || + snapshot.SpeedMetersPerSecond is null) + { + throw new InvalidOperationException( + "Required iRacing telemetry is unavailable."); + } + + if (!snapshot.IsOnTrack.Value) + { + throw new InvalidOperationException( + "iRacing no longer reports the car on track."); + } + + if (!float.IsFinite(snapshot.SpeedMetersPerSecond.Value) || + snapshot.SpeedMetersPerSecond.Value is < 0 or + > MaximumStationarySpeedMetersPerSecond) + { + throw new InvalidOperationException( + "Car movement or invalid speed detected; trial stopped."); + } + + if (snapshot.Gear.Value is < -1 or > 9) + { + throw new InvalidOperationException( + "Invalid iRacing gear value detected."); + } + } + + private static string FormatSpeed(float metersPerSecond) + { + float kilometersPerHour = + Math.Clamp(metersPerSecond * 3.6f, 0.0f, 999.0f); + return kilometersPerHour.ToString( + "F0", + CultureInfo.InvariantCulture) + " KMH"; + } + + private static string FormatGear(int gear) => gear switch + { + -1 => "R", + 0 => "N", + _ => gear.ToString(CultureInfo.InvariantCulture) + }; + + private static void PrintUsage(TextWriter error) + { + error.WriteLine( + "Build J is a ten-second stationary iRacing telemetry trial."); + error.WriteLine( + "It stops on movement and requires a separately authorized " + + "USBPcap capture:"); + error.WriteLine( + " Rs50SharedHidppTelemetryTrial.exe " + + string.Join(' ', ArmingArguments)); + } +} diff --git a/Rs50SharedHidppTelemetryTrial/BuildJTransactionTranscript.cs b/Rs50SharedHidppTelemetryTrial/BuildJTransactionTranscript.cs new file mode 100644 index 0000000..5ff00eb --- /dev/null +++ b/Rs50SharedHidppTelemetryTrial/BuildJTransactionTranscript.cs @@ -0,0 +1,316 @@ +using System.Globalization; +using System.Text.Json; +using LogiDynamicDash.Hidpp; +using Rs50SharedHidppTransport; + +namespace Rs50SharedHidppTelemetryTrial; + +internal sealed class BuildJRecordedExchange + : IRs50HidppDisplayExchange +{ + private readonly IRs50HidppDisplayExchange inner; + private readonly BuildJTransactionTranscript transcript; + private readonly TimeProvider timeProvider; + private bool disposed; + + internal BuildJRecordedExchange( + IRs50HidppDisplayExchange inner, + BuildJTransactionTranscript transcript, + TimeProvider timeProvider) + { + ArgumentNullException.ThrowIfNull(inner); + ArgumentNullException.ThrowIfNull(transcript); + ArgumentNullException.ThrowIfNull(timeProvider); + + this.inner = inner; + this.transcript = transcript; + this.timeProvider = timeProvider; + } + + internal static BuildJRecordedExchange OpenPhysical( + TimeProvider timeProvider, + TextWriter output) + { + ArgumentNullException.ThrowIfNull(timeProvider); + ArgumentNullException.ThrowIfNull(output); + + BuildJTransactionTranscript transcript = + BuildJTransactionTranscript.CreateLocal(timeProvider); + IRs50HidppDisplayExchange? inner = null; + try + { + inner = Rs50HidppDeviceExchange.Open(); + output.WriteLine( + $"Build J local transcript: {transcript.RelativePath}"); + return new(inner, transcript, timeProvider); + } + catch + { + inner?.Dispose(); + transcript.Dispose(); + throw; + } + } + + public byte[] Exchange(Rs50HidppDisplayTransaction transaction) + { + ObjectDisposedException.ThrowIf(disposed, this); + ArgumentNullException.ThrowIfNull(transaction); + + int sequence = transcript.RecordRequest( + transaction, + timeProvider.GetUtcNow()); + long startTimestamp = timeProvider.GetTimestamp(); + + byte[] response; + try + { + response = inner.Exchange(transaction); + } + catch (Exception exception) + { + TimeSpan elapsed = timeProvider.GetElapsedTime( + startTimestamp, + timeProvider.GetTimestamp()); + transcript.RecordFailure( + sequence, + transaction, + exception, + timeProvider.GetUtcNow(), + elapsed); + throw; + } + + TimeSpan responseElapsed = timeProvider.GetElapsedTime( + startTimestamp, + timeProvider.GetTimestamp()); + transcript.RecordResponse( + sequence, + transaction, + response, + timeProvider.GetUtcNow(), + responseElapsed); + return response; + } + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + try + { + inner.Dispose(); + } + finally + { + transcript.Dispose(); + } + } +} + +internal sealed class BuildJTransactionTranscript : IDisposable +{ + internal const int MaximumTransactions = 52; + + private readonly TextWriter writer; + private readonly bool ownsWriter; + private readonly object writeGate = new(); + private int transactionCount; + private bool disposed; + + internal BuildJTransactionTranscript( + TextWriter writer, + string relativePath, + bool ownsWriter = false) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + + this.writer = writer; + this.ownsWriter = ownsWriter; + RelativePath = relativePath; + } + + internal string RelativePath { get; } + + internal static BuildJTransactionTranscript CreateLocal( + TimeProvider timeProvider) + { + ArgumentNullException.ThrowIfNull(timeProvider); + + const string transcriptDirectory = + ".tmp/rs50-build-j-transcripts"; + string fileName = + "rs50-build-j-transcript-" + + timeProvider.GetUtcNow().ToString( + "yyyyMMdd-HHmmss-fffffff'Z'", + CultureInfo.InvariantCulture) + + ".jsonl"; + string relativePath = Path.Combine( + transcriptDirectory, + fileName); + string absolutePath = Path.GetFullPath( + relativePath, + Environment.CurrentDirectory); + + Directory.CreateDirectory( + Path.GetDirectoryName(absolutePath)!); + var stream = new FileStream( + absolutePath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.Read, + bufferSize: 4096, + FileOptions.WriteThrough); + var writer = new StreamWriter(stream) + { + AutoFlush = true + }; + + return new(writer, relativePath, ownsWriter: true); + } + + internal int RecordRequest( + Rs50HidppDisplayTransaction transaction, + DateTimeOffset timestampUtc) + { + ArgumentNullException.ThrowIfNull(transaction); + + lock (writeGate) + { + ThrowIfDisposed(); + if (transactionCount >= MaximumTransactions) + { + throw new IOException( + "Build J exceeded its bounded transcript capacity."); + } + + int sequence = ++transactionCount; + WriteLine( + new + { + schema_version = 1, + sequence, + event_type = "request", + timestamp_utc = timestampUtc.ToUniversalTime(), + transaction = transaction.Kind.ToString(), + report_hex = Convert.ToHexString( + transaction.Request.Span) + }); + return sequence; + } + } + + internal void RecordResponse( + int sequence, + Rs50HidppDisplayTransaction transaction, + ReadOnlySpan response, + DateTimeOffset timestampUtc, + TimeSpan elapsed) + { + ArgumentNullException.ThrowIfNull(transaction); + + lock (writeGate) + { + ThrowIfDisposed(); + WriteLine( + new + { + schema_version = 1, + sequence, + event_type = "response", + timestamp_utc = timestampUtc.ToUniversalTime(), + elapsed_microseconds = + ToWholeMicroseconds(elapsed), + transaction = transaction.Kind.ToString(), + report_hex = Convert.ToHexString(response), + exact_header_match = HasExactResponseHeader( + transaction, + response) + }); + } + } + + internal void RecordFailure( + int sequence, + Rs50HidppDisplayTransaction transaction, + Exception exception, + DateTimeOffset timestampUtc, + TimeSpan elapsed) + { + ArgumentNullException.ThrowIfNull(transaction); + ArgumentNullException.ThrowIfNull(exception); + + lock (writeGate) + { + ThrowIfDisposed(); + WriteLine( + new + { + schema_version = 1, + sequence, + event_type = "failure", + timestamp_utc = timestampUtc.ToUniversalTime(), + elapsed_microseconds = + ToWholeMicroseconds(elapsed), + transaction = transaction.Kind.ToString(), + exception_type = exception.GetType().FullName + }); + } + } + + public void Dispose() + { + lock (writeGate) + { + if (disposed) + { + return; + } + + disposed = true; + if (ownsWriter) + { + writer.Dispose(); + } + } + } + + private void WriteLine(T entry) + { + writer.WriteLine(JsonSerializer.Serialize(entry)); + writer.Flush(); + } + + private void ThrowIfDisposed() => + ObjectDisposedException.ThrowIf(disposed, this); + + private static long ToWholeMicroseconds(TimeSpan elapsed) => + checked(elapsed.Ticks / TimeSpan.TicksPerMicrosecond); + + private static bool HasExactResponseHeader( + Rs50HidppDisplayTransaction transaction, + ReadOnlySpan response) + { + if (response.Length < 4) + { + return false; + } + + ReadOnlySpan request = transaction.Request.Span; + byte expectedFeature = + transaction.Kind == + Rs50HidppDisplayTransactionKind.DiscoverDisplayFeature + ? (byte)0 + : request[2]; + + return response[0] == 0x12 && + response[1] == Rs50HidppDisplayProtocol.BaseDeviceIndex && + response[2] == expectedFeature && + response[3] == request[3]; + } +} diff --git a/Rs50SharedHidppTelemetryTrial/Rs50SharedHidppTelemetryTrial.csproj b/Rs50SharedHidppTelemetryTrial/Rs50SharedHidppTelemetryTrial.csproj new file mode 100644 index 0000000..6cb8d12 --- /dev/null +++ b/Rs50SharedHidppTelemetryTrial/Rs50SharedHidppTelemetryTrial.csproj @@ -0,0 +1,26 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + + + + diff --git a/Rs50SharedHidppTransport/HidSharpRs50CollectionCatalog.cs b/Rs50SharedHidppTransport/HidSharpRs50CollectionCatalog.cs new file mode 100644 index 0000000..6b04060 --- /dev/null +++ b/Rs50SharedHidppTransport/HidSharpRs50CollectionCatalog.cs @@ -0,0 +1,83 @@ +using HidSharp; + +namespace Rs50SharedHidppTransport; + +internal sealed class HidSharpRs50CollectionCatalog + : IRs50HidCollectionCatalog +{ + private const int LogitechVendorId = 0x046D; + private const int Rs50ProductId = 0xC276; + + public IReadOnlyList Enumerate() => + DeviceList.Local + .GetHidDevices(LogitechVendorId, Rs50ProductId) + .OrderBy( + device => device.DevicePath, + StringComparer.OrdinalIgnoreCase) + .Select( + device => + (IRs50HidCollection)new HidSharpRs50Collection(device)) + .ToArray(); + + private sealed class HidSharpRs50Collection + : IRs50HidCollection + { + private readonly HidDevice device; + + public HidSharpRs50Collection(HidDevice device) + { + this.device = device; + Usages = device + .GetReportDescriptor() + .DeviceItems + .SelectMany(item => item.Usages.GetAllValues()) + .ToHashSet(); + } + + public int VendorId => device.VendorID; + + public int ProductId => device.ProductID; + + public string DevicePath => device.DevicePath; + + public IReadOnlySet Usages { get; } + + public int MaximumInputReportLength => + device.GetMaxInputReportLength(); + + public int MaximumOutputReportLength => + device.GetMaxOutputReportLength(); + + public IRs50HidStream Open() + { + if (!device.TryOpen(out HidStream stream)) + { + throw new IOException( + "The validated RS50 HID++ collection could not be opened."); + } + + stream.ReadTimeout = 1000; + stream.WriteTimeout = 1000; + return new HidSharpRs50Stream(stream); + } + } + + private sealed class HidSharpRs50Stream(HidStream stream) + : IRs50HidStream + { + public int Read(byte[] buffer) + { + ArgumentNullException.ThrowIfNull(buffer); + return stream.Read(buffer, 0, buffer.Length); + } + + public void Write(byte[] report) + { + ArgumentNullException.ThrowIfNull(report); + stream.Write(report); + } + + public void Dispose() => + stream.Dispose(); + } +} diff --git a/Rs50SharedHidppTransport/IRs50HidCollectionCatalog.cs b/Rs50SharedHidppTransport/IRs50HidCollectionCatalog.cs new file mode 100644 index 0000000..bae66fd --- /dev/null +++ b/Rs50SharedHidppTransport/IRs50HidCollectionCatalog.cs @@ -0,0 +1,30 @@ +namespace Rs50SharedHidppTransport; + +internal interface IRs50HidCollectionCatalog +{ + IReadOnlyList Enumerate(); +} + +internal interface IRs50HidCollection +{ + int VendorId { get; } + + int ProductId { get; } + + string DevicePath { get; } + + IReadOnlySet Usages { get; } + + int MaximumInputReportLength { get; } + + int MaximumOutputReportLength { get; } + + IRs50HidStream Open(); +} + +internal interface IRs50HidStream : IDisposable +{ + int Read(byte[] buffer); + + void Write(byte[] report); +} diff --git a/Rs50SharedHidppTransport/Rs50HidppDeviceExchange.cs b/Rs50SharedHidppTransport/Rs50HidppDeviceExchange.cs new file mode 100644 index 0000000..b70be38 --- /dev/null +++ b/Rs50SharedHidppTransport/Rs50HidppDeviceExchange.cs @@ -0,0 +1,246 @@ +using LogiDynamicDash.Hidpp; + +namespace Rs50SharedHidppTransport; + +/// +/// Physical adapter compiled as a separate, unreferenced library. +/// No application route constructs or loads this type. +/// +internal sealed class Rs50HidppDeviceExchange + : IRs50HidppDisplayExchange +{ + private const int LogitechVendorId = 0x046D; + private const int Rs50ProductId = 0xC276; + private const uint ShortCollectionUsage = 0xFF430701; + private const uint VeryLongCollectionUsage = 0xFF430704; + private const int MaximumReportsPerExchange = 16; + + private readonly IRs50HidStream shortStream; + private readonly IRs50HidStream veryLongStream; + private readonly object exchangeLock = new(); + private bool disposed; + + private Rs50HidppDeviceExchange( + IRs50HidStream shortStream, + IRs50HidStream veryLongStream) + { + this.shortStream = shortStream; + this.veryLongStream = veryLongStream; + } + + internal static Rs50HidppDeviceExchange Open() => + Open(new HidSharpRs50CollectionCatalog()); + + internal static Rs50HidppDeviceExchange Open( + IRs50HidCollectionCatalog catalog) + { + ArgumentNullException.ThrowIfNull(catalog); + + IReadOnlyList collections = + catalog.Enumerate(); + + IRs50HidCollection shortCollection = + SelectOnlyCollection( + collections, + "mi_01&col01", + ShortCollectionUsage, + expectedInputLength: 7, + expectedOutputLength: 7); + + IRs50HidCollection veryLongCollection = + SelectOnlyCollection( + collections, + "mi_01&col03", + VeryLongCollectionUsage, + expectedInputLength: 64, + expectedOutputLength: 64); + + IRs50HidStream? shortStream = null; + try + { + shortStream = shortCollection.Open(); + IRs50HidStream veryLongStream = veryLongCollection.Open(); + return new(shortStream, veryLongStream); + } + catch + { + shortStream?.Dispose(); + throw; + } + } + + public byte[] Exchange(Rs50HidppDisplayTransaction transaction) + { + ArgumentNullException.ThrowIfNull(transaction); + + lock (exchangeLock) + { + ObjectDisposedException.ThrowIf(disposed, this); + + byte[] request = transaction.Request.ToArray(); + ValidateClosedTransaction(transaction.Kind, request); + + IRs50HidStream outputStream = + transaction.Kind == + Rs50HidppDisplayTransactionKind.DiscoverDisplayFeature + ? shortStream + : veryLongStream; + + outputStream.Write(request); + return ReadMatchingResponse(transaction.Kind, request); + } + } + + public void Dispose() + { + lock (exchangeLock) + { + if (disposed) + { + return; + } + + disposed = true; + try + { + shortStream.Dispose(); + } + finally + { + veryLongStream.Dispose(); + } + } + } + + private byte[] ReadMatchingResponse( + Rs50HidppDisplayTransactionKind kind, + byte[] request) + { + for (int attempt = 0; + attempt < MaximumReportsPerExchange; + attempt++) + { + byte[] response = + new byte[Rs50HidppDisplayProtocol.VeryLongReportLength]; + + int bytesRead = veryLongStream.Read(response); + if (bytesRead != response.Length) + { + throw new IOException( + $"Expected a {response.Length}-byte HID++ response, " + + $"received {bytesRead}."); + } + + if (MatchesTransaction(kind, request, response)) + { + return response; + } + } + + throw new IOException( + "No matching RS50 Display Game Data response was received " + + $"within {MaximumReportsPerExchange} reports."); + } + + private static bool MatchesTransaction( + Rs50HidppDisplayTransactionKind kind, + byte[] request, + byte[] response) + { + if (response[0] != 0x12 || + response[1] != Rs50HidppDisplayProtocol.BaseDeviceIndex) + { + return false; + } + + byte expectedFeature = + kind == + Rs50HidppDisplayTransactionKind.DiscoverDisplayFeature + ? (byte)0 + : request[2]; + byte expectedFunction = request[3]; + + bool exactResponse = + response[2] == expectedFeature && + response[3] == expectedFunction; + + bool matchingError = + response[2] == 0xFF && + response[3] == Rs50HidppDisplayProtocol.SoftwareId && + response[4] == expectedFeature && + response[5] == expectedFunction; + + return exactResponse || matchingError; + } + + private static void ValidateClosedTransaction( + Rs50HidppDisplayTransactionKind kind, + byte[] request) + { + if (kind == + Rs50HidppDisplayTransactionKind.DiscoverDisplayFeature) + { + byte[] expected = + Rs50HidppDisplayProtocol + .CreateDiscovery() + .Request + .ToArray(); + + if (!request.SequenceEqual(expected)) + { + throw new InvalidOperationException( + "The discovery transaction is not canonical."); + } + + return; + } + + if (kind is < Rs50HidppDisplayTransactionKind.SetLayoutA or + > Rs50HidppDisplayTransactionKind.SetLayoutJ || + request.Length != + Rs50HidppDisplayProtocol.VeryLongReportLength || + request[0] != 0x12 || + request[1] != Rs50HidppDisplayProtocol.BaseDeviceIndex || + request[2] is < 0x02 or >= 0xFF || + request[3] != 0x3A || + request[4] != + (byte)(kind - + Rs50HidppDisplayTransactionKind.SetLayoutA) || + request[63] != 0) + { + throw new InvalidOperationException( + "The display-layout transaction is not canonical."); + } + } + + private static IRs50HidCollection SelectOnlyCollection( + IReadOnlyList collections, + string pathMarker, + uint expectedUsage, + int expectedInputLength, + int expectedOutputLength) + { + IRs50HidCollection[] matches = collections + .Where(collection => + collection.VendorId == LogitechVendorId && + collection.ProductId == Rs50ProductId && + collection.DevicePath.Contains( + pathMarker, + StringComparison.OrdinalIgnoreCase) && + collection.Usages.Count == 1 && + collection.Usages.Contains(expectedUsage) && + collection.MaximumInputReportLength == expectedInputLength && + collection.MaximumOutputReportLength == expectedOutputLength) + .ToArray(); + + if (matches.Length != 1) + { + throw new InvalidOperationException( + $"Expected exactly one validated RS50 {pathMarker} " + + $"collection, found {matches.Length}."); + } + + return matches[0]; + } + +} diff --git a/Rs50SharedHidppTransport/Rs50SharedHidppTransport.csproj b/Rs50SharedHidppTransport/Rs50SharedHidppTransport.csproj new file mode 100644 index 0000000..d8cc6ea --- /dev/null +++ b/Rs50SharedHidppTransport/Rs50SharedHidppTransport.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/Build-Rs50DirectInputBridge.ps1 b/scripts/Build-Rs50DirectInputBridge.ps1 new file mode 100644 index 0000000..0730c3d --- /dev/null +++ b/scripts/Build-Rs50DirectInputBridge.ps1 @@ -0,0 +1,190 @@ +[CmdletBinding()] +param( + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" + +& (Join-Path $PSScriptRoot "Test-Rs50DirectInputBridgePrerequisites.ps1") +if ($LASTEXITCODE -ne 0) { + throw "Native bridge prerequisites are not ready." +} + +$vswherePath = + "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" +$visualStudioPath = & $vswherePath ` + -latest ` + -products "*" ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -property installationPath + +if (-not $visualStudioPath) { + throw "Visual Studio with x64/x86 MSVC tools was not found." +} + +$msbuildPath = Join-Path ` + $visualStudioPath ` + "MSBuild\Current\Bin\MSBuild.exe" +$repositoryRoot = Split-Path -Parent $PSScriptRoot + +function Invoke-NativeBuild { + param( + [Parameter(Mandatory)] + [string]$ProjectPath + ) + + # The Codex host currently supplies both Path and PATH. MSBuild's + # .NET Framework tool task rejects that duplicate. This child cmd + # keeps the existing Path value but exposes it under one spelling only. + $buildCommand = + 'set "SAVED=!Path!" & ' + + 'set "PATH=" & ' + + 'set "Path=" & ' + + 'set "Path=!SAVED!" & ' + + '"' + $msbuildPath + '" ' + + '"' + $ProjectPath + '" ' + + '/m /t:Rebuild ' + + "/p:Configuration=$Configuration " + + '/p:Platform=x64 /verbosity:minimal' + + Push-Location $repositoryRoot + try { + & cmd.exe /v:on /d /c $buildCommand + if ($LASTEXITCODE -ne 0) { + throw "MSBuild failed for $ProjectPath." + } + } + finally { + Pop-Location + } +} + +Invoke-NativeBuild (Join-Path ` + $repositoryRoot ` + "Rs50DirectInputQuery\Rs50DirectInputQuery.vcxproj") +Invoke-NativeBuild (Join-Path ` + $repositoryRoot ` + "Rs50DirectInputBridge.Tests\Rs50DirectInputBridge.Tests.vcxproj") + +$outputDirectory = Join-Path ` + $repositoryRoot ` + "artifacts\native\$Configuration" +$testsPath = Join-Path ` + $outputDirectory ` + "Rs50DirectInputBridge.Tests.exe" +$queryPath = Join-Path ` + $outputDirectory ` + "Rs50DirectInputQuery.exe" + +& (Join-Path $PSScriptRoot "Test-Rs50NativeBridgeSurface.ps1") ` + -Configuration $Configuration +if ($LASTEXITCODE -ne 0) { + throw "Native bridge surface audit failed." +} + +& $testsPath +if ($LASTEXITCODE -ne 0) { + throw "Native safety tests failed." +} + +& $queryPath +if ($LASTEXITCODE -ne 2) { + throw "Query tool did not refuse an unarmed invocation." +} + +& $queryPath --query-display-support +if ($LASTEXITCODE -ne 2) { + throw "Query tool accepted only one confirmation argument." +} + +& $queryPath --query-display-support --confirm-exclusive-acquire +if ($LASTEXITCODE -ne 2) { + throw "Query tool accepted acquisition without transmission confirmation." +} + +& $queryPath ` + --query-display-support ` + --confirm-exclusive-acquire ` + --confirm-transmit-query +if ($LASTEXITCODE -ne 2) { + throw "Query tool accepted the retired Build A2 argument sequence." +} + +& $queryPath ` + --query-display-support ` + --confirm-standard-data-format ` + --confirm-exclusive-acquire +if ($LASTEXITCODE -ne 2) { + throw "Query tool accepted Build A3 without transmission confirmation." +} + +& $queryPath --query-display-support --confirm-transmit-query +if ($LASTEXITCODE -ne 2) { + throw "Query tool accepted the obsolete non-acquiring argument sequence." +} + +& $queryPath --query-layout-j-support +if ($LASTEXITCODE -ne 2) { + throw "Query tool accepted an unconfirmed Layout J query." +} + +& $queryPath ` + --query-layout-j-support ` + --confirm-standard-data-format ` + --confirm-exclusive-acquire +if ($LASTEXITCODE -ne 2) { + throw "Query tool accepted Layout J without transmission confirmation." +} + +& $queryPath ` + --query-layout-j-support ` + --confirm-standard-data-format ` + --confirm-exclusive-acquire ` + --confirm-transmit-query +if ($LASTEXITCODE -ne 2) { + throw "Layout J query accepted the general-query confirmation token." +} + +& $queryPath --set-static-layout-j +if ($LASTEXITCODE -ne 2) { + throw "Static Layout J setter accepted an unconfirmed invocation." +} + +& $queryPath ` + --set-static-layout-j ` + --confirm-standard-data-format ` + --confirm-exclusive-acquire ` + --confirm-layout-j-static-text +if ($LASTEXITCODE -ne 2) { + throw "Static Layout J setter accepted missing transmit confirmation." +} + +& $queryPath ` + --set-static-layout-j ` + --confirm-standard-data-format ` + --confirm-exclusive-acquire ` + --confirm-transmit-static-setter +if ($LASTEXITCODE -ne 2) { + throw "Static Layout J setter accepted missing payload confirmation." +} + +& $queryPath ` + --set-static-layout-j ` + --confirm-standard-data-format ` + --confirm-exclusive-acquire ` + --confirm-layout-j-static-text ` + --confirm-transmit-layout-query +if ($LASTEXITCODE -ne 2) { + throw "Static Layout J setter accepted the query confirmation token." +} + +& $queryPath --describe +if ($LASTEXITCODE -ne 0) { + throw "Query tool metadata check failed." +} + +Write-Output "" +Write-Output "Native guarded Build D verified without transmitting." +Write-Output "No valid owner window was passed to the bridge." +Write-Output "No DirectInput object or HID device was opened." diff --git a/scripts/Compare-Rs50DirectInputQueryCaptures.ps1 b/scripts/Compare-Rs50DirectInputQueryCaptures.ps1 new file mode 100644 index 0000000..11905ed --- /dev/null +++ b/scripts/Compare-Rs50DirectInputQueryCaptures.ps1 @@ -0,0 +1,98 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })] + [string] $BaselinePcapPath, + + [Parameter(Mandatory)] + [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })] + [string] $QueryPcapPath, + + [Parameter(Mandatory)] + [ValidateRange(1, 127)] + [int] $DeviceAddress, + + [DateTimeOffset] $QueryFromUtc, + + [DateTimeOffset] $QueryToUtc, + + [string] $TsharkPath = "C:\Program Files\Wireshark\tshark.exe" +) + +$ErrorActionPreference = "Stop" + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$exportScript = Join-Path $PSScriptRoot "Export-Rs50HidReports.ps1" +$explorerProject = Join-Path ` + $repositoryRoot ` + "LogiDynamicExplorer\LogiDynamicExplorer.csproj" +$temporaryDirectory = Join-Path ` + $repositoryRoot ` + "artifacts\capture-analysis" +$temporaryId = [Guid]::NewGuid().ToString("N") +$baselineReportsPath = Join-Path ` + $temporaryDirectory ` + "$temporaryId-baseline.tsv" +$queryReportsPath = Join-Path ` + $temporaryDirectory ` + "$temporaryId-query.tsv" + +New-Item -ItemType Directory -Path $temporaryDirectory -Force | Out-Null + +try { + $baselineReports = @( + & $exportScript ` + -PcapPath $BaselinePcapPath ` + -DeviceAddress $DeviceAddress ` + -TsharkPath $TsharkPath + ) + [IO.File]::WriteAllLines( + $baselineReportsPath, + [string[]] $baselineReports) + + $queryExportArguments = @{ + PcapPath = $QueryPcapPath + DeviceAddress = $DeviceAddress + TsharkPath = $TsharkPath + } + + if ($PSBoundParameters.ContainsKey("QueryFromUtc")) { + $queryExportArguments.FromUtc = $QueryFromUtc + } + + if ($PSBoundParameters.ContainsKey("QueryToUtc")) { + $queryExportArguments.ToUtc = $QueryToUtc + } + + $queryReports = @(& $exportScript @queryExportArguments) + [IO.File]::WriteAllLines( + $queryReportsPath, + [string[]] $queryReports) + + Write-Output "RS50 DirectInput query capture comparison" + Write-Output "USB device address: $DeviceAddress" + Write-Output "" + + & dotnet run ` + --project $explorerProject ` + --configuration Release ` + --no-build ` + --no-restore ` + -- ` + --compare-reports ` + $baselineReportsPath ` + $queryReportsPath + + if ($LASTEXITCODE -ne 0) { + throw "The offline report comparison failed with exit code $LASTEXITCODE." + } +} +finally { + if (Test-Path -LiteralPath $baselineReportsPath) { + Remove-Item -LiteralPath $baselineReportsPath -Force + } + + if (Test-Path -LiteralPath $queryReportsPath) { + Remove-Item -LiteralPath $queryReportsPath -Force + } +} diff --git a/scripts/Export-Rs50HidReports.ps1 b/scripts/Export-Rs50HidReports.ps1 new file mode 100644 index 0000000..e2bf339 --- /dev/null +++ b/scripts/Export-Rs50HidReports.ps1 @@ -0,0 +1,88 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })] + [string] $PcapPath, + + [Parameter(Mandatory)] + [ValidateRange(1, 127)] + [int] $DeviceAddress, + + [DateTimeOffset] $FromUtc, + + [DateTimeOffset] $ToUtc, + + [string] $TsharkPath = "C:\Program Files\Wireshark\tshark.exe" +) + +$ErrorActionPreference = "Stop" + +if (-not (Test-Path -LiteralPath $TsharkPath -PathType Leaf)) { + throw "tshark was not found at '$TsharkPath'." +} + +$resolvedPcapPath = (Resolve-Path -LiteralPath $PcapPath).Path +$displayFilter = "usb.device_address == $DeviceAddress" +$hasFromUtc = $PSBoundParameters.ContainsKey("FromUtc") +$hasToUtc = $PSBoundParameters.ContainsKey("ToUtc") + +if ($hasFromUtc -xor $hasToUtc) { + throw "FromUtc and ToUtc must be supplied together." +} + +if ($hasFromUtc) { + if ($FromUtc -gt $ToUtc) { + throw "FromUtc must be earlier than or equal to ToUtc." + } + + $invariantCulture = [Globalization.CultureInfo]::InvariantCulture + $fromEpoch = ($FromUtc.ToUnixTimeMilliseconds() / 1000.0).ToString( + "F3", + $invariantCulture) + $toEpoch = ($ToUtc.ToUnixTimeMilliseconds() / 1000.0).ToString( + "F3", + $invariantCulture) + $displayFilter += + " && frame.time_epoch >= $fromEpoch && frame.time_epoch <= $toEpoch" +} + +$tsharkArguments = @( + "-r", $resolvedPcapPath, + "-Y", $displayFilter, + "-T", "fields", + "-E", "separator=/t", + "-e", "frame.number", + "-e", "frame.time_epoch", + "-e", "usb.src", + "-e", "usb.dst", + "-e", "usb.data_fragment", + "-e", "usbhid.data" +) + +$rows = & $TsharkPath @tsharkArguments + +if ($LASTEXITCODE -ne 0) { + throw "tshark failed with exit code $LASTEXITCODE." +} + +foreach ($row in $rows) { + $fields = $row -split "`t", 6 + + if ($fields.Count -lt 6) { + continue + } + + $frameNumber = $fields[0] + $frameTime = $fields[1] + $source = $fields[2] + $destination = $fields[3] + $hostData = $fields[4] + $deviceData = $fields[5] + + if ($source -eq "host" -and $hostData) { + "HOST`t$frameNumber`t$frameTime`t$hostData" + } + elseif ($destination -eq "host" -and $deviceData) { + "DEVICE`t$frameNumber`t$frameTime`t$deviceData" + } +} diff --git a/scripts/Inspect-Rs50DirectInputQueryCapture.ps1 b/scripts/Inspect-Rs50DirectInputQueryCapture.ps1 new file mode 100644 index 0000000..98d0ef7 --- /dev/null +++ b/scripts/Inspect-Rs50DirectInputQueryCapture.ps1 @@ -0,0 +1,80 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })] + [string] $PcapPath, + + [Parameter(Mandatory)] + [ValidateRange(1, 127)] + [int] $DeviceAddress, + + [DateTimeOffset] $FromUtc, + + [DateTimeOffset] $ToUtc, + + [string] $TsharkPath = "C:\Program Files\Wireshark\tshark.exe" +) + +$ErrorActionPreference = "Stop" + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$exportScript = Join-Path $PSScriptRoot "Export-Rs50HidReports.ps1" +$explorerProject = Join-Path ` + $repositoryRoot ` + "LogiDynamicExplorer\LogiDynamicExplorer.csproj" + +if (-not (Test-Path -LiteralPath $exportScript -PathType Leaf)) { + throw "The HID report export script was not found at '$exportScript'." +} + +if (-not (Test-Path -LiteralPath $explorerProject -PathType Leaf)) { + throw "The offline explorer project was not found at '$explorerProject'." +} + +$exportArguments = @{ + PcapPath = $PcapPath + DeviceAddress = $DeviceAddress + TsharkPath = $TsharkPath +} + +if ($PSBoundParameters.ContainsKey("FromUtc")) { + $exportArguments.FromUtc = $FromUtc +} + +if ($PSBoundParameters.ContainsKey("ToUtc")) { + $exportArguments.ToUtc = $ToUtc +} + +$reports = @(& $exportScript @exportArguments) + +Write-Output "RS50 DirectInput query capture inspection" +Write-Output "Capture: $((Resolve-Path -LiteralPath $PcapPath).Path)" +Write-Output "USB device address: $DeviceAddress" +Write-Output "Extracted HOST/DEVICE HID reports: $($reports.Count)" +Write-Output "" + +if ($reports.Count -eq 0) { + Write-Output "No HID reports were extracted for this device address." + Write-Output "Verify the USBPcap interface and device address." + exit 2 +} + +$reports | + & dotnet run ` + --project $explorerProject ` + --configuration Release ` + --no-build ` + --no-restore ` + -- ` + --analyze-reports - + +if ($LASTEXITCODE -ne 0) { + throw "The offline report analyzer failed with exit code $LASTEXITCODE." +} + +Write-Output "" +Write-Output "Interpretation boundary:" +Write-Output " This command only reads the saved capture." +Write-Output " It does not transmit a display layout or telemetry." +Write-Output " A captured setter proves OLED output only when matched with the" +Write-Output " native result and an independent physical observation." diff --git a/scripts/Inspect-Rs50OledFirmwareVisuals.py b/scripts/Inspect-Rs50OledFirmwareVisuals.py new file mode 100644 index 0000000..5b17bf7 --- /dev/null +++ b/scripts/Inspect-Rs50OledFirmwareVisuals.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +"""Inspect RS50 OLED font metadata without opening or writing to a device.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import struct +import sys +from dataclasses import dataclass +from pathlib import Path + + +EXPECTED_SHA256 = ( + "62c152d68ba0873b7330401050a2dd96e099cfb6e648759f4329d59e382d3d9d" +) +DFU_WRAPPER_SIZE = 0x20 +IMAGE_BASE = 0x08010000 +FRAMEBUFFER_WIDTH = 128 +FRAMEBUFFER_HEIGHT = 64 +FRAMEBUFFER_BITS_PER_PIXEL = 1 +FRAMEBUFFER_BYTES = 0x400 +PRINTABLE_START = 0x20 +PRINTABLE_END = 0x7F + + +@dataclass(frozen=True) +class FontDefinition: + descriptor_address: int + expected_height: int + layout_use: str + + +FONTS = ( + FontDefinition(0x08044631, 9, "H/I/J rows 1 and 3"), + FontDefinition(0x08044636, 16, "D/E text"), + FontDefinition(0x08043032, 18, "H/I/J rows 2 and 4"), + FontDefinition(0x08043037, 27, "F field 2; G field 1"), + FontDefinition(0x0804303C, 37, "F field 1; G field 2"), +) + + +@dataclass(frozen=True) +class Glyph: + width: int + height: int + bitmap: bytes + + def is_set(self, x: int, y: int) -> bool: + stride = (self.width + 7) // 8 + return bool(self.bitmap[(y * stride) + (x // 8)] & (1 << (x % 8))) + + +@dataclass(frozen=True) +class ParsedFont: + definition: FontDefinition + glyph_table_address: int + glyphs: dict[int, Glyph] + + +def image_offset(address: int, image_length: int, size: int = 1) -> int: + offset = address - IMAGE_BASE + if offset < 0 or offset + size > image_length: + raise ValueError(f"address 0x{address:08X} is outside the image") + return offset + + +def read_u32(image: bytes, address: int) -> int: + return struct.unpack_from( + " ParsedFont: + descriptor_offset = image_offset( + definition.descriptor_address, + len(image), + 5, + ) + height = image[descriptor_offset] + if height != definition.expected_height: + raise ValueError( + f"font 0x{definition.descriptor_address:08X} has unexpected " + f"height {height}" + ) + + table_address = read_u32(image, definition.descriptor_address + 1) + glyphs: dict[int, Glyph] = {} + for character in range(PRINTABLE_START, PRINTABLE_END + 1): + record_address = table_address + ((character - PRINTABLE_START) * 6) + record_offset = image_offset(record_address, len(image), 6) + width = image[record_offset] + byte_count = image[record_offset + 1] + bitmap_address = struct.unpack_from( + " str: + result: list[str] = [] + for character in value: + code = ord(character) + if 0x61 <= code <= 0x7A: + code -= 0x20 + if code < PRINTABLE_START or code > PRINTABLE_END: + code = ord("?") + result.append(chr(code)) + return "".join(result) + + +def text_width(font: ParsedFont, text: str) -> int: + return sum(font.glyphs[ord(character)].width for character in text) + + +def render_text( + pixels: list[list[bool]], + font: ParsedFont, + text: str, + origin_x: int, + origin_y: int, + scale: int, +) -> None: + cursor_x = origin_x + for character in sanitize_text(text): + glyph = font.glyphs[ord(character)] + for y in range(glyph.height): + for x in range(glyph.width): + if not glyph.is_set(x, y): + continue + for scaled_y in range(scale): + for scaled_x in range(scale): + pixels[origin_y + (y * scale) + scaled_y][ + cursor_x + (x * scale) + scaled_x + ] = True + cursor_x += glyph.width * scale + + +def write_bmp(path: Path, fonts: tuple[ParsedFont, ...]) -> None: + scale = 2 + sample = "A0? RPM 123" + margin = 16 + row_gap = 14 + widths = [text_width(font, sample) * scale for font in fonts] + width = max(widths) + (margin * 2) + height = ( + sum(font.definition.expected_height * scale for font in fonts) + + (row_gap * (len(fonts) - 1)) + + (margin * 2) + ) + pixels = [[False for _ in range(width)] for _ in range(height)] + + y = margin + for font in fonts: + render_text(pixels, font, sample, margin, y, scale) + y += (font.definition.expected_height * scale) + row_gap + + row_stride = ((width * 3) + 3) & ~3 + pixel_bytes = bytearray() + for row in reversed(pixels): + for enabled in row: + value = 0xFF if enabled else 0x00 + pixel_bytes.extend((value, value, value)) + pixel_bytes.extend(b"\x00" * (row_stride - (width * 3))) + + file_header_size = 14 + dib_header_size = 40 + pixel_offset = file_header_size + dib_header_size + file_size = pixel_offset + len(pixel_bytes) + header = struct.pack( + "<2sIHHI", + b"BM", + file_size, + 0, + 0, + pixel_offset, + ) + dib = struct.pack( + " int: + parser = argparse.ArgumentParser( + description=( + "Validate and inspect the five embedded RS50 OLED bitmap fonts. " + "This tool performs file I/O only." + ) + ) + parser.add_argument( + "--firmware", + required=True, + type=Path, + help="Path to rs50_main_v165_4_39.dfu", + ) + parser.add_argument( + "--bmp-output", + type=Path, + help="Optional path for a local representative font-sample BMP", + ) + arguments = parser.parse_args() + + firmware = arguments.firmware.read_bytes() + digest = hashlib.sha256(firmware).hexdigest() + if digest != EXPECTED_SHA256: + raise ValueError( + f"unexpected firmware SHA-256 {digest}; expected {EXPECTED_SHA256}" + ) + if len(firmware) <= DFU_WRAPPER_SIZE: + raise ValueError("firmware image is missing") + image = firmware[DFU_WRAPPER_SIZE:] + + fonts = tuple(parse_font(image, definition) for definition in FONTS) + if arguments.bmp_output is not None: + write_bmp(arguments.bmp_output, fonts) + + report = { + "firmware_sha256": digest.upper(), + "framebuffer": { + "width": FRAMEBUFFER_WIDTH, + "height": FRAMEBUFFER_HEIGHT, + "bits_per_pixel": FRAMEBUFFER_BITS_PER_PIXEL, + "bytes": FRAMEBUFFER_BYTES, + }, + "host_text_normalization": { + "lowercase": "converted to uppercase", + "unsupported": "replaced with ?", + "wire_character_range": "0x20..0x7F", + "unicode": False, + }, + "fonts": [ + { + "descriptor": f"0x{font.definition.descriptor_address:08X}", + "height_pixels": font.definition.expected_height, + "glyph_table": f"0x{font.glyph_table_address:08X}", + "glyph_count": len(font.glyphs), + "minimum_width_pixels": min( + glyph.width for glyph in font.glyphs.values() + ), + "maximum_width_pixels": max( + glyph.width for glyph in font.glyphs.values() + ), + "layout_use": font.definition.layout_use, + } + for font in fonts + ], + "sample_bmp": ( + str(arguments.bmp_output.resolve()) + if arguments.bmp_output is not None + else None + ), + } + print(json.dumps(report, indent=2)) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError) as exception: + print(f"error: {exception}", file=sys.stderr) + raise SystemExit(1) from None diff --git a/scripts/Test-Rs50DirectInputBridgePrerequisites.ps1 b/scripts/Test-Rs50DirectInputBridgePrerequisites.ps1 new file mode 100644 index 0000000..4e191d9 --- /dev/null +++ b/scripts/Test-Rs50DirectInputBridgePrerequisites.ps1 @@ -0,0 +1,130 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" + +Write-Output "RS50 DirectInput bridge prerequisite check" +Write-Output "This script does not enumerate, open, or write to HID hardware." +Write-Output "" + +$is64Bit = [Environment]::Is64BitOperatingSystem -and + [Environment]::Is64BitProcess +Write-Output ("[" + $(if ($is64Bit) { "OK" } else { "MISSING" }) + + "] 64-bit Windows process") + +$vswherePath = + "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" +$visualStudioPath = $null +$vcToolsPath = $null + +if (Test-Path -LiteralPath $vswherePath -PathType Leaf) { + $visualStudioPath = & $vswherePath ` + -latest ` + -products "*" ` + -property installationPath + $vcToolsPath = & $vswherePath ` + -latest ` + -products "*" ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -property installationPath +} + +if ($visualStudioPath) { + Write-Output "[OK] Visual Studio: $visualStudioPath" +} else { + Write-Output "[MISSING] Visual Studio installation" +} + +if ($vcToolsPath) { + Write-Output "[OK] MSVC x86/x64 build tools" +} else { + Write-Output "[MISSING] MSVC x86/x64 build tools" +} + +$sdkHeaders = @() +$sdkIncludeRoot = "C:\Program Files (x86)\Windows Kits\10\Include" + +if (Test-Path -LiteralPath $sdkIncludeRoot -PathType Container) { + $sdkHeaders = Get-ChildItem ` + -LiteralPath $sdkIncludeRoot ` + -Recurse ` + -Filter dinput.h ` + -File ` + -ErrorAction SilentlyContinue +} + +if ($sdkHeaders.Count -gt 0) { + Write-Output "[OK] Windows SDK dinput.h: $($sdkHeaders[-1].FullName)" +} else { + Write-Output "[MISSING] Windows SDK dinput.h" +} + +$driverClsid = "{62B43F0E-E7DB-4329-8C13-A966D84A289F}" +$driverRegistryPath = + "Registry::HKEY_CLASSES_ROOT\CLSID\$driverClsid\InProcServer32" +$expectedDriverHash = + "17AB8FBB23FD549CCCCDB72A502C3BDCD984F80B6C40E48027C25512D0405A7F" +$driverPath = $null + +if (Test-Path -LiteralPath $driverRegistryPath) { + $driverPath = (Get-ItemProperty -LiteralPath $driverRegistryPath)."(default)" +} + +$driverExists = $driverPath -and + (Test-Path -LiteralPath $driverPath -PathType Leaf) +$driverTrusted = $false +$driverHashMatches = $false + +if ($driverExists) { + $driverVersion = (Get-Item -LiteralPath $driverPath).VersionInfo.FileVersion + $driverHash = (Get-FileHash -LiteralPath $driverPath -Algorithm SHA256).Hash + $driverHashMatches = $driverHash -eq $expectedDriverHash + $driverSignature = Get-AuthenticodeSignature -LiteralPath $driverPath + $driverSigner = $driverSignature.SignerCertificate.Subject + $driverTrusted = $driverSignature.Status -eq "Valid" -and + $driverSigner -like "*O=Logitech Inc*" + Write-Output "[OK] Logitech force-feedback driver: $driverPath" + Write-Output " Version: $driverVersion" + Write-Output " CLSID: $driverClsid" + Write-Output ( + "[" + $(if ($driverHashMatches) { "OK" } else { "MISMATCH" }) + + "] SHA-256: $driverHash" + ) + Write-Output ( + "[" + $(if ($driverTrusted) { "OK" } else { "MISMATCH" }) + + "] Authenticode signer: $driverSigner" + ) +} else { + Write-Output "[MISSING] Logitech force-feedback COM driver $driverClsid" +} + +$ready = $is64Bit -and $vcToolsPath -and + $sdkHeaders.Count -gt 0 -and $driverExists -and $driverTrusted -and + $driverHashMatches + +Write-Output "" + +if ($ready) { + Write-Output "READY: guarded native bridge can be compiled." + exit 0 +} + +Write-Output "NOT READY: do not add or compile the native bridge yet." + +if (-not $vcToolsPath -or $sdkHeaders.Count -eq 0) { + Write-Output ( + "Install Visual Studio Desktop development with C++, including " + + "MSVC x64/x86 tools and a Windows SDK." + ) + Write-Output "Workload ID: Microsoft.VisualStudio.Workload.NativeDesktop" + + $repositoryConfig = Join-Path ` + (Split-Path -Parent $PSScriptRoot) ` + "LogiDynamicDash.vsconfig" + + if (Test-Path -LiteralPath $repositoryConfig -PathType Leaf) { + Write-Output "Visual Studio Installer config: $repositoryConfig" + } +} + +exit 1 diff --git a/scripts/Test-Rs50NativeBridgeSurface.ps1 b/scripts/Test-Rs50NativeBridgeSurface.ps1 new file mode 100644 index 0000000..aab6a64 --- /dev/null +++ b/scripts/Test-Rs50NativeBridgeSurface.ps1 @@ -0,0 +1,152 @@ +[CmdletBinding()] +param( + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$bridgeSource = Join-Path ` + $repositoryRoot ` + "Rs50DirectInputBridge\src\rs50_display_bridge.cpp" +$bridgeHeader = Join-Path ` + $repositoryRoot ` + "Rs50DirectInputBridge\include\rs50_display_bridge.h" +$bridgeBinary = Join-Path ` + $repositoryRoot ` + "artifacts\native\$Configuration\Rs50DirectInputBridge.dll" + +if (-not (Test-Path -LiteralPath $bridgeBinary -PathType Leaf)) { + throw "Bridge binary does not exist: $bridgeBinary" +} + +$source = Get-Content -Raw -LiteralPath $bridgeSource +$header = Get-Content -Raw -LiteralPath $bridgeHeader + +if ($source -notmatch 'DisplayEscapeCommand\s*=\s*4\s*;' -or + $source -notmatch 'QueryDisplaySupportCommand\s*=\s*2\s*;' -or + $source -notmatch 'QueryLayoutJSupportCommand\s*=\s*12\s*;' -or + $source -notmatch 'StaticLayoutJCommand\s*=\s*22\s*;') { + throw "The bridge does not contain the audited command constants." +} + +if ($source -notmatch 'DISCL_EXCLUSIVE\s*\|\s*DISCL_FOREGROUND' -or + $source -notmatch 'SetDataFormat\s*\(\s*&c_dfDIJoystick2\s*\)' -or + $source -notmatch '->Acquire\s*\(' -or + $source -notmatch '->Unacquire\s*\(') { + throw "The bridge does not contain the audited exclusive lifecycle." +} + +$escapeCallCount = [regex]::Matches($source, '->Escape\s*\(').Count +$dataFormatCallCount = [regex]::Matches($source, '->SetDataFormat\s*\(').Count +$acquireCallCount = [regex]::Matches($source, '->Acquire\s*\(').Count +$unacquireCallCount = [regex]::Matches($source, '->Unacquire\s*\(').Count + +if ($escapeCallCount -ne 1 -or + $dataFormatCallCount -ne 1 -or + $acquireCallCount -ne 1 -or + $unacquireCallCount -ne 1) { + throw ( + "Unexpected native call counts: " + + "Escape=$escapeCallCount, SetDataFormat=$dataFormatCallCount, " + + "Acquire=$acquireCallCount, " + + "Unacquire=$unacquireCallCount.") +} + +$forbiddenSourcePatterns = @( + '\bSetIdle\b', + '\bSetLayout[A-I]\b', + '\bQueryLayout[A-I]\b', + '\bHidD_', + '\bWriteFile\s*\(', + '\bGetDeviceState\s*\(', + '\bGetDeviceData\s*\(', + '\bPoll\s*\(' +) + +foreach ($pattern in $forbiddenSourcePatterns) { + if ($source -match $pattern) { + throw "Forbidden native bridge surface matched: $pattern" + } +} + +$vswherePath = + "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" +$visualStudioPath = & $vswherePath ` + -latest ` + -products "*" ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -property installationPath +$toolsetRoot = Join-Path $visualStudioPath "VC\Tools\MSVC" +$toolset = Get-ChildItem ` + -LiteralPath $toolsetRoot ` + -Directory | + Sort-Object Name -Descending | + Select-Object -First 1 +$dumpbinPath = Join-Path ` + $toolset.FullName ` + "bin\Hostx64\x64\dumpbin.exe" + +$exports = & $dumpbinPath /exports $bridgeBinary | Out-String +$expectedExports = @( + "rs50_display_abi_version", + "rs50_display_begin_layout_j_stream", + "rs50_display_close", + "rs50_display_end_layout_j_stream", + "rs50_display_open", + "rs50_display_query_layout_j_support", + "rs50_display_query_support", + "rs50_display_set_layout_j_frame", + "rs50_display_set_static_layout_j", + "rs50_display_status_message" +) + +foreach ($name in $expectedExports) { + if ($exports -notmatch "\b$([regex]::Escape($name))\b") { + throw "Expected guarded bridge export is missing: $name" + } +} + +$setterExports = @( + [regex]::Matches($exports, '\brs50_display_set_[A-Za-z0-9_]+\b'). + Value | + Sort-Object -Unique +) +if ($setterExports.Count -ne 2 -or + $setterExports[0] -ne "rs50_display_set_layout_j_frame" -or + $setterExports[1] -ne "rs50_display_set_static_layout_j") { + throw "The bridge exports an unexpected display setter surface." +} + +$imports = & $dumpbinPath /imports $bridgeBinary | Out-String +if ($imports -notmatch '\bDINPUT8\.dll\b') { + throw "The bridge does not import the expected DirectInput runtime." +} + +if ($imports -match '\bHID\.dll\b' -or + $imports -match '\bSETUPAPI\.dll\b') { + throw "The guarded bridge unexpectedly imports a raw HID API." +} + +foreach ($text in @("LOGIDYNAMICDASH", "RS50", "OLED LINK", "TEST 1")) { + if ($source -notmatch [regex]::Escape('"' + $text + '"')) { + throw "The fixed Layout J text is missing: $text" + } +} + +if ($header -notmatch + 'rs50_display_set_static_layout_j\s*\(\s*rs50_display_handle\s*\*\s*\w+\s*,\s*rs50_display_static_layout_j_result\s*\*\s*\w+\s*\)\s*noexcept') { + throw "The fixed setter unexpectedly accepts caller-controlled text." +} + +if ($header -notmatch + 'rs50_display_set_layout_j_frame\s*\(\s*rs50_display_handle\s*\*\s*\w+\s*,\s*const\s+rs50_display_layout_j_frame\s*\*\s*\w+\s*,\s*rs50_display_stream_result\s*\*\s*\w+\s*\)\s*noexcept') { + throw "The dynamic setter does not expose the audited fixed-frame ABI." +} + +Write-Output "Native guarded bridge surface audit passed." +Write-Output "Exports: $($expectedExports -join ', ')" +Write-Output ( + "Only the fixed and validated Layout J setters are exported; " + + "no raw HID surface was found.") diff --git a/scripts/Test-Rs50OledFirmwareInspectorSurface.ps1 b/scripts/Test-Rs50OledFirmwareInspectorSurface.ps1 new file mode 100644 index 0000000..e1b2db8 --- /dev/null +++ b/scripts/Test-Rs50OledFirmwareInspectorSurface.ps1 @@ -0,0 +1,77 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$inspectorPath = + Join-Path $repositoryRoot "scripts\Inspect-Rs50OledFirmwareVisuals.py" + +if (-not (Test-Path -LiteralPath $inspectorPath -PathType Leaf)) { + throw "The RS50 OLED firmware inspector was not found." +} + +$source = Get-Content -LiteralPath $inspectorPath -Raw + +$forbiddenPatterns = @{ + "device or HID library" = + "\b(?:hid|hidraw|pyusb|usb\.core|win32|ctypes)\b" + "process or network access" = + "\b(?:subprocess|socket|urllib|requests|httpx)\b" + "shell execution" = + "\b(?:system|popen|spawn|exec|eval)\s*\(" + "ambient firmware path" = + "ProgramData|LGHUB" +} + +foreach ($entry in $forbiddenPatterns.GetEnumerator()) { + if ($source -match $entry.Value) { + throw "The inspector contains forbidden $($entry.Key)." + } +} + +$requiredPatterns = @( + "EXPECTED_SHA256\s*=", + "62c152d68ba0873b7330401050a2dd96e099cfb6e648759f4329d59e382d3d9d", + "DFU_WRAPPER_SIZE\s*=\s*0x20", + "IMAGE_BASE\s*=\s*0x08010000", + "FRAMEBUFFER_WIDTH\s*=\s*128", + "FRAMEBUFFER_HEIGHT\s*=\s*64", + "FRAMEBUFFER_BITS_PER_PIXEL\s*=\s*1", + "FRAMEBUFFER_BYTES\s*=\s*0x400", + "--firmware", + "required=True", + "if digest != EXPECTED_SHA256", + "byte_count != expected_bytes" +) + +foreach ($pattern in $requiredPatterns) { + if ($source -notmatch $pattern) { + throw "The inspector is missing a required validation boundary." + } +} + +foreach ($descriptor in @( + "0x08044631, 9", + "0x08044636, 16", + "0x08043032, 18", + "0x08043037, 27", + "0x0804303C, 37")) { + if ([regex]::Matches( + $source, + [regex]::Escape($descriptor)).Count -ne 1) { + throw "The inspector font descriptor set changed." + } +} + +if ([regex]::Matches( + $source, + "FontDefinition\s*\(").Count -ne 5) { + throw "The inspector must contain exactly five font definitions." +} + +Write-Output "RS50 OLED offline firmware-inspector audit passed." +Write-Output " Input: explicit DFU path with exact SHA-256 validation" +Write-Output " Output: JSON metadata and optional local BMP only" +Write-Output " Fonts: five fixed descriptors; 96 glyph records each" +Write-Output " HID, device, process, shell, and network access: absent" diff --git a/scripts/Test-Rs50SharedHidppBoundedStreamSurface.ps1 b/scripts/Test-Rs50SharedHidppBoundedStreamSurface.ps1 new file mode 100644 index 0000000..d3d292a --- /dev/null +++ b/scripts/Test-Rs50SharedHidppBoundedStreamSurface.ps1 @@ -0,0 +1,174 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$streamDirectory = + Join-Path $repositoryRoot "Rs50SharedHidppBoundedStream" +$streamProject = + Join-Path $streamDirectory "Rs50SharedHidppBoundedStream.csproj" +$dashboardProject = + Join-Path $repositoryRoot "LogiDynamicDash\LogiDynamicDash.csproj" +$programPath = + Join-Path $repositoryRoot "LogiDynamicDash\Program.cs" +$appDependencies = + Join-Path $repositoryRoot ` + "LogiDynamicDash\bin\Release\net10.0\LogiDynamicDash.deps.json" + +if (-not (Test-Path -LiteralPath $streamProject -PathType Leaf)) { + throw "The Build H bounded-stream project was not found." +} + +$sourceFiles = @( + Get-ChildItem -LiteralPath $streamDirectory -Filter "*.cs" -File +) +$source = ($sourceFiles | Get-Content -Raw) -join "`n" +$project = Get-Content -LiteralPath $streamProject -Raw +$dashboard = Get-Content -LiteralPath $dashboardProject -Raw +$program = Get-Content -LiteralPath $programPath -Raw + +$forbiddenPatterns = @{ + "direct HID access" = + "\b(HidSharp|GetHidDevices|TryOpen|SetFeature|GetFeature|Read|Write)\b" + "native or DirectInput access" = + "\b(DllImport|CreateFile|HidD_|DirectInput|Acquire|Unacquire)\b" + "telemetry or simulator integration" = + "\b(Telemetry|MonitorAsync|RequiredTelemetryVars|SDK)\b" + "force-feedback or LED feature" = + "\b(ForceFeedback|FFB|RPM|LIGHTSYNC|Led)\b|0x(8123|807A|807B)" + "unbounded execution" = + "\b(for|foreach|while)\s*\(|\bdo\s*\{|" + + "\b(Timer|Task|Parallel)\b" + "unapproved collection" = + "\b(mi_00|mi_02|col02)\b" +} + +foreach ($entry in $forbiddenPatterns.GetEnumerator()) { + if ($source -match $entry.Value) { + throw "Build H contains forbidden $($entry.Key)." + } +} + +$requiredArguments = @( + "--arm-rs50-shared-hidpp-bounded-stream", + "--confirm-ghub-closed", + "--confirm-iracing-closed", + "--confirm-rs50-awake", + "--confirm-dynamic-selected", + "--confirm-usbpcap-running", + "--confirm-one-hz-five-fixed-frames" +) + +foreach ($argument in $requiredArguments) { + if ([regex]::Matches( + $source, + [regex]::Escape('"' + $argument + '"')).Count -ne 1) { + throw "Build H must contain each arming argument exactly once." + } +} + +$requiredText = @( + "RS50 SHARED HIDPP", + "BUILD H", + "FRAME 1 OF 5", + "FRAME 2 OF 5", + "FRAME 3 OF 5", + "FRAME 4 OF 5", + "FRAME 5 OF 5", + "1 HZ" +) + +foreach ($text in $requiredText) { + if ([regex]::Matches( + $source, + [regex]::Escape('"' + $text + '"')).Count -ne 1) { + throw "Build H must contain each fixed display field exactly once." + } +} + +if ([regex]::Matches( + $source, + "SendFrame\s*\(\s*exchange").Count -ne 5 -or + [regex]::Matches( + $source, + "delay\.WaitOneSecond\s*\(\s*\)").Count -ne 4 -or + [regex]::Matches( + $source, + "\.CreateDiscovery\s*\(").Count -ne 1 -or + [regex]::Matches( + $source, + "\.CreateLayoutJ\s*\(").Count -ne 1 -or + [regex]::Matches( + $source, + "\.ParseDiscoveryResponse\s*\(").Count -ne 1 -or + [regex]::Matches( + $source, + "\.ParseLayoutJAcknowledgement\s*\(").Count -ne 1) { + throw "Build H must contain one discovery and exactly five fixed setters." +} + +if ([regex]::Matches( + $source, + "Thread\.Sleep\s*\(").Count -ne 1 -or + [regex]::Matches( + $source, + "TimeSpan\.FromSeconds\s*\(\s*1\s*\)").Count -ne 1) { + throw "Build H must implement only the fixed one-second delay." +} + +if ($source -notmatch + "using\s+IRs50HidppDisplayExchange\s+exchange\s*=" -or + $source -notmatch + "arguments\.SequenceEqual\(\s*ArmingArguments") { + throw "Build H is missing deterministic disposal or exact arming." +} + +$projectReferences = [regex]::Matches( + $project, + "' -or + $project -notmatch + 'Include="\.\.\\Rs50SharedHidppProtocol\\Rs50SharedHidppProtocol\.csproj"\s*/>') { + throw "Build F has an unexpected project dependency surface." +} + +$packageCount = [regex]::Matches( + $project, + "]+Rs50SharedHidppTransport' -or + $program -match "Rs50HidppDeviceExchange|SharedHidppTransport") { + throw "The application must not reference or construct Build F." +} + +if (Test-Path -LiteralPath $appDependencies -PathType Leaf) { + $dependencies = Get-Content -LiteralPath $appDependencies -Raw + if ($dependencies -match "Rs50SharedHidppTransport|HidSharp") { + throw "The physical transport leaked into the application output." + } +} + +if ($source -notmatch "LogitechVendorId\s*=\s*0x046D\s*;" -or + $source -notmatch "Rs50ProductId\s*=\s*0xC276\s*;" -or + $source -notmatch "ShortCollectionUsage\s*=\s*0xFF430701\s*;" -or + $source -notmatch "VeryLongCollectionUsage\s*=\s*0xFF430704\s*;" -or + $source -notmatch '"mi_01&col01"' -or + $source -notmatch '"mi_01&col03"') { + throw "Build F is missing an exact RS50 collection identity gate." +} + +$exchangeImplementations = [regex]::Matches( + $source, + ":\s*IRs50HidppDisplayExchange\b").Count + +if ($exchangeImplementations -ne 1) { + throw "Build F must contain exactly one closed exchange implementation." +} + +$deviceEnumerationCount = [regex]::Matches( + $source, + "\.GetHidDevices\s*\(").Count + +if ($deviceEnumerationCount -ne 1) { + throw "Build F must have exactly one VID/PID-scoped HID enumeration." +} + +$tryOpenCount = [regex]::Matches( + $source, + "\.TryOpen\s*\(").Count + +if ($tryOpenCount -ne 1) { + throw "Build F must have exactly one collection open call site." +} + +$publicTypeCount = [regex]::Matches( + $source, + "\bpublic\s+(?:sealed\s+|static\s+|partial\s+)?(?:class|interface|enum|record|struct)\b" +).Count + +if ($publicTypeCount -ne 0) { + throw "Build F must not expose a public transport type." +} + +Write-Output "RS50 shared HID++ Build F transport audit passed." +Write-Output " Collections: MI_01 COL01 (FF43:0701) + COL03 (FF43:0704)" +Write-Output " VID/PID: 046D:C276 only; exact lengths and uniqueness required" +Write-Output " Application/CLI reference: absent" +Write-Output " DirectInput, FFB, LEDs, MI_00/MI_02, native HID APIs: absent" diff --git a/scripts/research/Disassemble-PeAddress.py b/scripts/research/Disassemble-PeAddress.py new file mode 100644 index 0000000..5e0d168 --- /dev/null +++ b/scripts/research/Disassemble-PeAddress.py @@ -0,0 +1,48 @@ +"""Disassemble bytes at a virtual address in a PE without loading it.""" + +from __future__ import annotations + +import argparse + +import capstone +import pefile + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("pe_path") + parser.add_argument("address", type=lambda value: int(value, 0)) + parser.add_argument("--count", type=int, default=100) + parser.add_argument("--skipdata", action="store_true") + arguments = parser.parse_args() + + pe = pefile.PE(arguments.pe_path, fast_load=True) + image_base = pe.OPTIONAL_HEADER.ImageBase + address = arguments.address + rva = address - image_base if address >= image_base else address + virtual_address = image_base + rva + file_offset = pe.get_offset_from_rva(rva) + data = bytes(pe.__data__)[ + file_offset:file_offset + arguments.count * 16 + ] + mode = ( + capstone.CS_MODE_64 + if pe.OPTIONAL_HEADER.Magic == 0x20B + else capstone.CS_MODE_32 + ) + disassembler = capstone.Cs(capstone.CS_ARCH_X86, mode) + disassembler.skipdata = arguments.skipdata + + for instruction in disassembler.disasm( + data, + virtual_address, + count=arguments.count, + ): + print( + f"0x{instruction.address:X}: " + f"{instruction.mnemonic:<8} {instruction.op_str}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/Disassemble-ThumbImage.py b/scripts/research/Disassemble-ThumbImage.py new file mode 100644 index 0000000..685626d --- /dev/null +++ b/scripts/research/Disassemble-ThumbImage.py @@ -0,0 +1,38 @@ +"""Disassemble Thumb code from a raw image with an optional file wrapper.""" + +from __future__ import annotations + +import argparse + +import capstone + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("image_path") + parser.add_argument("address", type=lambda value: int(value, 0)) + parser.add_argument("--base", type=lambda value: int(value, 0), required=True) + parser.add_argument("--wrapper-size", type=lambda value: int(value, 0), default=0) + parser.add_argument("--count", type=int, default=100) + parser.add_argument("--skipdata", action="store_true") + arguments = parser.parse_args() + + address = arguments.address & ~1 + file_offset = arguments.wrapper_size + address - arguments.base + data = open(arguments.image_path, "rb").read() + code = data[file_offset:file_offset + arguments.count * 4] + disassembler = capstone.Cs( + capstone.CS_ARCH_ARM, + capstone.CS_MODE_THUMB | capstone.CS_MODE_LITTLE_ENDIAN, + ) + disassembler.skipdata = arguments.skipdata + + for instruction in disassembler.disasm(code, address, count=arguments.count): + print( + f"0x{instruction.address:08X}: " + f"{instruction.mnemonic:<8} {instruction.op_str}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/Find-MsvcRttiVtables.py b/scripts/research/Find-MsvcRttiVtables.py new file mode 100644 index 0000000..b944e44 --- /dev/null +++ b/scripts/research/Find-MsvcRttiVtables.py @@ -0,0 +1,157 @@ +"""Locate x64 MSVC RTTI complete-object locators and their vtables. + +This helper performs static PE parsing only. It is useful for finding virtual +methods of anonymous lambdas and other classes whose names survive in RTTI. +Install the ``pefile`` and ``capstone`` packages before use. +""" + +from __future__ import annotations + +import argparse +import struct + +import capstone +import pefile + + +def find_all(data: bytes, needle: bytes): + offset = 0 + + while True: + offset = data.find(needle, offset) + + if offset < 0: + return + + yield offset + offset += 1 + + +def executable_section_for(pe: pefile.PE, virtual_address: int): + rva = virtual_address - pe.OPTIONAL_HEADER.ImageBase + + for section in pe.sections: + start = section.VirtualAddress + end = start + max(section.Misc_VirtualSize, section.SizeOfRawData) + + if start <= rva < end and section.Characteristics & 0x20000000: + return section + + return None + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("pe_path") + parser.add_argument("type_substring") + parser.add_argument("--method-count", type=int, default=6) + parser.add_argument("--instruction-count", type=int, default=8) + arguments = parser.parse_args() + + pe = pefile.PE(arguments.pe_path, fast_load=True) + data = bytes(pe.__data__) + image_base = pe.OPTIONAL_HEADER.ImageBase + needle = arguments.type_substring.encode("ascii") + disassembler = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64) + + seen_name_offsets: set[int] = set() + + for match_offset in find_all(data, needle): + name_offset = data.rfind(b"\0", 0, match_offset) + 1 + name_end = data.find(b"\0", match_offset) + + if ( + name_end < 0 + or name_offset in seen_name_offsets + or not data[name_offset:name_offset + 3].startswith(b".?A") + ): + continue + + seen_name_offsets.add(name_offset) + + name = data[name_offset:name_end].decode("ascii", errors="replace") + + # An x64 MSVC TypeDescriptor stores two pointers immediately before + # its NUL-terminated decorated name. + descriptor_offset = name_offset - 16 + + if descriptor_offset < 0: + continue + + descriptor_rva = pe.get_rva_from_offset(descriptor_offset) + print( + f"type {name}\n" + f" descriptor file=0x{descriptor_offset:X} " + f"rva=0x{descriptor_rva:X}" + ) + + for type_rva_offset in find_all(data, struct.pack(" len(data): + continue + + signature, _, _, referenced_type_rva, _, self_rva = ( + struct.unpack_from("<6I", data, locator_offset) + ) + + try: + locator_rva = pe.get_rva_from_offset(locator_offset) + except pefile.PEFormatError: + continue + + if ( + signature != 1 + or referenced_type_rva != descriptor_rva + or self_rva != locator_rva + ): + continue + + locator_va = image_base + locator_rva + print( + f" locator file=0x{locator_offset:X} " + f"rva=0x{locator_rva:X} va=0x{locator_va:X}" + ) + + for locator_pointer_offset in find_all( + data, + struct.pack(" None: + parser = argparse.ArgumentParser() + parser.add_argument("pe_path") + parser.add_argument("address", type=lambda value: int(value, 0)) + parser.add_argument("--data-pointers", action="store_true") + arguments = parser.parse_args() + + pe = pefile.PE(arguments.pe_path, fast_load=True) + image_base = pe.OPTIONAL_HEADER.ImageBase + target = ( + arguments.address + if arguments.address >= image_base + else image_base + arguments.address + ) + mode = ( + capstone.CS_MODE_64 + if pe.OPTIONAL_HEADER.Magic == 0x20B + else capstone.CS_MODE_32 + ) + disassembler = capstone.Cs(capstone.CS_ARCH_X86, mode) + disassembler.detail = True + disassembler.skipdata = True + + if arguments.data_pointers: + pointer_size = 8 if mode == capstone.CS_MODE_64 else 4 + pointer = struct.pack("= 0: + print( + f"data pointer 0x{image_base + section.VirtualAddress + offset:X}" + ) + offset = section_data.find(pointer, offset + pointer_size) + + for section in pe.sections: + if not section.Characteristics & 0x20000000: + continue + + section_data = section.get_data() + section_address = image_base + section.VirtualAddress + + for instruction in disassembler.disasm(section_data, section_address): + if instruction.id == 0: + continue + + matched = False + + for operand in instruction.operands: + if operand.type == X86_OP_IMM and operand.imm == target: + matched = True + elif operand.type == X86_OP_MEM: + referenced_address = operand.mem.disp + + if operand.mem.base == X86_REG_RIP: + referenced_address += ( + instruction.address + instruction.size + ) + + if referenced_address == target: + matched = True + + if matched: + print( + f"0x{instruction.address:X}: " + f"{instruction.mnemonic} {instruction.op_str}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/Find-PeImmediate.py b/scripts/research/Find-PeImmediate.py new file mode 100644 index 0000000..4bcb58d --- /dev/null +++ b/scripts/research/Find-PeImmediate.py @@ -0,0 +1,52 @@ +"""Find x86/x64 instructions containing a selected immediate in a PE file.""" + +from __future__ import annotations + +import argparse + +import capstone +from capstone.x86 import X86_OP_IMM +import pefile + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("pe_path") + parser.add_argument("immediate", type=lambda value: int(value, 0)) + arguments = parser.parse_args() + + pe = pefile.PE(arguments.pe_path, fast_load=True) + image_base = pe.OPTIONAL_HEADER.ImageBase + mode = ( + capstone.CS_MODE_64 + if pe.OPTIONAL_HEADER.Magic == 0x20B + else capstone.CS_MODE_32 + ) + disassembler = capstone.Cs(capstone.CS_ARCH_X86, mode) + disassembler.detail = True + disassembler.skipdata = True + + for section in pe.sections: + if not section.Characteristics & 0x20000000: + continue + + for instruction in disassembler.disasm( + section.get_data(), + image_base + section.VirtualAddress, + ): + if instruction.id == 0: + continue + + if any( + operand.type == X86_OP_IMM + and operand.imm == arguments.immediate + for operand in instruction.operands + ): + print( + f"0x{instruction.address:X}: " + f"{instruction.mnemonic} {instruction.op_str}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/Find-PeLoadCompare.py b/scripts/research/Find-PeLoadCompare.py new file mode 100644 index 0000000..cf99992 --- /dev/null +++ b/scripts/research/Find-PeLoadCompare.py @@ -0,0 +1,135 @@ +"""Find PE code that loads a field and soon compares it with an immediate. + +This static-analysis helper is intended for locating switch dispatchers such as +``mov eax, [object + 4]`` followed by ``cmp eax, 5``. It never loads or invokes +the inspected binary. Install the ``pefile`` and ``capstone`` packages first. +""" + +from __future__ import annotations + +import argparse + +import capstone +import pefile +from capstone.x86 import X86_OP_IMM, X86_OP_MEM, X86_OP_REG + + +def canonical_register(disassembler: capstone.Cs, register: int) -> str: + """Return a stable name for common x86 register-width aliases.""" + name = disassembler.reg_name(register) + + legacy_aliases = { + "rax": "a", "eax": "a", "ax": "a", "al": "a", "ah": "a", + "rbx": "b", "ebx": "b", "bx": "b", "bl": "b", "bh": "b", + "rcx": "c", "ecx": "c", "cx": "c", "cl": "c", "ch": "c", + "rdx": "d", "edx": "d", "dx": "d", "dl": "d", "dh": "d", + "rsi": "si", "esi": "si", "si": "si", "sil": "si", + "rdi": "di", "edi": "di", "di": "di", "dil": "di", + "rbp": "bp", "ebp": "bp", "bp": "bp", "bpl": "bp", + "rsp": "sp", "esp": "sp", "sp": "sp", "spl": "sp", + } + + if name in legacy_aliases: + return legacy_aliases[name] + + if name.startswith("r") and name[-1:] in {"b", "d", "w"}: + return name[:-1] + + return name + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("pe_path") + parser.add_argument("displacement", type=lambda value: int(value, 0)) + parser.add_argument("immediate", type=lambda value: int(value, 0)) + parser.add_argument("--lookahead", type=int, default=8) + parser.add_argument("--context", type=int, default=4) + arguments = parser.parse_args() + + pe = pefile.PE(arguments.pe_path, fast_load=True) + mode = ( + capstone.CS_MODE_64 + if pe.OPTIONAL_HEADER.Magic == 0x20B + else capstone.CS_MODE_32 + ) + disassembler = capstone.Cs(capstone.CS_ARCH_X86, mode) + disassembler.detail = True + disassembler.skipdata = True + image_base = pe.OPTIONAL_HEADER.ImageBase + + for section in pe.sections: + if not section.Characteristics & 0x20000000: + continue + + code = section.get_data() + section_va = image_base + section.VirtualAddress + instructions = [ + instruction + for instruction in disassembler.disasm(code, section_va) + if instruction.id != 0 + ] + + for index, instruction in enumerate(instructions): + if instruction.mnemonic not in {"mov", "movzx", "movsxd"}: + continue + + operands = instruction.operands + + if ( + len(operands) != 2 + or operands[0].type != X86_OP_REG + or operands[1].type != X86_OP_MEM + or operands[1].mem.disp != arguments.displacement + ): + continue + + loaded_register = operands[0].reg + loaded_family = canonical_register(disassembler, loaded_register) + match_index = None + + for candidate_index in range( + index + 1, + min(index + 1 + arguments.lookahead, len(instructions)), + ): + candidate = instructions[candidate_index] + candidate_operands = candidate.operands + + if ( + candidate.mnemonic == "cmp" + and len(candidate_operands) == 2 + and candidate_operands[0].type == X86_OP_REG + and canonical_register( + disassembler, + candidate_operands[0].reg, + ) == loaded_family + and candidate_operands[1].type == X86_OP_IMM + and candidate_operands[1].imm == arguments.immediate + ): + match_index = candidate_index + break + + written_registers = candidate.regs_access()[1] + + if any( + canonical_register(disassembler, written) == loaded_family + for written in written_registers + ): + break + + if match_index is None: + continue + + start = max(index - arguments.context, 0) + end = min(match_index + arguments.context + 1, len(instructions)) + print(f"match 0x{instruction.address:X}") + + for preview_index in range(start, end): + preview = instructions[preview_index] + marker = ">" if index <= preview_index <= match_index else " " + rendered = f"{preview.mnemonic} {preview.op_str}".rstrip() + print(f" {marker} 0x{preview.address:X}: {rendered}") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/Find-PeMemoryDisplacement.py b/scripts/research/Find-PeMemoryDisplacement.py new file mode 100644 index 0000000..a141bf2 --- /dev/null +++ b/scripts/research/Find-PeMemoryDisplacement.py @@ -0,0 +1,52 @@ +"""Find instructions using a selected memory displacement in a PE.""" + +from __future__ import annotations + +import argparse + +import capstone +from capstone.x86 import X86_OP_MEM +import pefile + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("pe_path") + parser.add_argument("displacement", type=lambda value: int(value, 0)) + arguments = parser.parse_args() + + pe = pefile.PE(arguments.pe_path, fast_load=True) + image_base = pe.OPTIONAL_HEADER.ImageBase + mode = ( + capstone.CS_MODE_64 + if pe.OPTIONAL_HEADER.Magic == 0x20B + else capstone.CS_MODE_32 + ) + disassembler = capstone.Cs(capstone.CS_ARCH_X86, mode) + disassembler.detail = True + disassembler.skipdata = True + + for section in pe.sections: + if not section.Characteristics & 0x20000000: + continue + + for instruction in disassembler.disasm( + section.get_data(), + image_base + section.VirtualAddress, + ): + if instruction.id == 0: + continue + + if any( + operand.type == X86_OP_MEM + and operand.mem.disp == arguments.displacement + for operand in instruction.operands + ): + print( + f"0x{instruction.address:X}: " + f"{instruction.mnemonic} {instruction.op_str}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/Find-PeMnemonic.py b/scripts/research/Find-PeMnemonic.py new file mode 100644 index 0000000..45ef3e9 --- /dev/null +++ b/scripts/research/Find-PeMnemonic.py @@ -0,0 +1,44 @@ +"""Find selected x86/x64 instruction mnemonics in a PE file.""" + +from __future__ import annotations + +import argparse + +import capstone +import pefile + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("pe_path") + parser.add_argument("mnemonics", nargs="+") + arguments = parser.parse_args() + + requested = {mnemonic.lower() for mnemonic in arguments.mnemonics} + pe = pefile.PE(arguments.pe_path, fast_load=True) + image_base = pe.OPTIONAL_HEADER.ImageBase + mode = ( + capstone.CS_MODE_64 + if pe.OPTIONAL_HEADER.Magic == 0x20B + else capstone.CS_MODE_32 + ) + disassembler = capstone.Cs(capstone.CS_ARCH_X86, mode) + disassembler.skipdata = True + + for section in pe.sections: + if not section.Characteristics & 0x20000000: + continue + + for instruction in disassembler.disasm( + section.get_data(), + image_base + section.VirtualAddress, + ): + if instruction.id != 0 and instruction.mnemonic in requested: + print( + f"0x{instruction.address:X}: " + f"{instruction.mnemonic} {instruction.op_str}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/Find-PeStringXrefs.py b/scripts/research/Find-PeStringXrefs.py new file mode 100644 index 0000000..007956a --- /dev/null +++ b/scripts/research/Find-PeStringXrefs.py @@ -0,0 +1,126 @@ +"""Find direct x64 RIP-relative references to a string in a PE file. + +This is an offline research helper. It reads an executable but never loads or +executes it. Install the `pefile` and `capstone` Python packages before use. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Iterable +import struct + +import capstone +from capstone.x86 import X86_OP_MEM, X86_REG_RIP +import pefile + + +def find_all(data: bytes, needle: bytes) -> Iterable[int]: + offset = 0 + + while True: + offset = data.find(needle, offset) + + if offset < 0: + return + + yield offset + offset += 1 + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("pe_path") + parser.add_argument("text") + arguments = parser.parse_args() + + pe = pefile.PE(arguments.pe_path, fast_load=True) + data = bytes(pe.__data__) + image_base = pe.OPTIONAL_HEADER.ImageBase + targets: dict[int, str] = {} + + encodings = { + "ASCII": arguments.text.encode("ascii") + b"\0", + "UTF-16LE": arguments.text.encode("utf-16le") + b"\0\0", + } + + for encoding_name, encoded_text in encodings.items(): + for file_offset in find_all(data, encoded_text): + rva = pe.get_rva_from_offset(file_offset) + target = image_base + rva + targets[target] = encoding_name + print( + f"string {encoding_name}: file=0x{file_offset:X} " + f"rva=0x{rva:X} va=0x{target:X}" + ) + + if not targets: + print("string not found") + return + + # MSVC binaries commonly keep a pointer to a string in a descriptor or + # message table and reference that slot from code. Include one level of + # indirection so those call sites are visible without loading the PE. + indirect_targets: dict[int, str] = {} + + for target, encoding_name in list(targets.items()): + encoded_pointer = struct.pack(" None: + parser = argparse.ArgumentParser() + parser.add_argument("image_path") + parser.add_argument("address", type=lambda value: int(value, 0)) + parser.add_argument("--base", type=lambda value: int(value, 0), required=True) + parser.add_argument( + "--wrapper-size", + type=lambda value: int(value, 0), + default=0, + ) + arguments = parser.parse_args() + + image = open(arguments.image_path, "rb").read()[arguments.wrapper_size:] + target = arguments.address & ~1 + disassembler = capstone.Cs( + capstone.CS_ARCH_ARM, + capstone.CS_MODE_THUMB | capstone.CS_MODE_LITTLE_ENDIAN, + ) + disassembler.detail = True + disassembler.skipdata = True + + for instruction in disassembler.disasm(image, arguments.base): + if instruction.id == 0: + continue + + if any( + operand.type == ARM_OP_IMM and (operand.imm & ~1) == target + for operand in instruction.operands + ): + print( + f"0x{instruction.address:08X}: " + f"{instruction.mnemonic} {instruction.op_str}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/Find-ThumbLiteralXrefs.py b/scripts/research/Find-ThumbLiteralXrefs.py new file mode 100644 index 0000000..1103ac2 --- /dev/null +++ b/scripts/research/Find-ThumbLiteralXrefs.py @@ -0,0 +1,69 @@ +"""Find Thumb literal loads whose pool word equals a selected value.""" + +from __future__ import annotations + +import argparse +import struct + +import capstone +from capstone.arm import ARM_OP_MEM, ARM_REG_PC + + +def find_all(data: bytes, needle: bytes): + offset = 0 + + while True: + offset = data.find(needle, offset) + + if offset < 0: + return + + yield offset + offset += 1 + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("image_path") + parser.add_argument("value", type=lambda value: int(value, 0)) + parser.add_argument("--base", type=lambda value: int(value, 0), required=True) + parser.add_argument("--wrapper-size", type=lambda value: int(value, 0), default=0) + arguments = parser.parse_args() + + data = open(arguments.image_path, "rb").read() + image = data[arguments.wrapper_size:] + disassembler = capstone.Cs( + capstone.CS_ARCH_ARM, + capstone.CS_MODE_THUMB | capstone.CS_MODE_LITTLE_ENDIAN, + ) + disassembler.detail = True + + pool_addresses = { + arguments.base + offset + for offset in find_all(image, struct.pack(" 0x{literal_address:08X}: " + f"{instruction.mnemonic} {instruction.op_str}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/README.md b/scripts/research/README.md new file mode 100644 index 0000000..819ae11 --- /dev/null +++ b/scripts/research/README.md @@ -0,0 +1,43 @@ +# Static Research Helpers + +These scripts reproduce narrow reverse-engineering checks used by the RS50 +OLED research. They parse binary files as data and never load a DLL, execute a +firmware image, open HID hardware, or transmit a report. + +## Requirements + +- Python 3.10 or later +- `pefile` and `capstone` for PE helpers + +Keep proprietary Logitech binaries, firmware, captures, and extracted files +outside the repository. Pass their local path explicitly on each invocation. + +## PE Helpers + +- `Disassemble-PeAddress.py`: disassemble a bounded virtual-address range. +- `Find-MsvcRttiVtables.py`: locate x64 MSVC RTTI vtables by type substring. +- `Find-PeAddressXrefs.py`: find code/data references to one address. +- `Find-PeImmediate.py`: find instructions containing one immediate value. +- `Find-PeLoadCompare.py`: find a field load followed by a comparison, useful + for locating switch dispatchers in both x86 and x64 images. +- `Find-PeMemoryDisplacement.py`: find memory operands with one displacement. +- `Find-PeMnemonic.py`: find one instruction mnemonic. +- `Find-PeStringXrefs.py`: find direct x64 references to ASCII/UTF-16 strings. +- `Read-PeRelativeJumpTable.py`: read image-relative jump tables; use + `--absolute` for x86 tables containing full virtual addresses. + +Example, with a locally extracted driver: + +```powershell +python scripts/research/Find-PeLoadCompare.py DRIVER.dll 4 5 +python scripts/research/Read-PeRelativeJumpTable.py DRIVER.dll 0x26789C 6 --absolute +``` + +## Firmware Helpers + +- `Disassemble-ThumbImage.py`: disassemble a bounded Thumb-2 image range. +- `Find-ThumbAddressXrefs.py`: find materialized references to an address. +- `Find-ThumbLiteralXrefs.py`: find literal-pool references to a value. + +All reported addresses are static evidence. They do not by themselves +authorize a hardware command or establish runtime behavior. diff --git a/scripts/research/Read-PeRelativeJumpTable.py b/scripts/research/Read-PeRelativeJumpTable.py new file mode 100644 index 0000000..852a4bf --- /dev/null +++ b/scripts/research/Read-PeRelativeJumpTable.py @@ -0,0 +1,45 @@ +"""Read a table of relative or absolute 32-bit PE code addresses.""" + +from __future__ import annotations + +import argparse +import struct + +import pefile + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("pe_path") + parser.add_argument("rva", type=lambda value: int(value, 0)) + parser.add_argument("count", type=int) + parser.add_argument("--first-index", type=int, default=0) + parser.add_argument( + "--absolute", + action="store_true", + help="Treat entries as absolute 32-bit virtual addresses.", + ) + arguments = parser.parse_args() + + pe = pefile.PE(arguments.pe_path, fast_load=True) + data = bytes(pe.__data__) + file_offset = pe.get_offset_from_rva(arguments.rva) + image_base = pe.OPTIONAL_HEADER.ImageBase + + for index in range(arguments.count): + raw_address = struct.unpack_from( + "