--- id: contract title: The wire is a contract, and every rule has one home status: open dependsOn: [protocol, puzzles] exitCriterion: > Rust matches on no MegaMek identifier outside the wire module, every rule in sds-core is a rulebook table, a named engine deviation or a named policy, a new per-unit or per-weapon fact lands by editing one Rust type and one Java emitter, and the recorded goldens reproduce the same decisions throughout. --- # contract `docs/PROTOCOL.md` says the protocol is the product and the JVM is scaffolding that can be replaced. That holds where facts cross the wire: the estimators read numbers, the hard questions are asked of the jar rather than derived, and MegaMek is named 446 times in Rust comments and seven times in Rust code. It does not hold in three places, and the same three places are where the merge conflicts come from. **The wire speaks MegaMek's identifiers and Rust matches on them.** Move steps are `MoveStepType` names (`FORWARDS`, `START_JUMP`, `GET_UP`, `LOAD`), 48 literals across six files, and the pathfinder emits them as the reply grammar. Movement modes, phases, `UnitRole` names (about sixty uses of `BRAWLER` and its siblings), hit-table names, location abbreviations (about two hundred literals in ten files), handler class names and weapon display names in `brackets::Excluded::of` are all MegaMek's spelling, read as strings. **Every fact about a unit lands in the same four places.** A field on the 63-field `wire::Unit` (`wire.rs:606-1042`), a `put` in the 826-line `Observation.unit()`, a branch in the bot, and an edit to a test fixture literal in the same file as the tests. Counted over the sixty in-flight branches on 2026-09-05: `Observation.java` is touched by 47, `wire.rs` by 46, `main.rs` by 42, `unit.rs` by 41. Unit kind is a string plus sentinel booleans dispatched at sixteen sites that disagree with each other (`pathfind.rs:305` forgets `TRIPOD`; `wire.rs:1217` includes it). **Rules, engine deviations and bot policy sit in the same files.** `los.rs` is ported from `LosEffects` "rather than from the rules", `arc.rs` from `ComputeArc`, `pathfind.rs` prices what `MovePath.getMpUsed` would say, and `hitloc.rs` reproduces a MegaMek vehicle hit-table defect on purpose. Those are defensible: a bot that plays the rulebook loses to the engine's bugs. But none of them is marked as a deviation, so a port to another engine could not tell what to remove. And a bot choice such as Rotary at three shots, or the list of what is not priced, lives inside an estimator rather than beside the other choices. ## The rule Three sentences, to go in `CLAUDE.md` once the plan is under way: 1. Rust reads properties, never identifiers. Every enumeration on the wire is defined in `PROTOCOL.md` as ours, Java maps MegaMek onto it, and Rust matches on an enum. A test forbids the MegaMek name set outside `wire/`. 2. A rules table in Rust cites the rulebook. An engine deviation is named as one, in one list, with the conformance line that proves it. 3. A fallback for a missing field lives only under a protocol version that predates the field. Under the current version the field is required. The plan is the order in which the code comes to obey them, with each phase landable on its own and each verified the same way: the recorded goldens reproduce, the conformance dump is unchanged, and clippy and the tests are green. ## Phase 0: the instruments Nothing moves until there is something that says a move changed no decision. - **An observation corpus.** `--dump-observations ` on `SdsClient`, which holds the `ObjectNode` at `ask()`; one recorded 2v2, gzipped, under `crates/sds-core/tests/corpus/`. It is the contract a wasm build is tested against too ([browser](browser.md)). - **Goldens from it.** The [puzzles](puzzles.md) runner in its golden form: every recorded decision replayed through the offline proposer and the force command with the vector that recorded it, asserting the recorded `chosen`. A phase that changes no decision leaves every golden green; a phase that changes one says so in its commit and re-records. - **Conformance per weapon family.** `SdsConformance` already re-asks the settled questions; extend it to dump the derived facts for one weapon of every family MegaMek ships, so a Java refactor is checked byte for byte. - **A ratchet on identifiers.** A test over `sds-core` source that fails on any MegaMek identifier from a listed set outside `wire/`, seeded with an allowlist of today's sites. The allowlist only shrinks. ## Phase 1: files, pure moves No window needed, no semantic change, goldens identical. - `unit.rs` into `unit/{projection,caches,menu,report,fixtures,tests}.rs` - landed as the unit-split stack. - `main.rs` (4700 lines) into `cli.rs` (the hand-parsed flags at 2768-2960), `weights.rs`, `firing.rs` (`firing_plan`, ladders, `allocations`, `volleys`), `bot.rs` (`Bot`, `plan`, `movement`, `deployment`), `classify.rs`, `run.rs`. Tests move with their subjects. - `Observation.unit()` into `bridge/sds/unit/.java`, one `static void into(ObjectNode u, Game g, Player me, Entity e)` per field group, called in today's order; the weapon helpers into `bridge/sds/weapons/`, still as the functions they are. - `bridge/sds/` into `runtime/`, `probes/` and `tools/` under one package; `scripts/build.sh:78` compiles with `find` instead of the glob, and no class name in `sds/match.py` moves. Probes whose answers are already in a corpus file or an `observed.txt` leave the default compile. - One fixture builder per crate. Twelve `fn unit(` and six `fn board(` builders exist; `features::fixture` already has the builder, behind `#[cfg(test)] pub(crate)`. A `fixtures` cargo feature makes it reachable from sds-bot's dev-dependencies and sds-core's own `tests/`. ## Phase 2: the wire owns its vocabulary Byte-identical JSON, so every corpus still parses; only the Rust type changes. - Enums in `wire/` with serde spelled as today: `Step`, `Phase`, `Role`, `LocationName`, `HitTable`, `PhysicalKind`, `MovementMode`. Each documented in `PROTOCOL.md` as the protocol's own, with the MegaMek name it maps from in the Java emitter and nowhere else. - `UnitKind` (`Mek(Biped|Quad|Tripod)`, `Vehicle`, `Vtol`, `BattleArmor`, `Infantry`, `Unknown`), from a `unitType` key emitted from `Entity.getUnitType()`, with the derivation from locations and movement mode as the fallback for older observations. `can_fall`, `tracks_heat` and `hitloc::Body` stay as fields beside it; `wire.rs:846-865` says why none of the three follows from the chassis. - The sixteen dispatch sites become a `match` on the kind. Resolving the `TRIPOD` disagreement changes how a tripod's movement is priced: that one gets a control and a re-recorded golden. - `protocol: u32` on `board`, a `hello` the host reads back, and refusal when the ranges do not overlap. The Mek-shaped fallbacks (`turn_mp` reading 1, `attacker_movement` reading the Mek penalties, empty terrain reading the ported column, empty mode reading a biped) are gated to protocol 0; under the current version the fields are required and their absence is an error. - The three drifts found at `fbd7d49` are fixed on the way: Java emits `board.kinds` and `rejected[].attempt` that Rust drops; Rust declares `stackEnterable` that no emitter writes, though `plan/battle-armor.md` says it is sent. - The ratchet's allowlist ends at `wire/`. ## Phase 3: one home per kind of rule Moves and renames inside sds-core; goldens identical. - `sds-core/src/rules/`: the cluster hits table, the hit-location tables, the range modifiers, the heat scale thresholds, the piloting cliff. Each a `const` with the rulebook reference in its doc and the dump test that already exists beside it. - `sds-core/src/rules/deviations.rs`: the list of places where the code follows MegaMek 0.51 rather than the book, each with its conformance line: the vehicle front table hitting one side twice, the `LosEffects` height off-by-one, the section MegaMek calls a HACK, `MovePath.getMpUsed` pricing. Nothing is rewritten; the ports stay. What changes is that a reader, or a port to another engine, can find the list. - `sds-core/src/policy/`: what the bot chooses rather than what the rules say. `brackets::Excluded` moves here as `not_priced.rs`, keyed by a wire property (a `modelled` flag per weapon, which `plan/protocol.md` already asks for) instead of by name; the rate-of-fire assumption (Rotary at three); `FIRING_ONLY`. Each entry is one sentence a player could argue with. - `sds-core/src/kinds/{mek,vehicle,battle_armor,infantry}.rs`, behind `UnitKind`: how each takes damage (`hitloc::Body`, the platoon model), how it moves (the terrain column, jumping, facing cost), what it cannot do (swarming squads, prone, heat). `infantry.rs` is the first of these already; this gives it siblings and a trait. ## Phase 4: weapons The exceptions are in Java because that is where the jar can be asked. - `bridge/sds/weapons/.java`: one `WeaponRule` per family that needs one (`LbxAutocannon`, `UltraAutocannon`, `Mml`, `Atm`, `Artillery`, `Flak`, `Infantry`), each `applies(WeaponType, AmmoType)` and `describe(facts)`. A general derivation runs first; the matching rules override fields in an order written down once in `Weapons.java`, because ammunition can redefine ranges and an MML's mode changes its rack. - The `modelled` flag on the wire, set by the rules, replaces every name match in Rust. - `docs/WEAPONS.md`, generated: family, Java rule, the wire fields it sets, the Rust readers of each. The page that answers "where does the LB-X live". - Verified by the per-family conformance dump from phase 0, byte for byte. ## Phase 5: the typed unit, in a window The one phase that rewrites every line of `wire.rs:606-1042` and `Observation.unit()`, so it conflicts with every open branch on every hotspot. Done as one mechanical pull request when the wire-touching branches have landed, it conflicts once. - Twelve sub-structs under `#[serde(flatten)]`, twelve Java emitters, one to one: | sub-struct | fields | who has it | |---|---|---| | `Identity` | id, name, owner_id, team, friendly, role, force_path | everyone | | `Worth` | bv, bv_original, weight | everyone | | `Pose` | x, y, elevation, facing, secondary_facing, twists, elevation_valid | everyone | | `Mobility` | walk_mp, run_mp, jump_mp, jump_mp_terrain, movement_mode, max_elevation_change, terrain, turn_mp, attacker_movement, mp_used, stack_enterable | everyone | | `Heat` | heat, heat_capacity, tracks_heat, heat_buildup, explodable_ammo | Meks; `tracks_heat` marks the rest | | `Hull` | armor, armor_max, internal, internal_max, locations | everyone | | `Crew` | gunnery, piloting, psr_base | everyone | | `Status` | prone, can_stand, can_fall, shut_down, immobile, stuck, can_unstick_by_jumping, destroyed, crippled, done, swarm_target_id, swarm_attacker_id | everyone | | `Melee` | physicals, allowed_physical_attacks | Meks | | `Carriage` | carried_by, carrying, can_carry | carriers, battle armor, infantry | | `Platoon` | infantry_damage, mechanized, damage_divisor, dug_in | conventional infantry | | `Arms` | weapons | everyone | Field names are unique by construction, so flattening cannot collide; `Reply.action` at `wire.rs:2473` is the precedent. `imitate.rs:502` builds a `Unit` as a struct literal and is the one constructor that breaks. - Delete `Appraisal`, `Intent`, `Stance`, `Commitment` and `asks`. They are computed every turn (`unit.rs:492-599`, `force.rs:278-296`) and decide nothing: the scorer is `allocate(&mut thoughts, &ours, &weights)` and `candidates.rs:436-438` says surfacing only supplies a label. `plan/scoring.md` asked for this already; it removes ninety lines from `propose_including`. Proposal labels change, so the imitation corpus is re-recorded before anything compares against it. - Delete `Proposal.risk`: written as `0.0` at nine sites, read by no Rust code, defaulted by the viewers. - Measure `Observation` parse time before and after: flatten deserialises through a buffered map. ## What this plan does not do It does not rewrite line of sight, pathfinding, arcs or hit locations to the rulebook. The bot plays MegaMek, so it predicts MegaMek; the plan names the deviations rather than removing them. It does not touch the sweep's cost ([latency](latency.md)) or the fit ([estimator](estimator.md)). And it does not move a per-weapon rule into Rust: a rule that lives after the wire changes what a recorded corpus means, and a Rust table of what an ATM does drifts from the jar while a Java rule that asks the jar does not. - [ ] Phase 0: observation corpus, goldens, per-family conformance, the identifier ratchet - [x] Phase 1: `unit.rs` split - [ ] Phase 1: `main.rs` split, `Observation.unit()` emitters, bridge layout, one fixture builder per crate - [ ] Phase 2: wire enums, `UnitKind` and `unitType`, the sixteen sites as one `match`, the `TRIPOD` control - [ ] Phase 2: `protocol` on `board`, `hello`, fallbacks gated to protocol 0, the three drifts fixed - [ ] Phase 3: `rules/`, `rules/deviations.rs`, `policy/`, `kinds/` - [ ] Phase 4: `bridge/sds/weapons/`, the `modelled` flag, `docs/WEAPONS.md` - [ ] Phase 5: twelve sub-structs and emitters in one window; delete `Appraisal` and friends and `risk`; parse timing - [ ] The three-sentence rule in `CLAUDE.md`