Skip to content

bc16 — Battlecode 2016 "Zombie Invasion": the eighth year module - #10

Merged
daveey merged 16 commits into
mainfrom
bc16-year-module
Sep 9, 2026
Merged

bc16 — Battlecode 2016 "Zombie Invasion": the eighth year module#10
daveey merged 16 commits into
mainfrom
bc16-year-module

Conversation

@daveey

@daveey daveey commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

bc16 — Battlecode 2016 "Zombie Invasion"

The eighth year module. A third team neither cog controls: dens on a
published schedule pour zombies at whoever is nearest, the horde gets stronger
every 300 rounds, a bite turns your own robot against you when it dies, and
losing your last archon loses the game on the spot.

Adds the bc16 manifest variant (num_agents: 2), the year module, both
chassis, the viewer block, 24 new test shards and a new parity-oracle-bc16
CI job. Certification stays on bc26. No bc20/bc21/bc22/bc23/bc24/bc25/bc26
module file, map, atlas, test or variant is edited.

The parity result

All eighteen oracle pairs are BIT-EXACT for whole games. The ledger is
EMPTY.
833 581 trace lines a side, 1 667 162 lines compared.

parity-oracle-bc16 fetches the published battlecode-2016.0.2.2.jar (pinned
by size, sha256 and its own battlecode-version file — there is no
SPEC_VERSION in 2016's GameConstants), compiles a driver and two bots
against it on Temurin 8, and diffs whole games.

Three real defects it found, none of which any unit test caught:

  1. Collectors.toMap PREPENDS within a HashMap bucket. It is implemented
    with HashMap.merge, not put. tools/convert_maps_bc16.py's iteration-
    order emulator appended. All 22 maps regenerated; five changed bytes.
  2. ZombieControlProvider.denQueues is keyed by robot ID, not
    MapLocation
    — so a den's successor on the same square inherits its queue.
  3. matchEnded() nulls the provider's random field, so the RNG state must
    be read through a held reference.

And one comparator pin: String.format("%.6f") rounds HALF-UP while C's
printf rounds HALF-TO-EVEN. On checkers at round 219 the two sides held
byte-identical rubble arrays and printed 88935.090413 against
88935.090412. Both 900-term sums now travel as raw IEEE-754 bit patterns.

The two edits a reviewer should look for

  • config_schema.maxRounds.maximum 2000 → 3000. The one non-additive
    manifest edit — a widened bound that accepts everything it accepted before.
    bc16's tiebreak fires at the end of round 2999.
  • GameVersion GV10 → GV11, with GV10 and every earlier value kept in
    ReplayCompatibleGameVersions. The two committed sibling fixtures are
    regenerated; tests/test_bc22_replay.nim's hard-coded "GV10" literal is
    replaced by MORE checks, not fewer.

Deviations, all recorded in the tree

  • Tier A′ is not run. The Nim half of the scenario bot exists and works;
    the Java half is not written and the three sub-variants exist on neither
    side. docs/PARITY.md §bc16 names the exact shape of the gap.
  • The survival gate commits the MEASURED ratio (3 of 6), not the note's 5 of
    6.
    2016 combat arithmetic is slow — a GUARD deals 3.0/round to a zombie
    against a level-9 BIGZOMBIE's 1500 HP — so a horde that overwhelms an
    unbroken den field is authentic 2016. Both measurement tables (healthy and
    -d:bc16BrokenChassis) are inline in the test header and in
    docs/RULES-BC16.md §Divergences item 16, and the negative control still
    comes back RED.
  • Three chassis adjustments for knob teeth, each measured, each in
    §Divergences item 20.
  • Four numeric corrections to the design note, verified against the JVM, in
    docs/RULES-BC16.md §Corrections.
  • dispatch.currentRound's bc16 arm reports rounds PLAYED (item 19), because
    bc16 is the only year whose world counts from −1.

A viewer defect fixed on the way

broadcast.nim's zombie_wave and outbreak beat labels read count, dens
and multiplier — none of which are emitted. A missing key in a JsonNode
{} lookup reads back as ZERO, so the label was non-empty, passed every
word-level assertion, and showed the spectator "WAVE — 0 zombies from 0
dens"
. tests/test_bc16_beats.nim now asserts each label against the VALUE in
the event that produced it.

The eighth year module and the first pre-2020 year, ported from
battlecode/battlecode-server-2016 at 11a0b09f26a70da19f33a61ebec4ceaf6e161aa3
with a file:line citation on every rule.

The official 2016 spec is lost (dead S3, dead battlecode.org, no Wayback
copy) and this year's GameConstants has no SPEC_VERSION field at all, so the
ENGINE SOURCE IS THE SPEC: GameWorld.java (1051 lines), InternalRobot.java
(473), RobotControllerImpl.java (886), ZombieControlProvider.java (398),
GameMap.java (874), RobotType.java (390), GameConstants.java (162),
MapLocation/Direction/ZombieSpawnSchedule/ZombieCount/Team/IDGenerator/
GameMapIO were read whole and are cited in the module's own doc comments.

The sim, in the note's own file layout:

  constants.nim  GENERATED by tools/gen_year_constants.py --year bc16 (2016's
                 GameConstants is an INTERFACE, so its fields carry no
                 `public static final` and it needed its own regex), the whole
                 twelve-row RobotType table with all seventeen constructor
                 fields, and the outbreak ladder read out of the engine's own
                 switch
  units.nim      the pure per-unit arithmetic: four Teams, ten Directions with
                 2016's SOUTHWARD y axis, six symmetries, the eight derived
                 predicates, the tabled (int) Math.sqrt(r2) for 0..10 000, the
                 float64 rubble/move/guard/broadcast arithmetic, and
                 directionTo's 2.414 fan computed in the engine's own doubles
  delays.nim     the core/weapon pair and its four mutators, plus the two
                 composite helpers whose OPPOSITE set/add pairing is the
                 easiest way to break this year; decrementDelays is pinned to
                 1.0 (V1) and the engine's whole formula is kept beside it for
                 the table and the tests
  health.nim     the two INDEPENDENT infection counters, the viper tick's
                 exactly-2.0 damage, the health cap, and deathConsequence --
                 the ordered decision a death makes (rubble or a zombie, never
                 both)
  world.nim      state, geometry, terrain, the INSERTION-ordered exec list
                 with by-value removal, sensing in insertion order, the single
                 changeHealthLevel mutation point with the mid-turn DESTROYED
                 check, and every action of rule 3.2
  zombies.nim    the VERBATIM ZombieControlProvider: the den's three-step
                 turn, spawnAllPossible's ring and its no-break "last
                 non-zero type" priority, and processZombie's eight-step
                 ladder with both RNG draw preconditions exact
  economy.nim    max(0, 2 - 0.01 * robots) per team per round, A then B
  signals.nim    the 1000-entry FIFO, the 5/20 per-turn counters, and the
                 broadcast walk over ALL FOUR TEAMS with its two-counter charge
  rules.nim      the four-step round loop (rounds numbered FROM ZERO), the
                 four-rung round-2999 ladder on exact float64 differences, the
                 float32-narrowed points formula and one game
  maps.nim       the 22-map pool, the loader, the draw and the map cards
  knobs.nim      the eleven-knob doctrine sheet, no `chassis` key (D1), with
                 ABSENT-key defaulting counted (the envelope pin, item 2)
  chassis/       fourteen files: kit, econ, archon, combat, micro, turret,
                 dens, neutral, rubble, infect, comms, bulwark, greenhorn and
                 scenario16

data/maps/bc16/ carries the 22 converted maps, each with its BUILD-TIME
per-den schedule split (D3) and computed symmetry (D4), so the runtime sim
hashes nothing. tools/convert_maps_bc16.py reproduces Java 8's HashMap
iteration order (h ^ (h >>> 16), bucket hash & (n-1), capacity 16, load factor
0.75, resize splitting each bucket in place) and that emulation was verified
against a real java.util.HashMap on all 98 official rosters, 0 mismatches. All
98 maps parse; the two armageddon maps are REFUSED by flag (V4).

data/atlas_bc16.* is cut from battlecode/battlecode-client-2016 (GPL-3.0,
pinned 317e1f3f) by tools/build_sprite_atlas_bc16.py: 49 sprites, all twelve
robot types at ALL FOUR Team palettes, so this is the first year in the repo
whose art draws a NEUTRAL robot as itself.

TheDuck314/battlecode2016 and bshimanuki/battlecode2016 carry NO LICENCE and
were not cloned, not read, not copied, not vendored, not compiled and not
translated; neither contributes a line. `bulwark` is written from the engine's
own mechanics and from the three archetypes the run's idea text names.

GameVersion GV10 -> GV11 with a prepend-only changelog entry, and
ReplayCompatibleGameVersions EXTENDED to GV04..GV11 (never reset): this run
makes no year-neutral behaviour change at all, so no recorded byte anywhere
changes meaning. rng.nim gains ONE optional parameter -- IDGenerator's first
block, which is 0 in 2016 (ids from 1) against the 10 000 floor every later
year uses -- and every existing call site keeps the default.

The year boundary is compiler-enforced: registry, dispatch (Session variant,
every case arm, statsJson16 and the three name tables), sheet, baselines,
decide (the bc16 preamble and observation), broadcast (beatsFor's bc16 arms
and the five bc16 chrome records) and render (the six-step rubble heat ramp
with hard breaks at 50 and 100, and the four-palette unit sprites) all carry
their bc16 arm.

Measured locally (release Nim, not CI): a 3000-round mirror on `river` runs in
2.2 s -- 0.74 ms/round at a peak of 45 and 41 robots -- well inside the note's
2-5 ms/round estimate and the 130 s perf gate.
Adds the `bc16` variant to `coworld_manifest_template.json` beside the seven
already there: `num_agents: 2` inside `game_config`, maxRounds 3000, the mixed
pool, 3 games a match and the measured budgets. `config_schema.year` gains
`"bc16"`; `game.docs.pages` gains a tenth page for `docs/RULES-BC16.md`; the
two `player[]` descriptions name the bc16 chassis. `player[]` and the
certification block stay on bc26, unchanged.

ONE EDIT IS NOT ADDITIVE and is named here so a reviewer looks for it:
`config_schema.maxRounds.maximum` goes 2000 -> 3000, a widened bound that
accepts everything it accepted before. bc16's tiebreak fires at the end of
round 2999, so 2000 would have refused the year's own default.

`results.nim` gains `Bc16GameKeys` (44 keys, 10 of them reused from the
year-neutral set) and three end reasons -- `archons_destroyed`,
`more_archon_health`, `more_parts_net_worth`. `tools/ci/policies.json` gains
bc16's four entries (two LLM champions, two scripted fillers), taking the
repo-wide file to 32.
`match.nim`'s `collectGameEvents` gains the bc16 arms -- `zombie_wave`,
`outbreak`, `den_destroyed`, `neutral_activated`, `infection`, `turned`,
`tiebreak` and `unit_milestone`, plus year-tested `first_action`, `rout` and
`archon_lost` -- each bounded per game as `docs/REPLAY.md` records.
`winBonusFor` adds bc16 to the 200 set, because bc16's `points` can favour the
loser: a one-unit margin on a rung with large totals is arbitrarily small.

`years/dispatch.nim`'s `currentRound` arm returns `world.currentRound + 1`.
bc16 is the only year whose world counts from -1 and whose first played round
is 0, exactly as the engine's is; `replay.nim`'s year-neutral deriver indexes
the recorded chain with a ROUNDS-PLAYED count, so the arm converts. Every
event, trace line and viewer clock still carries the engine's 0-based number.

AND A REAL DEFECT: `broadcast.nim`'s `zombie_wave` and `outbreak` beat labels
read `count`, `dens` and `multiplier`, none of which are emitted -- the fields
are `total`, `dens_spawning`, `outbreak_level` and `multiplier_permille`. A
missing key in a `JsonNode` `{}` lookup reads back as ZERO, so the label was
non-empty, passed every word-level assertion, and showed the spectator
"WAVE - 0 zombies from 0 dens at x". Both labels now read the emitted fields
and render the per-mille multiplier as `1.1x`.
…walk

Three measured adjustments, made so the doctrine knobs have teeth, and each
recorded in `docs/RULES-BC16.md` §Divergences item 20 with its measurement:

* `dens.nim` gains a standing strike group (`DenStrikeGroup = 4` attackers,
  `DenGarrison = 4` held back), a sticky den target and a `DenPressureFloor`
  that commits while the attackers are still alive. `inStrikeGroup` now
  outranks the defensive floor in `bulwark.nim`'s `objectiveFor`. Without it
  the floor reclaimed the whole army every round and the den field was never
  broken: 6 dens killed across the six small maps, against 20 after.
* `econ.nim` gains `openingGuardBias` -- `soldier_viper_aggro` takes -20 points
  of guard share and moves the viper ahead of the third soldier once the
  stockpile can afford 120 + 30. The bias is DELIBERATELY ONE-SIDED (`turtle`
  keeps the cog's own number) because a symmetric +/-20 moved the all-defaults
  game from 3-of-6 to 1-of-6 on the survival gate. Without it `opening` moved
  nothing measurable: 116 soldiers against 115.
* `archon.nim`'s `archon_spread` biases the parts walk by distance from the
  friendly archon centroid. Without it `huddle` and `split` produced
  byte-identical games.

`rules.nim` also gains the `-d:bc16Idle` define, the Nim twin of the parity
oracle's `bc16idle` bot, whose whole body is `while (true) Clock.yield();`.

And `chassis/scenario16.nim`'s header is corrected to say what the file is.
It claimed a Java twin and three sub-variants (`-d:bc16ScenarioTurn`,
`…Annihilate`, `…Tie`). The Java twin is not written, Tier A' does not run, and
the three sub-variant defines are read nowhere -- a build with only one of them
silently plays `bulwark`, because they do not imply `-d:bc16Scenario`. The base
script IS implemented and does run (357 rounds on `river`, 18 605 trace lines);
the header now says exactly that, and `docs/PARITY.md` §bc16 names the gap.
Found by the Tier B map probe in `parity-oracle-bc16`, which reads every map's
symmetry and per-den zombie split out of the JVM's own `GameMap` and compares
it cell by cell against the committed JSON.

`tools/convert_maps_bc16.py` emulates `java.util.HashMap` iteration order so
that the build-time per-den split of the public zombie schedule matches the
engine's. Its emulator appended a new node within a bucket. The engine reaches
that map through `Collectors.toMap`, which is implemented with `HashMap.merge`
-- and `merge` PREPENDS. All 22 maps are regenerated; five change bytes
(`collision`, `frogger`, `quadrants`, `voluted`, `zigzag`).
The `parity-oracle-bc16` half that lives outside `ci.yml`. It fetches the
published `battlecode-2016.0.2.2.jar` (6 563 607 bytes, sha256 c78ef341...),
compiles a driver and two bots against it ON TEMURIN 8, and diffs whole games
against this sim.

RESULT: all eighteen pairs BIT-EXACT, 833 581 trace lines a side, ledger EMPTY.

* `tools/oracle/bc16/jar.lock` -- the URL, the size, the sha256 and the jar's
  own `battlecode-version` file. There is no `SPEC_VERSION` in 2016's
  `GameConstants`, so those are the only pins available.
* `tools/oracle/bc16/build_oracle.sh` -- verifies all three pins, asserts the
  bundled ASM/XStream/`MethodCosts.txt` and the 54 `.xml` map resources, and
  compiles the driver and both bots.
* `tools/oracle/bc16/Bc16Trace.java` -- the driver. NEUTRALs are registered to
  a `NullControlProvider`, not the zombie one, or `ZombieControlProvider`
  deletes every neutral on round 0; it calls `System.exit()`, because the
  sandboxed player threads are non-daemon; and it exits 3 if nothing happened,
  because under any JDK newer than 8 the 2016 instrumenter throws on every
  player class load and the job would otherwise be green while proving nothing.
* `tools/parity_trace_bc16.nim` -- the Nim side.
* `tools/JavaBc16Tables.java` + `data/bc16/tables.json` -- Tier B: the WHOLE
  finite arithmetic domain regenerated from the jar's own classes. Both
  non-algebraic functions in their entirety (`Math.pow(k/8000,1.5)` for all
  8 001 k, the 25 921-pair `directionTo` lattice), the twelve-row RobotType
  table, the outbreak ladder, the guard reduction, the rubble clear map, the
  parts income curve and all 42 `GameConstants` fields.
* `tools/ci/parity_tiers_bc16.py` -- the comparator, with a 10-case selftest.
* `tools/ci/parity_ledger_bc16.json` -- empty.

TWO MORE DRIVER DEFECTS THE ORACLE FOUND: `ZombieControlProvider.denQueues` is
keyed by ROBOT ID, not `MapLocation` (so a den's successor on the same square
inherits its queue); and `matchEnded()` nulls the provider's `random` field, so
the state must be read through a held reference rather than reflected after the
match. Both fixed here.

AND ONE COMPARATOR PIN: `String.format("%.6f")` rounds HALF-UP while C's
`printf` -- which Nim's `formatFloat` calls -- rounds HALF-TO-EVEN. On
`checkers` at round 219 the two sides held byte-identical rubble arrays and
printed 88935.090413 against 88935.090412. Both 900-term sums are now emitted
as raw IEEE-754 bit patterns on both sides.
`client/replay_broadcast.html` gains the bc16 block beside the other years',
following the starter's chrome verbatim: the CSS block hides the 41 sibling
ids under `html[data-year="bc16"]` and the 7 bc16 ids everywhere else, and all
13 `.beat-marker` rules are year-scoped, because six of the thirteen beat names
are shared with other years.

The readouts are `#bc16-archons` (the headline: both factions' archon tally
with a health pip each, a green ring for a zombie infection and a violet one
for a viper's -- the only readout that can end the match), `#bc16-horde` (THE
HORDE CLOCK, the readout no other year has: zombies alive by type, the next
scheduled wave with its composition and how many rounds away, the outbreak
level and multiplier, dens standing, and the tiebreak countdown -- kept at
every width down to 360 px), `#bc16-econ`, `#bc16-units`, `#bc16-doctrines`
and `#bc16-siege` (the endcard war panel with the full tiebreak ledger).

`relayout()`'s `--statrail` list, both `onText` hooks, the endcard noun row and
the 8-way year guard all gain their bc16 entry.
`tools/gen_bc16_fixture_replay.nim` plays a real three-game match
(`frogger`/`checkers`/`river`, side-A slots [1,0,1], seed 2016, 1600 rounds,
clinched in two) and writes `tests/fixtures/replay-bc16.json`, 49 471 bytes.

It carries ALL THIRTEEN beat kinds, so `tests/test_bc16_beats.nim` proves
emission, label and CSS together against a committed artefact rather than
inventorying a hand-written list -- which is the shape the bc25 run failed
review on.
bc16 bumps `GameVersion` to GV11 (GV10 and every earlier value stay in
`ReplayCompatibleGameVersions`, so an older replay still loads). The two
committed sibling fixtures were recorded at GV10 and are regenerated with
their own in-tree generators; `test_bc22_beats`, `test_bc23_beats` and
`test_bc23_replay` are unchanged and still green.

`tests/test_bc22_replay.nim` asserted the literal string "GV10". A literal
cannot survive an authorised version bump, so it is replaced by MORE checks,
not fewer: the fixture's version must equal `GameVersion`, must be a real
headline version, must be in `ReplayCompatibleGameVersions`, and that list must
still be at least 8 long and still contain GV04.
New: `bc16_fixture.nim` (the shared builder), `table_bc16_delay.nim` (the
engine's whole delay-decay domain), and 22 `test_bc16_*.nim` shards covering
the cooldown pair, the unit table, rubble, infection, the zombie AI, exec
order, sensing, the economy, signals, neutral activation, the end ladder,
scoring, the maps, the sheet, greenhorn, the baselines, survival, the knobs,
the arithmetic, the beats, the replay document and the perf gate.

TWO GATES ARE WORTH NAMING.

`test_bc16_knobs.nim` proves every one of the eleven knobs has teeth in a
NAMED, SIGNED direction over a 72-game sweep, and asserts across the whole
sweep that both seats always built >= 10 units and dealt >= 500 damage -- the
anti-inert rule, because no setting of any knob may produce a self-starving
faction.

`test_bc16_survival.nim` commits the MEASURED survival ratio (3 of 6 mirror
games not `archons_destroyed`, 20 dens killed, median 2147 rounds) rather than
the assumed one, with BOTH measurement tables inline in its header, and it
carries a `-d:bc16BrokenChassis` subprocess negative control that must come
back RED -- verified at 15 failures. A horde that overwhelms an unbroken den
field is authentic 2016, so the gate's anti-degeneracy work is done by
substance floors rather than by the ratio alone.

Extended: `test_manifest.nim`, `test_viewer.nim`, `test_constants.nim`,
`test_sheet.nim` and `test_determinism.nim` -- the last of which asserts bc16's
THREE INDEPENDENT `java.util.Random` STREAMS are three separate objects with
separate 48-bit states, and that 2016's `IDGenerator` mints from 1 while every
other year's still starts above the 10 000 floor.

`test_bc16_beats.nim` additionally asserts each beat label against the VALUE in
the event that produced it, not just against the words -- the assertion that
would have caught the `zombie_wave`/`outbreak` field-name defect.
`.github/workflows/ci.yml` goes to 11 jobs.

NEW JOB `parity-oracle-bc16` (Temurin 8, and it asserts that `javac` REJECTS
`--release` so the check cannot pass on a mislabelled toolchain): jar fetch and
three-pin verify, Tier B part 1 (all 42 `GameConstants` fields cross-checked
against the jar's own loaded classes, naming each deliberately-unported
constant rather than skipping it silently), Tier B part 2 (the whole finite
arithmetic domain, BYTE-DIFFED against `data/bc16/tables.json`), Tier B part 3
(all 22 maps' symmetry and per-den zombie split, read out of the JVM's own
`GameMap` -- the step that caught the `HashMap.merge` defect), eighteen whole
games, the anti-vacuity check, Tiers A/A"/C, and an evidence artefact.

The anti-vacuity floors are MEASURED, not guessed. Neither oracle bot survives
the 3000-round cap -- an idle archon line is eaten by the horde -- so the games
run 298-1424 rounds and the traces compare the end round, the winner and the
domination factor as well. Measured: 36-162 peak robots, 735 peak zombies
summed over the eighteen pairs, 18 of 18 saw an infection, and peak bytecode 1
and 294 against limits of 20 000 and 10 000, i.e. 0 % and 2 %, so neither bot
ever leaves the `1.0` branch of the engine's `amountToDecrement` and the V1
comparison is defined to the last round.

EXISTING GATES gain bc16: the branch trigger; the `BC16_COMMIT`,
`BC16_CLIENT_COMMIT` and `BC16_JAR_URL` env; the test job's timeout 130 -> 150;
the four generated-file checks (`gen_year_constants --check`, `convert_maps
--check`, `--parse-all` expecting `98 official .xml files parsed, 2 refused`,
`atlas --check`); an eighth docker-smoke episode (seed 2016004 draws `river`,
900 rounds, per-seat floors on units, damage and parts and across-pair floors
on infections, turned, zombie damage, spawned and outbreaks -- every one
measured); an EIGHT-replay check; the wasm viewer at `--timeout 120 --soak 15`;
and `wasm_replay_smoke` on both the bc16 smoke replay and the committed
fixture.

TIER A' IS NOT RUN. The design note specified four scenario bots to force the
rare end-ladder rungs. The Nim half of the base bot exists and works; the Java
half is not written and the three sub-variants exist on neither side, so the
job compares Tier A and Tier A" only. `docs/PARITY.md` §bc16 names the exact
shape of the gap rather than leaving coverage to be inferred.
* `docs/RULES-BC16.md` -- new. The game, the round loop as the step list, the
  four-rung ladder, the verbatim unit table, the delay pair, the zombie AI, the
  eleven knobs, the scoring, a twenty-item §Divergences list and a §Corrections
  section recording four places the design note's arithmetic was wrong against
  the JVM (`2.0 - 0.01*137` is 0.6299999999999999 not 0.63; `60*1.1` is 66.0;
  `145*(1.0/3.0)` is 48.33333333333333; 100 rubble takes 8 clears and 1000
  takes 35, not 14 and 55; and `directionTo(self)` is OMNI, not NONE).
* `NOTICE` -- the four sections the year needs. bc16 is the FIRST year here
  whose upstreams are GPL-3.0 rather than AGPL-3.0 and there are two of them,
  so the file records why the combined work stays AGPL-3.0, names
  `battlecode-server-2016` @ 11a0b09f and `battlecode-client-2016` @ 317e1f3f
  by directory and file family, names the CI-only jar, and names
  `TheDuck314/battlecode2016` and `bshimanuki/battlecode2016` -- both of which
  carry no licence anywhere -- as not cloned, not read and not vendored.
* `docs/PARITY.md` §bc16 -- the oracle, the three defects it found, the
  Temurin-8 requirement and its three independent defences, the
  `NullControlProvider` requirement, the tier table, the measured anti-vacuity
  numbers, and Tier A' NAMED AS NOT IMPLEMENTED with the exact shape of the gap.
* `docs/PROTOCOL.md` §bc16 -- the observation, key by key, and the reply.
* `docs/REPLAY.md` §bc16 -- the event vocabulary with its per-game bounds, the
  three name tables, the four kinds bc16 shares with other years, and why the
  outbreak multiplier travels as an integer per-mille.
* `README.md` -- the variant row, the module paths, the docs link and the 2016
  licence trail, including that the official 2016 specification is LOST so the
  engine source is the spec.
* `docs/plans/2026-09-09-battlecode-2016-design.md` -- the accepted note.
…t mask

Two defects the first CI run found, both in `parity-oracle-bc16`'s and the
test job's plumbing rather than in the year module.

* `convert_maps_bc16.py --parse-all` prints its per-map table on **stdout** and
  its summary line on **stderr**, so `--parse-all | tee /tmp/bc16parse.txt`
  captured the table and lost the one line the next `grep -q` looks for. The
  step failed while the log showed `98 official .xml files parsed, 2 refused`
  three lines above the error. Now `2>&1 | tee`, with the reason in a comment.
* The trace-upload step is `if: always()`, so it runs after an earlier step
  failed -- at which point `/tmp/oracle16/traces` does not exist and its `cd`
  fails the job a SECOND time, burying the real error under a missing
  directory. It now reports that no traces were produced and exits 0.
The first CI run failed the Tier B byte-diff on exactly one line of a 304 KB
file: `pow_1_5`. Nothing else differed. Root cause, and it is not this port's.

`InternalRobot.decrementDelays` calls `Math.pow`. The JLS permits `Math.pow` to
be up to **1 ulp** from the exact result and requires only semi-monotonicity,
so it is not reproducible between JDK builds -- and it is not: the table was
generated on Temurin **8u422** and the runner has Temurin **8u452**.

`StrictMath.pow` must reproduce fdlibm bit for bit on every conforming JVM, so
`tools/JavaBc16Tables.java` now tables `StrictMath.pow` and records that in the
document's own new `pow_1_5_source` key. **The byte-diff therefore stays
absolute for the whole file** -- and a new BLOCKING step adds the assertion the
change would otherwise have lost: the RUNNING JDK's `Math.pow`, the call the
engine actually makes, is within one ulp of EVERY tabled value, and the step
prints the census. That is strictly more checking than a `Math.pow` byte-diff,
not less.

Measured over the whole 8 001-value domain, none of it reachable in this
coworld because V1 pins the expression to its `pow(0, 1.5) = 0` branch where
all three implementations are exact:

  Temurin 8u422 Math.pow vs StrictMath.pow   7 221 exact,  780 one ulp, 0 worse
  glibc pow (the port)  vs StrictMath.pow    7 220 exact,  781 one ulp, 0 worse
  glibc pow             vs 8u422 Math.pow    7 996 exact,    5 one ulp, 0 worse

`tests/table_bc16_delay.nim` asserts the second row (it asserted the third
before), `docs/PARITY.md` §bc16 carries the table and the reasoning, and
`docs/RULES-BC16.md` V1 records that the sub-1.0 branch could not have been
compared bit-exactly even in principle.
bc22's `knobs.nim` exports constants of exactly these names,
`battlecode/sheet.nim` re-exports both years' knob modules, and BOTH MODULES
ARE CALLED `knobs` -- so `RetreatHpLo` became an ambiguous identifier in any
test that pulls in a year fixture as well as `sheet`, and
`tests/test_bc22_sheet.nim` stopped compiling.

bc22 had the name first and nothing outside `years/bc16/knobs.nim` reads
bc16's pair, so the fix is to stop exporting them rather than to rename bc22's
or to edit a bc22 test. The comment at the constants says why, so the next year
with a `retreat_hp` knob does not rediscover it.
The converter writes `RobotType`'s own SCREAMING names into the committed map
JSON (`STANDARDZOMBIE`), and the probe's Python half looked up
`standardzombie`. Every schedule row therefore read back as all-zero, every row
was dropped as empty, and the step reported `ours None` against a real JVM row
on all 110 dens of all 22 maps -- a comparison that could never have passed and
never did.

Verified locally against the pinned jar under Temurin 8, not only reasoned
about: `compared 22 symmetries and 110 per-den schedules against the JVM's own
GameMap`, exit 0.
@daveey
daveey merged commit fbc7d34 into main Sep 9, 2026
14 of 23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant