# Phase 02 — BCD codec

Pure logic for encoding integers as DirectLOGIC BCD nibbles and decoding nibbles back. No I/O, no network, no Modbus framing. The codec exposed by this phase is what phase 04 plugs into the proxy.

**Depends on:** Phase 00 (csproj + options POCOs).
**Parallel-safe with:** Phase 01, Phase 03. (All work lives under `src/Mbproxy/Bcd/` and `tests/Mbproxy.Tests/Bcd/` — disjoint from sim harness and proxy plumbing.)

## Goal

A tiny, allocation-free codec library that:
- Encodes a non-negative `int` (capped at the width's range) to either one 16-bit raw register value or a `(low, high)` register pair for 32-bit BCD per the design's CDAB digit-layout rule.
- Decodes one or two raw register values back to an `int`.
- Resolves `Global + per-PLC Add - per-PLC Remove` into an **immutable per-PLC `BcdTagMap`** that the rewriter looks up by Modbus address in O(1).

The codec is the single source of BCD-encoding correctness in the system. Phase 04 must not reimplement any nibble math.

## Outputs

```
src/Mbproxy/Bcd/BcdCodec.cs           # static class: Encode16, Decode16, Encode32, Decode32
src/Mbproxy/Bcd/BcdTag.cs              # the public record (mirrors design.md exactly)
src/Mbproxy/Bcd/BcdTagMap.cs           # immutable, address-keyed lookup; describes per-PLC resolved tags
src/Mbproxy/Bcd/BcdTagMapBuilder.cs    # resolves global + Add - Remove into a map; runs validation
src/Mbproxy/Bcd/BcdValidationError.cs  # enum + ValidationResult record

tests/Mbproxy.Tests/Bcd/BcdCodecTests.cs
tests/Mbproxy.Tests/Bcd/BcdTagMapBuilderTests.cs
```

No other files. The proxy plumbing layer doesn't exist yet and isn't touched.

## Tasks

1. **`BcdTag.cs`** — `public sealed record BcdTag(ushort Address, byte Width)` with a static factory `Create(ushort, byte)` that throws on `Width != 16 && Width != 32`. This record is the type phases 04 / 06 / 07 will use.
2. **`BcdCodec.cs`** — `internal static class` with four pure methods. Internal because the proxy is the only consumer; nothing else in the assembly should call these.
   - `static ushort Encode16(int value)` — value in `[0, 9999]`; produces the 16-bit BCD register, e.g. `1234 → 0x1234`. Throws `ArgumentOutOfRangeException` if value is out of range.
   - `static int Decode16(ushort raw)` — inverse. If any nibble is `>= 0xA`, return a `int.MinValue` sentinel? No — throw `FormatException` with the raw value in the message. The rewriter catches this and surfaces a `mbproxy.rewrite.invalid_bcd` event (event name added in phase 04).
   - `static (ushort low, ushort high) Encode32(int value)` — value in `[0, 99_999_999]`; produces the CDAB pair, where `low` = low 4 BCD digits (least-significant) and `high` = high 4 BCD digits (most-significant). Decoded decimal = `high * 10000 + low_as_bcd_decoded`. Throws if out of range.
   - `static int Decode32(ushort low, ushort high)` — inverse. Throws `FormatException` if either word has a bad nibble.
3. **`BcdTagMap.cs`** — `public sealed class BcdTagMap` wrapping a frozen address-keyed dictionary. Methods:
   - `static BcdTagMap Empty { get; }`
   - `bool TryGet(ushort address, out BcdTag tag)` — O(1) lookup.
   - `bool TryGetForRange(ushort startAddress, ushort qty, out IEnumerable<(int offset, BcdTag tag)> hits)` — returns every BCD tag whose register footprint intersects `[startAddress, startAddress+qty)`. Offsets are relative to `startAddress`. Used by the rewriter to know which slots in a multi-register PDU to touch.
   - `int Count { get; }`, `IEnumerable<BcdTag> All { get; }` — for telemetry / status page.
4. **`BcdTagMapBuilder.cs`** — given `BcdTagListOptions Global` and `PlcBcdOverrides? perPlc`, produce a `(BcdTagMap, ValidationResult)`. Validation rules from design.md:
   - Reject duplicate addresses within the resolved list (Add+Global after Remove).
   - Reject 32-bit entries whose high register (`Address+1`) collides with any other entry's address (16-bit or 32-bit).
   - Warn on `Remove` entries that don't match any address in Global (this is not a failure; the warning rides on `ValidationResult.Warnings`).
   - Reject `Width` values other than 16/32 (defensive; phase 00's `IValidateOptions` should already have caught this, but the builder is the last line of defence).
5. **`BcdValidationError.cs`** — `public enum BcdValidationError { DuplicateAddress, OverlappingHighRegister, InvalidWidth }`. `public sealed record ValidationResult(BcdTagMap Map, IReadOnlyList<BcdError> Errors, IReadOnlyList<BcdWarning> Warnings)`. Errors fail the build; warnings ride along.

## Public surface declared in this phase

```csharp
namespace Mbproxy.Bcd;

public sealed record BcdTag(ushort Address, byte Width) {
    public static BcdTag Create(ushort address, byte width);
    public bool IsThirtyTwoBit => Width == 32;
    public ushort HighRegister => (ushort)(Address + 1);   // throws if Width != 32
}

public sealed class BcdTagMap {
    public static BcdTagMap Empty { get; }
    public int Count { get; }
    public IEnumerable<BcdTag> All { get; }
    public bool TryGet(ushort address, out BcdTag tag);
    public bool TryGetForRange(ushort startAddress, ushort qty, out IReadOnlyList<RangeHit> hits);
}

public readonly record struct RangeHit(int OffsetWords, BcdTag Tag);

public static class BcdTagMapBuilder {
    public static ValidationResult Build(BcdTagListOptions global, PlcBcdOverrides? perPlc);
}

public sealed record ValidationResult(
    BcdTagMap Map,
    IReadOnlyList<BcdError> Errors,
    IReadOnlyList<BcdWarning> Warnings);

public sealed record BcdError(BcdValidationError Kind, string Message, ushort? Address);
public sealed record BcdWarning(string Message, ushort? Address);
public enum BcdValidationError { DuplicateAddress, OverlappingHighRegister, InvalidWidth }
```

```csharp
namespace Mbproxy.Bcd;
internal static class BcdCodec {
    public static ushort Encode16(int value);
    public static int Decode16(ushort raw);
    public static (ushort low, ushort high) Encode32(int value);
    public static int Decode32(ushort low, ushort high);
}
```

## Tests required

### Unit (`Category = Unit`)

`BcdCodecTests` (≥ 16 tests):

1. `Encode16_1234_Returns_0x1234`
2. `Encode16_0_Returns_0x0000`
3. `Encode16_9999_Returns_0x9999`
4. `Encode16_10000_Throws_OutOfRange`
5. `Encode16_Negative_Throws_OutOfRange`
6. `Decode16_0x1234_Returns_1234`
7. `Decode16_0x0000_Returns_0`
8. `Decode16_0x9999_Returns_9999`
9. `Decode16_0x123A_Throws_Format` — bad nibble `A`.
10. `Encode32_12345678_Returns_LowHigh_5678_1234` — verify `low = 0x5678`, `high = 0x1234`.
11. `Encode32_0_Returns_LowHigh_0_0`
12. `Encode32_99999999_Returns_LowHigh_9999_9999`
13. `Encode32_100000000_Throws_OutOfRange`
14. `Decode32_LowHigh_5678_1234_Returns_12345678`
15. `Decode32_BadNibble_InLow_Throws`
16. `Decode32_BadNibble_InHigh_Throws`
17. `RoundTrip16_AllValuesUnder10000` — `[Theory]` with `[InlineData]` for boundary values; for the dense check use `[Theory] [MemberData]` enumerating every 100th value. The codec must be `Decode16(Encode16(v)) == v`.

`BcdTagMapBuilderTests` (≥ 10 tests):

1. `Build_EmptyGlobal_EmptyOverride_ReturnsEmptyMap`
2. `Build_GlobalOnly_PopulatesMap`
3. `Build_PerPlcAdd_AppendsToGlobal`
4. `Build_PerPlcRemove_DropsFromGlobal`
5. `Build_AddOverrideSameAddressAsGlobal_AddWidthWins`
6. `Build_DuplicateAddressInGlobal_ReturnsDuplicateAddressError`
7. `Build_32BitHighRegOverlaps16BitGlobal_ReturnsOverlappingHighRegisterError`
8. `Build_Remove_OfNonExistentAddress_ReturnsWarning_NotError`
9. `Build_InvalidWidth_ReturnsInvalidWidthError`
10. `Map_TryGetForRange_ReturnsAllHits_InOrder` — covers full overlap, partial overlap (low only, high only), and no overlap.

### E2E (Category = E2E)

None. The codec is pure logic.

## Phase gate

- [ ] Zero-warnings build.
- [ ] `dotnet test --filter Category=Unit` — all green, ≥ 26 new tests.
- [ ] `BcdCodec` is `internal`; nothing outside `Mbproxy.Bcd` calls it directly.
- [ ] `BcdTagMap` has zero allocations on `TryGet` and on the hot `TryGetForRange` path (verify via a microbench note in the test file's docstring; no benchmark project added).
- [ ] [`../design.md`](../design.md) → "BCD tag shape" matches the public record exactly; if the spec drifted during implementation, update design.md in this PR.

## Out of scope

- Signed BCD. Design explicitly excludes it.
- Half-byte / "BCD with sign nibble" variants used by some DL-family math instructions. Not in the design's tag shape.
- The actual PDU-byte-level rewriting (FC parsing, MBAP framing). That's phase 04.
- Telemetry counters. The codec exposes nothing to counters; phase 04 instruments the rewrite pipeline that USES the codec.

## Notes for the subagent

- The DirectLOGIC CDAB digit layout is the most-likely-to-confuse part of this phase. Re-read [`../design.md`](../design.md) → "BCD tag shape" and [`../../DL260/dl205.md`](../../DL260/dl205.md) → "Word Order" before implementing `Encode32`/`Decode32`. The seeded marker in `dl205.json` for the float32 case (`HR[1056]=0x0000, HR[1057]=0x3FC0` for IEEE 1.5) confirms low-word-first; the BCD-32 case is the same word order with BCD nibble semantics inside each word.
- `BcdTagMapBuilder` is single-shot — given inputs, produce a map. There is NO `IObservable<BcdTagMap>` here. Phase 06 owns reload-driven rebuilds and just calls `Build` again.
- `TryGetForRange` is on the hot path for FC03/04 responses. Implementation should pre-bucket BCD tags by 256-register window if it makes the lookup faster, but only if a microbench shows a real win. Don't preoptimise.
