---
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`