docs(D.2b-B): B-Wire spec — inventory wire layer
Design/spec for B-Wire (follows B-Controller c38f098). Root cause found:
the burden binding already reads wire EncumbranceVal (PropertyInt 5) but
the value never arrives — login PD parses the player's int table then
drops it, the live 0x02CD (PrivateUpdatePropertyInt, no guid) is unparsed,
and ObjectTableWiring gates all non-UiEffects ints out.
Scope (user chose the full wire pass): player-property delivery (login PD
bundle upsert + 0x02CD parse + loosen the apply gate, retiring AP-48/AP-49),
latent-bug fixes (0x0022 4th field, 0x00A0 error), new C→S builders
(DropItem 0x001B / GetAndWieldItem 0x001A / NoLongerViewingContents 0x0195),
new S→C parsers (ViewContents 0x0196 GameEvent; SetStackSize 0x0197 +
InventoryRemoveObject 0x0024 GameMessages), and WireAll registration.
Opcodes pinned against ACE GameMessageOpcode.cs / GameActionType.cs; every
format requires grep-named → cross-ref → pseudocode → port + conformance.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1230c07af3
commit
5737951a19
1 changed files with 272 additions and 0 deletions
272
docs/superpowers/specs/2026-06-21-d2b-inventory-wire-design.md
Normal file
272
docs/superpowers/specs/2026-06-21-d2b-inventory-wire-design.md
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
# D.2b-B B-Wire — inventory wire layer (design / spec)
|
||||||
|
|
||||||
|
**Date:** 2026-06-21
|
||||||
|
**Phase:** D.2b inventory, sub-phase **B-Wire** (follows B-Controller, shipped `c38f098`).
|
||||||
|
**Branch:** `claude/hopeful-maxwell-214a12`.
|
||||||
|
**Predecessor handoff:** `docs/research/2026-06-21-d2b-bcontroller-shipped-next-handoff.md` §4(a).
|
||||||
|
**Research oracle:** `docs/research/2026-06-16-inventory-deep-dive.md` §4 (the wire catalog,
|
||||||
|
with CONFIRMED ACE `file:line` + holtburger fixtures per format).
|
||||||
|
**Status:** approved design → write plan next.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal / non-goals
|
||||||
|
|
||||||
|
**Goal.** Make the inventory **wire layer** retail-faithful and complete:
|
||||||
|
|
||||||
|
1. Deliver the player's **server-authoritative properties** to the player's `ClientObject`,
|
||||||
|
so the burden bar reads the wire `EncumbranceVal` instead of a client-side estimate.
|
||||||
|
(Retires divergence **AP-48** and **AP-49**.)
|
||||||
|
2. Fix two **latent parse bugs** in existing inbound handlers (dropped fields).
|
||||||
|
3. Add the missing inventory **builders** (C→S) and **parsers** (S→C) so the next
|
||||||
|
sub-phases (B-Drag, container-open) have their wire ready and tested.
|
||||||
|
|
||||||
|
**Non-goals (deferred to the consuming sub-phase).** The *behavior* that calls these
|
||||||
|
builders/parsers: drag-release → DropItem/GetAndWieldItem (B-Drag), opening a side-pack /
|
||||||
|
ground container → ViewContents into a second `UiItemList` (container-open), speculative-move
|
||||||
|
rollback UI (B-Drag). B-Wire lands wire + parsers + tests + minimal table-apply; it does not
|
||||||
|
build the UI actions. A parser with no UI consumer yet registers as **parsed-and-applied to the
|
||||||
|
object table** (or parsed-and-logged where there is genuinely nothing to apply) so the wire is
|
||||||
|
correct and the later phase only adds behavior.
|
||||||
|
|
||||||
|
**Mandatory workflow (binding on the implementer + any subagent).** Every wire format goes
|
||||||
|
through **grep-named → cross-ref (ACE + holtburger) → pseudocode → port → conformance test**.
|
||||||
|
The deep-dive §4 already did the cross-ref and cites the ACE writer `file:line` + holtburger
|
||||||
|
fixture for each format; re-confirm each against `docs/research/named-retail/` before porting
|
||||||
|
(the named symbols to anchor on are listed per component below). Do not guess a field order.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Background — the root cause (why the burden bar is wrong today)
|
||||||
|
|
||||||
|
The burden binding is **already correct**: `InventoryController.RefreshBurden`
|
||||||
|
([InventoryController.cs:215](../../../src/AcDream.App/UI/Layout/InventoryController.cs)) reads
|
||||||
|
`EncumbranceVal` (PropertyInt 5) from `_objects.Get(playerGuid).Properties.Ints` and only falls
|
||||||
|
back to `ClientObjectTable.SumCarriedBurden` when it is absent. The value **never arrives**.
|
||||||
|
Three independent gaps, all confirmed in source:
|
||||||
|
|
||||||
|
1. **Login path.** `GameEventWiring`'s PlayerDescription (`0x0013`) handler
|
||||||
|
([GameEventWiring.cs:285](../../../src/AcDream.Core.Net/GameEventWiring.cs)) parses the player's
|
||||||
|
full PropertyInt table into a `PropertyBundle` (`PlayerDescriptionParser` → `bundle.Ints[5]`)
|
||||||
|
but **never applies that bundle to the player's `ClientObject`** — it extracts vitals /
|
||||||
|
attributes / skills / spells / enchantments and records item *membership*, then drops the
|
||||||
|
player's own property bundle. (The "PD is a membership manifest, not the data source" comment
|
||||||
|
at line 403 is about *items* — whose weenie data comes from CreateObject — and does **not**
|
||||||
|
apply to the player's own stats, which legitimately come from PD.)
|
||||||
|
2. **Live path.** Burden changes (pick up / drop) ride `PrivateUpdatePropertyInt (0x02CD)` —
|
||||||
|
the player's own object, **no guid on the wire**. acdream does not parse `0x02CD` at all (the
|
||||||
|
`PublicUpdatePropertyInt` 0x02CE doc-comment says exactly this).
|
||||||
|
3. **Apply gate.** Even parsed `0x02CE` updates only reach the table when
|
||||||
|
`Property == UiEffects` — [ObjectTableWiring.cs:28](../../../src/AcDream.Core.Net/ObjectTableWiring.cs)
|
||||||
|
hard-gates every other int out.
|
||||||
|
|
||||||
|
A **consumer-side** bug compounds this: `InventoryController.Concerns(o)` returns *false* for the
|
||||||
|
player's own object (its `ContainerId`/`WielderId` are not the player guid), so even once
|
||||||
|
`EncumbranceVal` updates live, the burden bar would not refresh. Fixed in C1 below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Components
|
||||||
|
|
||||||
|
All wire code lives in `AcDream.Core.Net` (pure data, no GL). Tests in
|
||||||
|
`tests/AcDream.Core.Net.Tests` (+ a burden end-to-end test in `tests/AcDream.App.Tests`).
|
||||||
|
|
||||||
|
### C1 — Player-property delivery (the core; retires AP-48/AP-49)
|
||||||
|
|
||||||
|
**C1a — Login delivery.** In the PD handler ([GameEventWiring.cs:285](../../../src/AcDream.Core.Net/GameEventWiring.cs)),
|
||||||
|
after the existing attribute/skill/membership extraction, apply the parsed player `PropertyBundle`
|
||||||
|
to the player's `ClientObject`. Requires:
|
||||||
|
- A new `Func<uint> playerGuid` threaded into the GameEvent wiring (the local player's server
|
||||||
|
guid; `GameWindow._playerServerGuid`, already passed elsewhere).
|
||||||
|
- A new `ClientObjectTable.UpsertProperties(uint guid, PropertyBundle bundle)` — **create-if-absent
|
||||||
|
then merge** (PD may arrive before the player's own `CreateObject`; the existing `UpdateProperties`
|
||||||
|
no-ops on an unknown object, [ClientObjectTable.cs:152](../../../src/AcDream.Core/Items/ClientObjectTable.cs)).
|
||||||
|
The later `CreateObject` `Ingest` is already a merge-upsert, so either arrival order converges.
|
||||||
|
- Apply the **whole** bundle (Ints/Floats/Bools/Int64s/Strings/DataIds/InstanceIds), not a
|
||||||
|
whitelist — the bundle is dictionaries; storing the player's full property set is faithful
|
||||||
|
(retail keeps them on the ACCWeenieObject) and future-proofs Str/aug/etc. (Decision §4.1.)
|
||||||
|
|
||||||
|
**C1b — Live delivery.** New `PrivateUpdatePropertyInt (0x02CD)` GameMessage parser
|
||||||
|
(`src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs`, mirroring `PublicUpdatePropertyInt.cs`):
|
||||||
|
- Wire body: `u32 opcode(0x02CD), u8 seq, u32 property, i32 value` — **no guid** (size 13).
|
||||||
|
Seq is a single byte (same `ByteSequence` as 0x02CE; not honored, latest-wins, DR-4).
|
||||||
|
- `WorldSession` dispatches it (new `else if (op == PrivateUpdatePropertyInt.Opcode)` in the
|
||||||
|
GameMessage switch, [WorldSession.cs:954](../../../src/AcDream.Core.Net/WorldSession.cs)) →
|
||||||
|
new event `PlayerIntPropertyUpdated(uint Property, int Value)` (no guid — implicitly the player).
|
||||||
|
- `ObjectTableWiring.Wire` gains the `Func<uint> playerGuid` and subscribes
|
||||||
|
`PlayerIntPropertyUpdated` → `table.UpdateIntProperty(playerGuid(), property, value)`.
|
||||||
|
- Anchor: named-retail `ACCWeenieObject` int-property apply (the client-side `0x02CD` consumer);
|
||||||
|
ACE writer `GameMessagePrivateUpdatePropertyInt`.
|
||||||
|
|
||||||
|
**C1c — Apply gate.** Loosen [ObjectTableWiring.cs:26-30](../../../src/AcDream.Core.Net/ObjectTableWiring.cs)
|
||||||
|
so the `0x02CE` (`ObjectIntPropertyUpdated`) path applies **all** ints via
|
||||||
|
`table.UpdateIntProperty(u.Guid, u.Property, u.Value)` (which stores in the bundle and still
|
||||||
|
mirrors UiEffects→`Effects`). (Decision §4.2.)
|
||||||
|
|
||||||
|
**C1d — Consumer refresh.** In `InventoryController.Concerns(o)`
|
||||||
|
([InventoryController.cs:115](../../../src/AcDream.App/UI/Layout/InventoryController.cs)) also return
|
||||||
|
true when `o.ObjectId == playerGuid` so a live player-property update refreshes the burden bar.
|
||||||
|
(The burden source IS the player object; today it is excluded.)
|
||||||
|
|
||||||
|
### C2 — Latent-bug fixes (existing inbound handlers)
|
||||||
|
|
||||||
|
**C2a — `InventoryPutObjInContainer (0x0022)`** GameEvent: read the dropped **4th** field
|
||||||
|
`containerType` (`u32 itemGuid, u32 containerGuid, u32 placement, u32 containerType`).
|
||||||
|
`GameEvents.ParsePutObjInContainer` stops after 3 u32s today. Oracle: ACE
|
||||||
|
`GameEventItemServerSaysContainId.cs` + holtburger `events.rs` (fixture slot=3 type=1).
|
||||||
|
|
||||||
|
**C2b — `InventoryServerSaveFailed (0x00A0)`** GameEvent: read the dropped **`weenieError`** u32
|
||||||
|
(`u32 itemGuid, u32 weenieError`). `GameEvents.ParseInventoryServerSaveFailed` reads only the guid.
|
||||||
|
Oracle: ACE `GameEventInventoryServerSaveFailed.cs` + holtburger `events.rs:147`.
|
||||||
|
|
||||||
|
### C3 — New C→S builders (`InventoryActions.cs`, matching the existing builder style)
|
||||||
|
|
||||||
|
All ride the `0xF7B1` GameAction envelope (`u32 0xF7B1; u32 seq; u32 subOpcode; …`). Anchor each
|
||||||
|
against ACE `GameAction*/…Handle` + holtburger `inventory/actions.rs` (both cited in deep-dive §4.1).
|
||||||
|
|
||||||
|
| Builder | Opcode | Body |
|
||||||
|
|---|---|---|
|
||||||
|
| `BuildDropItem` | `0x001B` | `u32 itemGuid` |
|
||||||
|
| `BuildGetAndWieldItem` | `0x001A` | `u32 itemGuid, u32 equipMask` |
|
||||||
|
| `BuildNoLongerViewingContents` | `0x0195` | `u32 containerGuid` |
|
||||||
|
|
||||||
|
Opcode source CONFIRMED: ACE `GameActionType.cs` (`GetAndWieldItem=0x001A, DropItem=0x001B,
|
||||||
|
NoLongerViewingContents=0x0195`). Add `WorldSession.Send*` wrappers (mirroring
|
||||||
|
`SendAddShortcut`/`SendRemoveShortcut`) so GameWindow can inject them via `_liveSession?.Send*`.
|
||||||
|
|
||||||
|
### C4 — New S→C parsers
|
||||||
|
|
||||||
|
**C4a — `ViewContents (0x0196)` — GameEvent** (rides `0xF7B0`). New `GameEvents.ParseViewContents`
|
||||||
|
+ a `GameEventType.ViewContents = 0x0196` entry. Body: `u32 containerGuid, u32 count,
|
||||||
|
[u32 guid, u32 containerType]×count` (a leading guid then a PackableList<ContentProfile>).
|
||||||
|
Oracle: ACE `GameEventViewContents.cs` + named-retail `ClientUISystem::OnViewContents`
|
||||||
|
(`?OnViewContents@ClientUISystem@@…PackableList<ContentProfile>`) + holtburger `events.rs`.
|
||||||
|
No UI consumer yet → parse + apply to the table where meaningful (the listed guids are the
|
||||||
|
container's contents) or parse-and-log; container-open phase wires it to a second `UiItemList`.
|
||||||
|
|
||||||
|
**C4b — `SetStackSize (0x0197)` — top-level GameMessage** (UIQueue), **not** a GameEvent. New
|
||||||
|
`src/AcDream.Core.Net/Messages/SetStackSize.cs` + dispatch in `WorldSession`'s GameMessage switch.
|
||||||
|
Body: `u32 opcode(0x0197), u8 seq, u32 guid, u32 stackSize, u32 value` (size 17 ⇒ byte seq).
|
||||||
|
Apply via the table (update the object's `StackSize` + `Value`; add a typed
|
||||||
|
`ClientObjectTable.UpdateStackSize(guid, stackSize, value)` or route through the property path).
|
||||||
|
Oracle: ACE `GameMessageSetStackSize.cs` (writer) + named-retail
|
||||||
|
`ACCWeenieObject::ServerSaysSetStackSize` (the client consumer).
|
||||||
|
|
||||||
|
**C4c — `InventoryRemoveObject (0x0024)` — top-level GameMessage** (UIQueue), **not** a GameEvent.
|
||||||
|
New `src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs` + dispatch in `WorldSession`'s switch.
|
||||||
|
Body: `u32 opcode(0x0024), u32 guid`. Apply: remove the object from the player's inventory **view**.
|
||||||
|
Confirm evict-vs-unparent against named-retail `ClientUISystem` + ACE send sites before choosing;
|
||||||
|
default to the faithful "no longer in my inventory" semantics (unparent from its container + fire
|
||||||
|
the table's refresh event, so the grid drops the cell) rather than a hard global evict unless the
|
||||||
|
oracle shows full destruction. Oracle: ACE `GameMessageInventoryRemoveObject.cs`.
|
||||||
|
|
||||||
|
> **Namespace caution.** `SetStackSize 0x0197` / `InventoryRemoveObject 0x0024` are **GameMessage
|
||||||
|
> opcodes** (top-level `WorldSession` switch, like `0x02CE`/`0xF745`). `ViewContents 0x0196` /
|
||||||
|
> `InventoryPutObjInContainer 0x0022` are **GameEvent eventTypes** (inside the `0xF7B0` envelope,
|
||||||
|
> dispatched by `GameEvents.Dispatch`). Adjacent numbers, different dispatchers — do not cross them.
|
||||||
|
|
||||||
|
### C5 — Registration
|
||||||
|
|
||||||
|
- **GameEventWiring.WireAll** ([GameEventWiring.cs](../../../src/AcDream.Core.Net/GameEventWiring.cs)):
|
||||||
|
register the GameEvent parsers that exist (or are added) but are not dispatched today:
|
||||||
|
`ViewContents (0x0196)`, `InventoryPutObjectIn3D (0x019A)`, `CloseGroundContainer (0x0052)`,
|
||||||
|
`InventoryServerSaveFailed (0x00A0)`. Where there is no UI consumer yet, the handler applies to
|
||||||
|
the table if meaningful, else logs at debug — the registration makes the wire correct so B-Drag
|
||||||
|
only adds behavior.
|
||||||
|
- **WorldSession** GameMessage switch: dispatch `PrivateUpdatePropertyInt (0x02CD)`,
|
||||||
|
`SetStackSize (0x0197)`, `InventoryRemoveObject (0x0024)`.
|
||||||
|
- **GameWindow** wiring: pass `_playerServerGuid` into `ObjectTableWiring.Wire` and the GameEvent
|
||||||
|
wiring; inject the new `WorldSession.Send*` builders where the existing shortcut sends are wired.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Design decisions
|
||||||
|
|
||||||
|
### 4.1 Apply the whole player `PropertyBundle` at login (vs. whitelist)
|
||||||
|
**Chosen: whole bundle via upsert.** The bundle is plain dictionaries; storing the player's full
|
||||||
|
property set mirrors retail (all live on the ACCWeenieObject) and means future readers (Str, aug,
|
||||||
|
coin value, etc.) get their value for free without re-touching the PD handler. Whitelisting
|
||||||
|
EncumbranceVal+aug would be smaller but would re-introduce the same "value never arrives" class of
|
||||||
|
bug for the next property we need. No downside: the player object is the client's own authoritative
|
||||||
|
store.
|
||||||
|
|
||||||
|
### 4.2 Loosen the int-apply gate to all ints (vs. extend the whitelist)
|
||||||
|
**Chosen: apply all ints.** Same reasoning. `UpdateIntProperty` already stores any int in the
|
||||||
|
bundle and only special-cases UiEffects for the typed mirror; the gate in `ObjectTableWiring` is the
|
||||||
|
sole thing dropping non-UiEffects ints. Removing it is the faithful behavior (the server is the
|
||||||
|
authority on object properties). Typed-field mirrors stay opt-in per property.
|
||||||
|
|
||||||
|
### 4.3 Consumer-less parsers register now (vs. defer)
|
||||||
|
**Chosen: register now, apply-or-log.** The user chose the full wire pass to unblock B-Drag in one
|
||||||
|
trip. A registered parser that applies to the object table (or logs where there's nothing to apply)
|
||||||
|
keeps the wire correct and conformance-tested; the consuming phase adds only UI behavior. Any parser
|
||||||
|
that ends up a pure no-op gets a one-line divergence/TODO note so it isn't mistaken for "wired".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Testing / conformance
|
||||||
|
|
||||||
|
One conformance test per format, golden bytes from the ACE writer / holtburger fixture cited above:
|
||||||
|
|
||||||
|
- `PrivateUpdatePropertyInt.TryParse` — valid 0x02CD (no guid) → (property, value); wrong opcode →
|
||||||
|
null; truncation → null.
|
||||||
|
- **Login delivery** — feed a PlayerDescription payload carrying PropertyInt 5 (and aug 0xE6) →
|
||||||
|
assert the player `ClientObject.Properties.Ints[5]` is set after the handler runs (covers
|
||||||
|
`UpsertProperties` create-if-absent both orders: PD-before-CreateObject and after).
|
||||||
|
- **Gate** — a 0x02CE for a non-UiEffects int lands in the target object's bundle.
|
||||||
|
- `ParsePutObjInContainer` — 4-field read (the 4th = containerType).
|
||||||
|
- `ParseInventoryServerSaveFailed` — reads guid + weenieError.
|
||||||
|
- `BuildDropItem` / `BuildGetAndWieldItem` / `BuildNoLongerViewingContents` — exact byte layout
|
||||||
|
(envelope + body), round-tripped against the ACE handler's expected read.
|
||||||
|
- `ParseViewContents` — guid + count + N×{guid,containerType}; zero-count edge.
|
||||||
|
- `SetStackSize` parse — op + byte-seq + guid + stackSize + value (size 17).
|
||||||
|
- `InventoryRemoveObject` parse — op + guid.
|
||||||
|
- **Burden end-to-end** (App.Tests) — with the wire `EncumbranceVal` present, `RefreshBurden` uses
|
||||||
|
it (not `SumCarriedBurden`); a live player-int update fires a burden refresh (C1d).
|
||||||
|
|
||||||
|
All existing tests stay green (App 532 / Core 1526 baseline). `dotnet build` + `dotnet test` green
|
||||||
|
before the phase is claimed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Divergence register changes (`docs/architecture/retail-divergence-register.md`)
|
||||||
|
|
||||||
|
- **Retire AP-48** (client-side `SumCarriedBurden` fallback) — burden now reads the wire value.
|
||||||
|
Keep `SumCarriedBurden` in code as a defensive fallback for "wire value genuinely absent" and
|
||||||
|
note that in the row's deletion commit; if a residual divergence remains (fallback ever used),
|
||||||
|
reword the row instead of deleting.
|
||||||
|
- **Retire AP-49** (carry-aug unwired) — the aug (`0xE6`) now arrives via the PD bundle.
|
||||||
|
- No new deviations expected (faithful ports). If C4c's evict-vs-unparent choice, or any
|
||||||
|
consumer-less no-op, ends up an approximation, add its row **in the same commit** per the
|
||||||
|
register rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Out of scope (explicit)
|
||||||
|
|
||||||
|
- Drag-to-drop / drag-to-wield **behavior** (B-Drag wires the C3 builders to drag-release).
|
||||||
|
- Opening side-packs / ground containers into a second `UiItemList` (container-open wires C4a).
|
||||||
|
- Speculative-move rollback **UI** (B-Drag; C2b lands the parser only).
|
||||||
|
- Paperdoll `UiViewport` + per-slot art (Sub-phase C).
|
||||||
|
- `CreateObject` field extraction — **already done** (`ObjectTableWiring.ToWeenieData` captures
|
||||||
|
IconId/StackSize/Value/capacities/Burden); the deep-dive's "discards" note is stale. Verify no
|
||||||
|
field is still `_`-discarded in `CreateObject.TryParse`; extend only if a real gap remains.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Login PD delivers the player's PropertyInt table to the player `ClientObject` (EncumbranceVal
|
||||||
|
+ aug present after login), via `UpsertProperties` (both arrival orders covered by tests).
|
||||||
|
- [ ] `PrivateUpdatePropertyInt (0x02CD)` parsed + dispatched + applied to the player object; the
|
||||||
|
apply gate passes all ints.
|
||||||
|
- [ ] Burden bar reads the wire `EncumbranceVal` and refreshes live on a player-int update (C1d).
|
||||||
|
- [ ] `0x0022` reads containerType; `0x00A0` reads weenieError.
|
||||||
|
- [ ] Builders `0x001B` / `0x001A` / `0x0195` added with `WorldSession.Send*` wrappers + byte tests.
|
||||||
|
- [ ] Parsers `ViewContents 0x0196` / `SetStackSize 0x0197` / `InventoryRemoveObject 0x0024` added,
|
||||||
|
dispatched in the correct dispatcher, applied-or-logged.
|
||||||
|
- [ ] `WireAll` registers ViewContents / InventoryPutObjectIn3D / CloseGroundContainer /
|
||||||
|
InventoryServerSaveFailed.
|
||||||
|
- [ ] Every AC-specific format cites its named-retail symbol/address + ACE `file:line` in a comment.
|
||||||
|
- [ ] AP-48 + AP-49 retired in the register; any new approximation has a row.
|
||||||
|
- [ ] `dotnet build` + `dotnet test` green; existing tests unbroken.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue