diff --git a/tests/IfLoc.Sim.Tests/IfLoc.Sim.Tests.csproj b/tests/IfLoc.Sim.Tests/IfLoc.Sim.Tests.csproj
new file mode 100644
index 0000000..ea9682d
--- /dev/null
+++ b/tests/IfLoc.Sim.Tests/IfLoc.Sim.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+
+
+ net9.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/IfLoc.Sim.Tests/RoundTripTests.cs b/tests/IfLoc.Sim.Tests/RoundTripTests.cs
new file mode 100644
index 0000000..314c033
--- /dev/null
+++ b/tests/IfLoc.Sim.Tests/RoundTripTests.cs
@@ -0,0 +1,228 @@
+using IfLoc.Sim;
+using Xunit;
+
+namespace IfLoc.Sim.Tests;
+
+///
+/// Mapped test for S1-b (Risk R-3, SRS §3.1). Verifies (1) every telemetry dictionary
+/// field survives a byte-level encode/decode with correct units and sentinels, and
+/// (2) the capture round-trip: every locator trigger yields exactly one correctly
+/// correlated capture-result and no capture is silently dropped (SRS-LOG-7).
+///
+public class RoundTripTests
+{
+ [Fact]
+ public void Telemetry_frame_is_exactly_32_bytes()
+ {
+ var frame = LocatorSimulator.BuildTelemetry(0, 0).EncodeFrame(0);
+ Assert.Equal(IfLoc.TelemetryFrameLen, frame.Length);
+ Assert.Equal(IfLoc.FrameVersion, frame[0]);
+ Assert.Equal((byte)MessageType.Telemetry, frame[1]);
+ }
+
+ [Fact]
+ public void Telemetry_roundtrips_every_field_with_units()
+ {
+ var t = new Telemetry
+ {
+ LocatorUptimeMs = 1_234_567,
+ DepthMeters = 1.23, // 0.01 m resolution → 123 cm on wire
+ SignalCurrentMa = 742,
+ FrequencyHz = 82_500,
+ Mode = LocateMode.TwinSweep,
+ SignalType = SignalType.Sonde,
+ GainDb = 137,
+ SignalLevel = 88,
+ DistortionQualityPct = 91,
+ SignalDirection = 2,
+ CompassAngleDeg = 174,
+ GuidanceOffset = -375, // negative = left
+ Warnings = WarningFlags.Shallow | WarningFlags.DistortionHigh,
+ Utility = UtilityType.Gas,
+ BatteryPercent = 64,
+ Status = StatusFlags.Locating | StatusFlags.TimeSynced,
+ };
+
+ var back = Telemetry.DecodePayload(t.EncodePayload());
+
+ Assert.Equal(t.LocatorUptimeMs, back.LocatorUptimeMs);
+ Assert.Equal(1.23, back.DepthMeters!.Value, 3);
+ Assert.Equal(t.SignalCurrentMa, back.SignalCurrentMa);
+ Assert.Equal(t.FrequencyHz, back.FrequencyHz);
+ Assert.Equal(t.Mode, back.Mode);
+ Assert.Equal(t.SignalType, back.SignalType);
+ Assert.Equal(t.GainDb, back.GainDb);
+ Assert.Equal(t.SignalLevel, back.SignalLevel);
+ Assert.Equal(t.DistortionQualityPct, back.DistortionQualityPct);
+ Assert.Equal(t.SignalDirection, back.SignalDirection);
+ Assert.Equal(t.CompassAngleDeg, back.CompassAngleDeg);
+ Assert.Equal(t.GuidanceOffset, back.GuidanceOffset);
+ Assert.Equal(t.Warnings, back.Warnings);
+ Assert.Equal(t.Utility, back.Utility);
+ Assert.Equal(t.BatteryPercent, back.BatteryPercent);
+ Assert.Equal(t.Status, back.Status);
+ }
+
+ [Fact]
+ public void Telemetry_sentinels_decode_to_null()
+ {
+ var t = new Telemetry
+ {
+ DepthMeters = null, // no depth
+ SignalCurrentMa = null, // invalid current
+ FrequencyHz = null, // n/a
+ GuidanceOffset = null, // n/a
+ };
+ var back = Telemetry.DecodePayload(t.EncodePayload());
+ Assert.Null(back.DepthMeters);
+ Assert.Null(back.SignalCurrentMa);
+ Assert.Null(back.FrequencyHz);
+ Assert.Null(back.GuidanceOffset);
+ }
+
+ [Fact]
+ public void Depth_is_little_endian_centimetres()
+ {
+ // 1.23 m → 123 cm → 0x007B, little-endian at frame offset 8..9 = 7B 00
+ var frame = new Telemetry { DepthMeters = 1.23 }.EncodeFrame(0);
+ Assert.Equal(0x7B, frame[8]);
+ Assert.Equal(0x00, frame[9]);
+ }
+
+ [Fact]
+ public void CaptureResult_pointId_is_rfc4122_big_endian()
+ {
+ var id = Guid.Parse("018f5b2c-1a2b-7c3d-9e4f-a0b1c2d3e4f5"); // UUIDv7 shape
+ var frame = new CaptureResult { PointId = id }.EncodeFrame(0);
+ // §5.3: pointId at offset 36, most-significant byte first.
+ Assert.Equal(0x01, frame[36]);
+ Assert.Equal(0x8f, frame[37]);
+ Assert.Equal(0xf5, frame[51]);
+ Assert.Equal(id, CaptureResult.DecodeFrame(frame).PointId);
+ }
+
+ [Fact]
+ public void CaptureResult_roundtrips_position_and_accuracy()
+ {
+ var r = new CaptureResult
+ {
+ CaptureSeq = 7,
+ Outcome = CaptureOutcome.Stored,
+ Reason = ReasonCode.Ok,
+ FixType = FixType.RtkFixed,
+ Lat = 45.7649321,
+ Lon = 4.8354792,
+ OrthometricHeightM = 172.418,
+ HrmsM = 0.008,
+ VrmsM = 0.015,
+ Utc = DateTimeOffset.FromUnixTimeMilliseconds(1_760_000_000_000),
+ PointId = Guid.NewGuid(),
+ };
+ var back = CaptureResult.DecodeFrame(r.EncodeFrame(0));
+ Assert.Equal(IfLoc.CaptureResultFrameLen, r.EncodeFrame(0).Length);
+ Assert.Equal(r.CaptureSeq, back.CaptureSeq);
+ Assert.Equal(r.Outcome, back.Outcome);
+ Assert.Equal(r.FixType, back.FixType);
+ Assert.Equal(45.7649321, back.Lat!.Value, 7);
+ Assert.Equal(4.8354792, back.Lon!.Value, 7);
+ Assert.Equal(172.418, back.OrthometricHeightM!.Value, 3);
+ Assert.Equal(0.008, back.HrmsM!.Value, 3);
+ Assert.Equal(0.015, back.VrmsM!.Value, 3);
+ Assert.Equal(r.Utc, back.Utc);
+ Assert.Equal(r.PointId, back.PointId);
+ }
+
+ [Fact]
+ public void Rejected_result_carries_reason_and_no_position()
+ {
+ var r = new CaptureResult
+ {
+ CaptureSeq = 3,
+ Outcome = CaptureOutcome.Rejected,
+ Reason = ReasonCode.HrmsExceeded,
+ FixType = FixType.RtkFloat,
+ Lat = null, Lon = null, OrthometricHeightM = null,
+ };
+ var back = CaptureResult.DecodeFrame(r.EncodeFrame(0));
+ Assert.Equal(CaptureOutcome.Rejected, back.Outcome);
+ Assert.Equal(ReasonCode.HrmsExceeded, back.Reason);
+ Assert.Null(back.Lat);
+ Assert.Null(back.Lon);
+ Assert.Equal(Guid.Empty, back.PointId);
+ }
+
+ ///
+ /// End-to-end capture round-trip over the loopback link: the simulator replays a
+ /// session and raises capture-triggers; a stand-in App decodes each trigger and writes
+ /// back a capture-result gated on accuracy. Asserts every trigger is answered exactly
+ /// once (no silent-failure) and correlation holds by captureSeq.
+ ///
+ [Fact]
+ public async Task Capture_round_trip_answers_every_trigger_exactly_once()
+ {
+ var link = new LoopbackLink();
+ var sim = new LocatorSimulator(link);
+
+ int telemetrySeen = 0;
+ int triggersSeen = 0;
+
+ // Stand-in App: consume telemetry, and on each trigger apply a simple accuracy gate
+ // and write back a capture-result. This is what Field Logger's Locator Driver does.
+ link.ToApp += frame =>
+ {
+ switch (Frames.PeekType(frame))
+ {
+ case MessageType.Telemetry:
+ _ = Telemetry.DecodePayload(frame.AsSpan(IfLoc.HeaderLen, IfLoc.TelemetryPayloadLen));
+ telemetrySeen++;
+ break;
+
+ case MessageType.CaptureTrigger:
+ triggersSeen++;
+ var trig = CaptureTrigger.DecodeFrame(frame);
+ // Alternate a good fix and an out-of-spec fix to exercise both outcomes.
+ bool good = trig.CaptureSeq % 2 == 1;
+ var result = good
+ ? new CaptureResult
+ {
+ CaptureSeq = trig.CaptureSeq,
+ Outcome = CaptureOutcome.Stored,
+ Reason = ReasonCode.Ok,
+ FixType = FixType.RtkFixed,
+ Lat = 45.76 + trig.CaptureSeq * 1e-5,
+ Lon = 4.83,
+ OrthometricHeightM = 170 + trig.Snapshot.DepthMeters ?? 170,
+ HrmsM = 0.009,
+ VrmsM = 0.014,
+ Utc = DateTimeOffset.UtcNow,
+ PointId = Guid.NewGuid(),
+ }
+ : new CaptureResult
+ {
+ CaptureSeq = trig.CaptureSeq,
+ Outcome = CaptureOutcome.Rejected,
+ Reason = ReasonCode.HrmsExceeded,
+ FixType = FixType.RtkFloat,
+ };
+ link.WriteToLocator(result.EncodeFrame((ushort)(1000 + trig.CaptureSeq)));
+ break;
+ }
+ };
+
+ var captureAt = new HashSet { 5, 17, 42, 88 };
+ var report = await sim.ReplayAsync(frames: 100, captureAtFrames: captureAt, realTime: false);
+
+ Assert.Equal(100, telemetrySeen);
+ Assert.Equal(captureAt.Count, triggersSeen);
+ Assert.Equal(captureAt.Count, report.CaptureTriggersEmitted);
+ Assert.Equal(captureAt.Count, report.CaptureResultsReceived);
+ Assert.Empty(report.PendingCaptures); // SRS-LOG-7: no silent drop
+
+ // Every result correlates to a distinct trigger, and both outcomes occurred.
+ Assert.Equal(captureAt.Count, report.Results.Select(r => r.CaptureSeq).Distinct().Count());
+ Assert.Contains(report.Results, r => r.Outcome == CaptureOutcome.Stored);
+ Assert.Contains(report.Results, r => r.Outcome == CaptureOutcome.Rejected);
+ foreach (var r in report.Results.Where(r => r.Outcome == CaptureOutcome.Stored))
+ Assert.NotEqual(Guid.Empty, r.PointId);
+ }
+}
diff --git a/tests/IfLoc.Sim/Frames.cs b/tests/IfLoc.Sim/Frames.cs
new file mode 100644
index 0000000..8426a9f
--- /dev/null
+++ b/tests/IfLoc.Sim/Frames.cs
@@ -0,0 +1,216 @@
+using System.Buffers.Binary;
+
+namespace IfLoc.Sim;
+
+// Byte-accurate codec for the frozen IF-LOC v1.0 frames.
+// All multi-byte integers little-endian except pointId (RFC 4122 big-endian, §5.3).
+
+/// §4 telemetry payload — the locate data dictionary.
+public sealed record Telemetry
+{
+ public uint LocatorUptimeMs { get; init; }
+ /// Depth in metres (resolution 0.01 m). Null = no depth (sentinel on wire).
+ public double? DepthMeters { get; init; }
+ /// Signal current in mA. Null = invalid.
+ public ushort? SignalCurrentMa { get; init; }
+ /// Frequency in Hz. Null = n/a.
+ public uint? FrequencyHz { get; init; }
+ public LocateMode Mode { get; init; } = LocateMode.Single;
+ public SignalType SignalType { get; init; } = SignalType.Active;
+ public byte GainDb { get; init; }
+ public byte SignalLevel { get; init; }
+ public byte DistortionQualityPct { get; init; }
+ public byte SignalDirection { get; init; }
+ public byte CompassAngleDeg { get; init; }
+ /// Guidance offset −1000..+1000 (negative = left). Null = n/a.
+ public short? GuidanceOffset { get; init; }
+ public WarningFlags Warnings { get; init; }
+ public UtilityType Utility { get; init; } = UtilityType.None;
+ public byte BatteryPercent { get; init; }
+ public StatusFlags Status { get; init; }
+
+ /// Encodes the 28-byte payload (frame offsets 4..31).
+ public byte[] EncodePayload()
+ {
+ var p = new byte[IfLoc.TelemetryPayloadLen];
+ var s = p.AsSpan();
+ BinaryPrimitives.WriteUInt32LittleEndian(s[0..], LocatorUptimeMs); // 4
+ BinaryPrimitives.WriteInt16LittleEndian(s[4..], DepthMeters is { } d ? (short)Math.Round(d * 100) : IfLoc.DepthNoValue); // 8
+ BinaryPrimitives.WriteUInt16LittleEndian(s[6..], SignalCurrentMa ?? IfLoc.CurrentInvalid); // 10
+ BinaryPrimitives.WriteUInt32LittleEndian(s[8..], FrequencyHz ?? IfLoc.FrequencyNa); // 12
+ s[12] = (byte)Mode; // 16
+ s[13] = (byte)SignalType; // 17
+ s[14] = GainDb; // 18
+ s[15] = SignalLevel; // 19
+ s[16] = DistortionQualityPct; // 20
+ s[17] = SignalDirection; // 21
+ s[18] = CompassAngleDeg; // 22
+ s[19] = 0; // 23 reserved
+ BinaryPrimitives.WriteInt16LittleEndian(s[20..], GuidanceOffset ?? IfLoc.GuidanceNa); // 24
+ BinaryPrimitives.WriteUInt16LittleEndian(s[22..], (ushort)Warnings); // 26
+ s[24] = (byte)Utility; // 28
+ s[25] = BatteryPercent; // 29
+ s[26] = (byte)Status; // 30
+ s[27] = 0; // 31 reserved
+ return p;
+ }
+
+ /// Decodes a 28-byte payload (frame offsets 4..31).
+ public static Telemetry DecodePayload(ReadOnlySpan p)
+ {
+ if (p.Length < IfLoc.TelemetryPayloadLen)
+ throw new ArgumentException($"telemetry payload must be {IfLoc.TelemetryPayloadLen} bytes");
+ short depth = BinaryPrimitives.ReadInt16LittleEndian(p[4..]);
+ ushort cur = BinaryPrimitives.ReadUInt16LittleEndian(p[6..]);
+ uint freq = BinaryPrimitives.ReadUInt32LittleEndian(p[8..]);
+ short guid = BinaryPrimitives.ReadInt16LittleEndian(p[20..]);
+ return new Telemetry
+ {
+ LocatorUptimeMs = BinaryPrimitives.ReadUInt32LittleEndian(p[0..]),
+ DepthMeters = depth == IfLoc.DepthNoValue ? null : depth / 100.0,
+ SignalCurrentMa = cur == IfLoc.CurrentInvalid ? null : cur,
+ FrequencyHz = freq == IfLoc.FrequencyNa ? null : freq,
+ Mode = (LocateMode)p[12],
+ SignalType = (SignalType)p[13],
+ GainDb = p[14],
+ SignalLevel = p[15],
+ DistortionQualityPct = p[16],
+ SignalDirection = p[17],
+ CompassAngleDeg = p[18],
+ GuidanceOffset = guid == IfLoc.GuidanceNa ? null : guid,
+ Warnings = (WarningFlags)BinaryPrimitives.ReadUInt16LittleEndian(p[22..]),
+ Utility = (UtilityType)p[24],
+ BatteryPercent = p[25],
+ Status = (StatusFlags)p[26],
+ };
+ }
+
+ /// Encodes a full 32-byte TELEMETRY frame (header + payload).
+ public byte[] EncodeFrame(ushort seq)
+ {
+ var f = new byte[IfLoc.TelemetryFrameLen];
+ Frames.WriteHeader(f, MessageType.Telemetry, seq);
+ EncodePayload().CopyTo(f.AsSpan(IfLoc.HeaderLen));
+ return f;
+ }
+}
+
+/// §5.1 capture-trigger frame (locator → app).
+public sealed record CaptureTrigger
+{
+ public ushort CaptureSeq { get; init; }
+ public TriggerType TriggerType { get; init; }
+ public required Telemetry Snapshot { get; init; }
+
+ public byte[] EncodeFrame(ushort seq)
+ {
+ var f = new byte[IfLoc.CaptureTriggerFrameLen];
+ Frames.WriteHeader(f, MessageType.CaptureTrigger, seq);
+ BinaryPrimitives.WriteUInt16LittleEndian(f.AsSpan(4), CaptureSeq); // 4
+ f[6] = (byte)TriggerType; // 6
+ f[7] = 0; // 7 reserved
+ Snapshot.EncodePayload().CopyTo(f.AsSpan(8)); // 8..35
+ return f;
+ }
+
+ public static CaptureTrigger DecodeFrame(ReadOnlySpan f)
+ {
+ Frames.Expect(f, MessageType.CaptureTrigger, IfLoc.CaptureTriggerFrameLen);
+ return new CaptureTrigger
+ {
+ CaptureSeq = BinaryPrimitives.ReadUInt16LittleEndian(f[4..]),
+ TriggerType = (TriggerType)f[6],
+ Snapshot = Telemetry.DecodePayload(f.Slice(8, IfLoc.TelemetryPayloadLen)),
+ };
+ }
+}
+
+/// §5.2 capture-result frame (app → locator).
+public sealed record CaptureResult
+{
+ public ushort CaptureSeq { get; init; }
+ public CaptureOutcome Outcome { get; init; }
+ public ReasonCode Reason { get; init; }
+ public FixType FixType { get; init; }
+ /// WGS84 latitude in degrees. Null = no position.
+ public double? Lat { get; init; }
+ public double? Lon { get; init; }
+ /// Orthometric height in metres. Null = no position.
+ public double? OrthometricHeightM { get; init; }
+ /// Horizontal RMS (1σ) in metres. Null = unknown.
+ public double? HrmsM { get; init; }
+ public double? VrmsM { get; init; }
+ /// Position epoch, UTC.
+ public DateTimeOffset? Utc { get; init; }
+ /// Stored point UUIDv7. All-zero on REJECTED.
+ public Guid PointId { get; init; }
+
+ public byte[] EncodeFrame(ushort seq)
+ {
+ var f = new byte[IfLoc.CaptureResultFrameLen];
+ var s = f.AsSpan();
+ Frames.WriteHeader(f, MessageType.CaptureResult, seq);
+ BinaryPrimitives.WriteUInt16LittleEndian(s[4..], CaptureSeq);
+ s[6] = (byte)Outcome;
+ s[7] = (byte)Reason;
+ s[8] = (byte)FixType;
+ s[9] = 0;
+ BinaryPrimitives.WriteInt32LittleEndian(s[10..], Lat is { } la ? (int)Math.Round(la * 1e7) : IfLoc.PositionNoValue);
+ BinaryPrimitives.WriteInt32LittleEndian(s[14..], Lon is { } lo ? (int)Math.Round(lo * 1e7) : IfLoc.PositionNoValue);
+ BinaryPrimitives.WriteInt32LittleEndian(s[18..], OrthometricHeightM is { } h ? (int)Math.Round(h * 1000) : IfLoc.PositionNoValue);
+ BinaryPrimitives.WriteUInt16LittleEndian(s[22..], HrmsM is { } hr ? (ushort)Math.Round(hr * 1000) : IfLoc.RmsUnknown);
+ BinaryPrimitives.WriteUInt16LittleEndian(s[24..], VrmsM is { } vr ? (ushort)Math.Round(vr * 1000) : IfLoc.RmsUnknown);
+ BinaryPrimitives.WriteUInt16LittleEndian(s[26..], 0); // reserved
+ BinaryPrimitives.WriteUInt64LittleEndian(s[28..], Utc is { } t ? (ulong)t.ToUnixTimeMilliseconds() : 0);
+ PointId.TryWriteBytes(s.Slice(36, 16), bigEndian: true, out _); // §5.3 RFC 4122 network order
+ return f;
+ }
+
+ public static CaptureResult DecodeFrame(ReadOnlySpan f)
+ {
+ Frames.Expect(f, MessageType.CaptureResult, IfLoc.CaptureResultFrameLen);
+ int lat = BinaryPrimitives.ReadInt32LittleEndian(f[10..]);
+ int lon = BinaryPrimitives.ReadInt32LittleEndian(f[14..]);
+ int h = BinaryPrimitives.ReadInt32LittleEndian(f[18..]);
+ ushort hr = BinaryPrimitives.ReadUInt16LittleEndian(f[22..]);
+ ushort vr = BinaryPrimitives.ReadUInt16LittleEndian(f[24..]);
+ ulong ms = BinaryPrimitives.ReadUInt64LittleEndian(f[28..]);
+ return new CaptureResult
+ {
+ CaptureSeq = BinaryPrimitives.ReadUInt16LittleEndian(f[4..]),
+ Outcome = (CaptureOutcome)f[6],
+ Reason = (ReasonCode)f[7],
+ FixType = (FixType)f[8],
+ Lat = lat == IfLoc.PositionNoValue ? null : lat / 1e7,
+ Lon = lon == IfLoc.PositionNoValue ? null : lon / 1e7,
+ OrthometricHeightM = h == IfLoc.PositionNoValue ? null : h / 1000.0,
+ HrmsM = hr == IfLoc.RmsUnknown ? null : hr / 1000.0,
+ VrmsM = vr == IfLoc.RmsUnknown ? null : vr / 1000.0,
+ Utc = ms == 0 ? null : DateTimeOffset.FromUnixTimeMilliseconds((long)ms),
+ PointId = new Guid(f.Slice(36, 16), bigEndian: true),
+ };
+ }
+}
+
+/// Shared header helpers.
+public static class Frames
+{
+ public static void WriteHeader(Span f, MessageType type, ushort seq)
+ {
+ f[0] = IfLoc.FrameVersion;
+ f[1] = (byte)type;
+ BinaryPrimitives.WriteUInt16LittleEndian(f[2..], seq);
+ }
+
+ public static MessageType PeekType(ReadOnlySpan f) => (MessageType)f[1];
+
+ public static void Expect(ReadOnlySpan f, MessageType type, int fixedLen)
+ {
+ if (f.Length < fixedLen)
+ throw new ArgumentException($"{type} frame must be ≥ {fixedLen} bytes, got {f.Length}");
+ if (f[0] != IfLoc.FrameVersion)
+ throw new ArgumentException($"unsupported frameVersion 0x{f[0]:X2}");
+ if ((MessageType)f[1] != type)
+ throw new ArgumentException($"expected {type}, got 0x{f[1]:X2}");
+ }
+}
diff --git a/tests/IfLoc.Sim/IfLoc.Sim.csproj b/tests/IfLoc.Sim/IfLoc.Sim.csproj
new file mode 100644
index 0000000..dd31fef
--- /dev/null
+++ b/tests/IfLoc.Sim/IfLoc.Sim.csproj
@@ -0,0 +1,19 @@
+
+
+
+
+
+ net9.0
+ enable
+ enable
+ latest
+ IfLoc.Sim
+
+
+
diff --git a/tests/IfLoc.Sim/LocatorSimulator.cs b/tests/IfLoc.Sim/LocatorSimulator.cs
new file mode 100644
index 0000000..f20284e
--- /dev/null
+++ b/tests/IfLoc.Sim/LocatorSimulator.cs
@@ -0,0 +1,145 @@
+namespace IfLoc.Sim;
+
+///
+/// A bidirectional in-memory link modelling the bonded BLE GATT session between the
+/// locator (peripheral) and the App (central). Frames pushed toward the App surface on
+/// ; frames the App writes back surface on .
+/// A transport-agnostic stand-in for the real GATT characteristics — a TCP or real-BLE
+/// implementation can replace it without touching the simulator or codec.
+///
+public sealed class LoopbackLink
+{
+ /// Locator → App (Telemetry / Event characteristics).
+ public event Action? ToApp;
+ /// App → Locator (Command / Capture Result characteristics).
+ public event Action? ToLocator;
+
+ public void PublishToApp(byte[] frame) => ToApp?.Invoke(frame);
+ public void WriteToLocator(byte[] frame) => ToLocator?.Invoke(frame);
+}
+
+/// Result of a replayed session, for test assertions.
+public sealed record SessionReport
+{
+ public int TelemetryFramesEmitted { get; init; }
+ public int CaptureTriggersEmitted { get; init; }
+ public int CaptureResultsReceived { get; init; }
+ public IReadOnlyList Results { get; init; } = Array.Empty();
+ /// Trigger captureSeqs still awaiting a result — must be empty (no silent-failure, SRS-LOG-7).
+ public IReadOnlyList PendingCaptures { get; init; } = Array.Empty();
+}
+
+///
+/// Replays a canned locate session over the IF-LOC contract: emits telemetry at the
+/// configured rate, raises capture-triggers at scripted frames, and consumes the App's
+/// capture-results — correlating each result to its trigger by captureSeq. Built purely
+/// from the frozen data dictionary so App development is unblocked without firmware
+/// (Risk R-3).
+///
+public sealed class LocatorSimulator
+{
+ private readonly LoopbackLink _link;
+ private ushort _seq;
+ private ushort _captureSeq;
+ private readonly HashSet _pending = new();
+ private readonly List _results = new();
+
+ public LocatorSimulator(LoopbackLink link)
+ {
+ _link = link;
+ _link.ToLocator += OnAppFrame;
+ }
+
+ private void OnAppFrame(byte[] frame)
+ {
+ if (frame.Length < IfLoc.HeaderLen) return; // §14 ignore malformed
+ if (Frames.PeekType(frame) != MessageType.CaptureResult) return;
+ var result = CaptureResult.DecodeFrame(frame);
+ _results.Add(result);
+ _pending.Remove(result.CaptureSeq);
+ }
+
+ ///
+ /// Emits telemetry frames, raising a capture-trigger at each
+ /// index in . When is true,
+ /// paces at for a live demo; otherwise emits as fast as possible
+ /// for deterministic tests. Returns once all frames are emitted (results may still be
+ /// arriving synchronously on the loopback).
+ ///
+ public async Task ReplayAsync(
+ int frames,
+ IReadOnlySet captureAtFrames,
+ double rateHz = 5.0,
+ bool realTime = false,
+ CancellationToken ct = default)
+ {
+ int triggers = 0;
+ uint uptime = 0;
+ int stepMs = (int)Math.Round(1000.0 / rateHz);
+
+ for (int i = 0; i < frames; i++)
+ {
+ ct.ThrowIfCancellationRequested();
+ var tele = BuildTelemetry(i, uptime);
+ _link.PublishToApp(tele.EncodeFrame(_seq++));
+
+ if (captureAtFrames.Contains(i))
+ {
+ var trigger = new CaptureTrigger
+ {
+ CaptureSeq = ++_captureSeq, // locator-initiated: high bit clear (§5.1)
+ TriggerType = TriggerType.ButtonSingle,
+ Snapshot = tele,
+ };
+ _pending.Add(trigger.CaptureSeq);
+ triggers++;
+ _link.PublishToApp(trigger.EncodeFrame(_seq++));
+ }
+
+ uptime += (uint)stepMs;
+ if (realTime) await Task.Delay(stepMs, ct).ConfigureAwait(false);
+ }
+
+ return new SessionReport
+ {
+ TelemetryFramesEmitted = frames,
+ CaptureTriggersEmitted = triggers,
+ CaptureResultsReceived = _results.Count,
+ Results = _results.ToArray(),
+ PendingCaptures = _pending.ToArray(),
+ };
+ }
+
+ ///
+ /// A deterministic, physically-plausible canned telemetry frame for step .
+ /// Sweeps depth, current, and signal across their ranges and exercises a warning flag
+ /// and a no-depth sentinel so consumers see the full dictionary.
+ ///
+ public static Telemetry BuildTelemetry(int i, uint uptimeMs)
+ {
+ bool noDepth = i % 20 == 19; // periodically drop depth (sentinel path)
+ var warn = WarningFlags.None;
+ if (i % 25 == 12) warn |= WarningFlags.Shallow;
+ if (i % 40 == 30) warn |= WarningFlags.Overload;
+
+ return new Telemetry
+ {
+ LocatorUptimeMs = uptimeMs,
+ DepthMeters = noDepth ? null : 0.50 + 0.01 * (i % 200), // 0.50 → 2.49 m
+ SignalCurrentMa = (ushort)(50 + (i % 300)),
+ FrequencyHz = 32768,
+ Mode = LocateMode.Twin,
+ SignalType = SignalType.Active,
+ GainDb = (byte)(40 + (i % 60)),
+ SignalLevel = (byte)(60 + (i % 40)),
+ DistortionQualityPct = (byte)(100 - (i % 15)),
+ SignalDirection = 1,
+ CompassAngleDeg = (byte)(i % 181),
+ GuidanceOffset = (short)(((i % 41) - 20) * 25), // −500 → +500, negative = left
+ Warnings = warn,
+ Utility = UtilityType.Water,
+ BatteryPercent = (byte)Math.Max(5, 100 - i / 10),
+ Status = StatusFlags.Locating,
+ };
+ }
+}
diff --git a/tests/IfLoc.Sim/Protocol.cs b/tests/IfLoc.Sim/Protocol.cs
new file mode 100644
index 0000000..b81cdff
--- /dev/null
+++ b/tests/IfLoc.Sim/Protocol.cs
@@ -0,0 +1,150 @@
+namespace IfLoc.Sim;
+
+// Reference constants for the frozen IF-LOC v1.0 contract.
+// Source of truth: meta/contracts/ble-device-interface.md. Keep the two in lockstep;
+// enum values and message-type ids are append-only once frozen.
+
+/// Wire message-type ids (frame header byte 1).
+public enum MessageType : byte
+{
+ Telemetry = 0x01,
+ CaptureTrigger = 0x02,
+ CaptureResult = 0x03,
+ Alert = 0x04,
+ Ack = 0x05,
+
+ CmdRequestSnapshot = 0x10,
+ CmdSetFrequency = 0x11,
+ CmdSetMode = 0x12,
+ CmdSetTelemetryRate = 0x13,
+ CmdTimeSync = 0x14,
+ CmdSetUtility = 0x15,
+
+ OtaBegin = 0x20,
+ OtaData = 0x21,
+ OtaEnd = 0x22,
+
+ UsageLogRequest = 0x30,
+ UsageLogRecord = 0x31,
+ UsageLogEnd = 0x32,
+
+ ClaimChallenge = 0x40,
+ ClaimAssertion = 0x41,
+ ClaimCertReq = 0x42,
+ ClaimCert = 0x43,
+}
+
+/// §4.1 locateMode.
+public enum LocateMode : byte
+{
+ Single = 0, Twin = 1, Null = 2, Sweep = 3, TwinSweep = 4, Omni = 5, TwinOmni = 6,
+ NotAvailable = 0xFF,
+}
+
+/// §4.2 signalType.
+public enum SignalType : byte
+{
+ Active = 0, LineDropActive = 1, Power = 2, GroupedPower = 3, Cathodic = 4,
+ Sonde = 5, Radio = 6, FaultFind = 7, NotAvailable = 0xFF,
+}
+
+/// §4.4 utilityType (APWA-aligned).
+public enum UtilityType : byte
+{
+ None = 0, Gas = 1, Power = 2, Communications = 3, Water = 4, Sewer = 5, Fiber = 6,
+ Other = 7, NotAvailable = 0xFF,
+}
+
+/// §4.3 warningFlags bitfield.
+[Flags]
+public enum WarningFlags : ushort
+{
+ None = 0,
+ Shallow = 1 << 0,
+ Overload = 1 << 1,
+ SwingTilt = 1 << 2,
+ DepthInvalid = 1 << 3,
+ CurrentInvalid = 1 << 4,
+ OutOfRange = 1 << 5,
+ DistortionHigh = 1 << 6,
+ LowBattery = 1 << 7,
+}
+
+/// §4.5 statusFlags bitfield.
+[Flags]
+public enum StatusFlags : byte
+{
+ None = 0,
+ Locating = 1 << 0,
+ MenuActive = 1 << 1,
+ TimeSynced = 1 << 2,
+ DepthModeAuto = 1 << 3,
+}
+
+/// §5.1 triggerType.
+public enum TriggerType : byte
+{
+ ButtonSingle = 0, ButtonHold = 1, OffsetRequest = 2, AppInitiated = 3,
+}
+
+/// §5.2 outcome — the deterministic capture outcome (SRS-LOG-7).
+public enum CaptureOutcome : byte
+{
+ Stored = 0, StoredFlagged = 1, Rejected = 2,
+}
+
+/// §5.4 reasonCode / gateStatus.
+public enum ReasonCode : byte
+{
+ Ok = 0,
+ FixTypeTooLow = 1,
+ HrmsExceeded = 2,
+ VrmsExceeded = 3,
+ CorrectionAgeExceeded = 4,
+ NoActiveTicket = 5,
+ ImuCalibrationInvalid = 6,
+ HeadingConfidenceLow = 7,
+ BufferFull = 8,
+ NoPosition = 9,
+ WaiverRequired = 10,
+ Other = 255,
+}
+
+/// §5.2 fixType (GGA-quality mapping; 3 reserved).
+public enum FixType : byte
+{
+ NoFix = 0, Autonomous = 1, Dgps = 2, RtkFixed = 4, RtkFloat = 5,
+}
+
+/// Frozen wire constants and sentinels.
+public static class IfLoc
+{
+ public const byte FrameVersion = 0x01;
+
+ // Fixed frame lengths (Appendix A).
+ public const int HeaderLen = 4;
+ public const int TelemetryFrameLen = 32;
+ public const int TelemetryPayloadLen = 28; // offsets 4..31
+ public const int CaptureTriggerFrameLen = 36;
+ public const int CaptureResultFrameLen = 52;
+
+ // Sentinels (§1).
+ public const short DepthNoValue = unchecked((short)0x8000);
+ public const ushort CurrentInvalid = 0xFFFF;
+ public const uint FrequencyNa = 0xFFFFFFFF;
+ public const short GuidanceNa = unchecked((short)0x8000);
+ public const int PositionNoValue = unchecked((int)0x80000000);
+ public const ushort RmsUnknown = 0xFFFF;
+
+ // GATT (§2) — recommended UUIDs the App builds against.
+ public const string BaseUuidFormat = "A9E1{0:X4}-1B4C-4F9A-9B7E-2D6F0C3A5E11";
+ public static string LocateServiceUuid => string.Format(BaseUuidFormat, 0x0001);
+ public static string ProtocolInfoUuid => string.Format(BaseUuidFormat, 0x0002);
+ public static string TelemetryUuid => string.Format(BaseUuidFormat, 0x0003);
+ public static string EventUuid => string.Format(BaseUuidFormat, 0x0004);
+ public static string CommandUuid => string.Format(BaseUuidFormat, 0x0005);
+ public static string CaptureResultUuid => string.Format(BaseUuidFormat, 0x0006);
+ public static string LinkStateUuid => string.Format(BaseUuidFormat, 0x0007);
+ public static string BulkOtaUuid => string.Format(BaseUuidFormat, 0x0008);
+ public static string ClaimUuid => string.Format(BaseUuidFormat, 0x0009);
+}