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:
- Rust reads properties, never identifiers. Every enumeration on the wire is
defined in
PROTOCOL.mdas ours, Java maps MegaMek onto it, and Rust matches on an enum. A test forbids the MegaMek name set outsidewire/. - 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.
- 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 <dir>onSdsClient, which holds theObjectNodeatask(); one recorded 2v2, gzipped, undercrates/sds-core/tests/corpus/. It is the contract a wasm build is tested against too (browser). - Goldens from it. The puzzles 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.
SdsConformancealready 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-coresource that fails on any MegaMek identifier from a listed set outsidewire/, 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.rsintounit/{projection,caches,menu,report,fixtures,tests}.rs- landed as the unit-split stack.main.rs(4700 lines) intocli.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()intobridge/sds/unit/<Group>.java, onestatic void into(ObjectNode u, Game g, Player me, Entity e)per field group, called in today's order; the weapon helpers intobridge/sds/weapons/, still as the functions they are.bridge/sds/intoruntime/,probes/andtools/under one package;scripts/build.sh:78compiles withfindinstead of the glob, and no class name insds/match.pymoves. Probes whose answers are already in a corpus file or anobserved.txtleave the default compile.- One fixture builder per crate. Twelve
fn unit(and sixfn board(builders exist;features::fixturealready has the builder, behind#[cfg(test)] pub(crate). Afixturescargo feature makes it reachable from sds-bot's dev-dependencies and sds-core's owntests/.
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 inPROTOCOL.mdas 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 aunitTypekey emitted fromEntity.getUnitType(), with the derivation from locations and movement mode as the fallback for older observations.can_fall,tracks_heatandhitloc::Bodystay as fields beside it;wire.rs:846-865says why none of the three follows from the chassis.- The sixteen dispatch sites become a
matchon the kind. Resolving theTRIPODdisagreement changes how a tripod's movement is priced: that one gets a control and a re-recorded golden. protocol: u32onboard, ahellothe host reads back, and refusal when the ranges do not overlap. The Mek-shaped fallbacks (turn_mpreading 1,attacker_movementreading 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
fbd7d49are fixed on the way: Java emitsboard.kindsandrejected[].attemptthat Rust drops; Rust declaresstackEnterablethat no emitter writes, thoughplan/battle-armor.mdsays 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 aconstwith 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, theLosEffectsheight off-by-one, the section MegaMek calls a HACK,MovePath.getMpUsedpricing. 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::Excludedmoves here asnot_priced.rs, keyed by a wire property (amodelledflag per weapon, whichplan/protocol.mdalready 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, behindUnitKind: 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.rsis 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/<Family>.java: oneWeaponRuleper family that needs one (LbxAutocannon,UltraAutocannon,Mml,Atm,Artillery,Flak,Infantry), eachapplies(WeaponType, AmmoType)anddescribe(facts). A general derivation runs first; the matching rules override fields in an order written down once inWeapons.java, because ammunition can redefine ranges and an MML's mode changes its rack.- The
modelledflag 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,Commitmentandasks. They are computed every turn (unit.rs:492-599,force.rs:278-296) and decide nothing: the scorer isallocate(&mut thoughts, &ours, &weights)andcandidates.rs:436-438says surfacing only supplies a label.plan/scoring.mdasked for this already; it removes ninety lines frompropose_including. Proposal labels change, so the imitation corpus is re-recorded before anything compares against it. - Delete
Proposal.risk: written as0.0at nine sites, read by no Rust code, defaulted by the viewers. - Measure
Observationparse 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) or the fit (estimator). 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.