OpenRA-Bench / ENGINE_AUDIT.md
yxc20098's picture
Phase 1 engine audit: ENGINE_AUDIT.md + bench-side closures
c634971
|
Raw
History Blame Contribute Delete
22 kB
# ENGINE_AUDIT โ€” Phase 1
End-to-end completeness audit of every engine verb + observation
field, written 2026-05-22 against `OpenRA-Rust@engine-feature-wave`
HEAD `a5014a5` and `OpenRA-Bench@pr13-revised`.
Scope: pin tests for every gap that scripted-policy validation has
caught, audit advanced-feature surface coverage (Rust + Python),
audit observation completeness, and verify the
`Command::*` โ†” Python static โ†” agent.py tool-entry surface is
complete.
---
## 1. Engine gaps โ€” status
| # | Gap | Status | Pinning |
|---|-----|--------|---------|
| 1 | `place_building('proc')` auto-spawned harv at lowest-id proc (not new one); `find_refinery` returned lowest-id proc unconditionally โ‡’ 2nd refinery far from 1st added no throughput | **FIXED** in `openra-sim/src/world.rs` this phase. Added `spawn_unit_near_building(unit_type, owner, building_id)` (anchors scan on the NEW building's footprint), `find_refinery_from(owner, from_cell)` (path-shortest with Chebyshev fallback), and rewired `order_place_building` + `harvester_start_delivery` stale-id resolve to use them. Existing harvs do NOT re-snap (only stale-id resolve calls the path-shortest helper); to reroute live harvs the agent must `set_primary` on the new proc or sell the old one. | `OpenRA-Rust/openra-sim/tests/test_proc_auto_spawn_at_new_proc.rs` (1 test) + `OpenRA-Bench/tests/test_proc_auto_spawn_python.py` (1 test) |
| 2 | Thief `Infiltrate` drained 0 cash because `enemy: {cash: N}` was historically ignored | **FIXED upstream** (per-player starting cash plumb landed in commit `a5014a5`). Verified `tests/test_per_player_starting_cash.py` passes against the rebuilt wheel. Engine + Rust + Python tests all green. | `OpenRA-Rust/openra-sim/tests/test_per_player_starting_cash.rs` (3) + `OpenRA-Rust/openra-data/tests/test_per_player_starting_cash.rs` (4) + `tests/test_per_player_starting_cash.py` (2) |
| 2b | Thief `Infiltrate` against any non-`proc`/`silo` building is a no-op (engine match-arm intent) | **INTENT โ€” DOCUMENTED**. Tool description in `agent.py` already states "thief drains a chunk of the target owner's cash to your player (only when the target is a proc or silo)". Added a note to bench `CLAUDE.md` engine-footguns block. | `openra-sim/tests/test_infiltrate.rs::thief_infiltration_steals_enemy_cash` covers the proc-targeted happy path. |
| 3 | Stance:0 (HoldFire) units don't return fire even when attacked โ‡’ defenders die silently | **INTENT โ€” already-pinned**; added an explicit defender-perspective note to bench `CLAUDE.md` so pack authors don't author defense scenarios that silently lose to a stall policy. | `openra-sim/tests/test_stance_semantics.rs::test_stance_0_holds_fire` |
| (bonus, found this audit) | `fire_superweapon` had no `agent.py` tool entry โ€” model couldn't issue superweapon orders | **FIXED** โ€” added `_TOOL_SCHEMAS["fire_superweapon"]` with `kind / target_x / target_y / target_id` parameter schema + a `_to_commands` case that maps `(target_x, target_y) โ†’ cell tuple` and forwards `target_id` as string. Bumped `tests/test_tools.py::test_wildcard_exposes_everything` from 21 โ†’ 25 (covers every Command variant now). | `OpenRA-Bench/tests/test_superweapons_python.py` (4 tests: nuke / iron / chrono / launcher-validation) โ€” none existed pre-audit. |
| (bonus, found this audit) | No Rust unit test exercised the full APC `EnterTransport โ†’ Move โ†’ Unload` loop end-to-end | **FIXED** โ€” added a single integration test that boards an e1 into an APC, drives ~30 cells east, unloads, and asserts the passenger lands within 4 cells of the destination and is back in the active actor map. | `OpenRA-Rust/openra-sim/tests/test_apc_transport.rs` (1) + `OpenRA-Bench/tests/test_apc_transport_end_to_end.py` (1) |
### Pre-existing failures observed during audit (NOT caused by this phase's changes)
These were already failing on `engine-feature-wave` HEAD when I
checked out the branch. Documented here so they aren't conflated
with this phase's diff:
- `openra-sim` lib test `gamerules::tests::defaults_have_all_common_units` โ€” MCV vs Vehicle kind classification regression.
- `openra-sim` integration tests `sync_hash_verify` + `debug_sync` โ€” sync-hash reference fixtures are stale and need regeneration after the recent engine merges (`a5014a5`, `2a1cd30`, `9f2181b`, `0a13243`, `b828c3b`).
- `OpenRA-Bench/tests/test_parallel_production.py::test_two_war_factories_outproduce_one` โ€” a single war factory produces 0 tanks in the test budget (not 1+). Loop times out; `place_building` reports `PLACE BLOCKED: pbox not completed in queue` repeatedly in the related `test_pbox_fires.py`. The shared symptom suggests a production-queue advance regression in one of the recent merges (`order_place_building.has_completed` evaluates false even after the build timer expired). Out of scope for Phase 1; flagged for Phase 2.
- `OpenRA-Bench/tests/test_pbox_fires.py::test_built_pbox_kills_enemy_e1` โ€” same root cause as parallel_production (pbox never gets placed, so it never fires).
---
## 2. Advanced-feature pinning matrix (verb ร— Rust ร— Python)
`Command::*` = the engine verb in `openra-train/src/command.rs`.
A โœ“ in "Rust" means there is at least one `cargo test`-runnable
test in `openra-sim/tests/` or `openra-data/tests/` that exercises
the order through `process_frame`. A โœ“ in "Python" means there is
a `pytest`-runnable test in `OpenRA-Bench/tests/` that exercises
the order through `Command.<verb>` + the `RustEnvHandle.step`
boundary.
| Verb | Rust pinning test | Python pinning test |
|------|-------------------|---------------------|
| `MoveUnits` | `openra-sim/tests/move_activity_replay.rs` + `parity_move_vs_csharp.rs` | `tests/test_resource_economy.py`, many combat packs |
| `AttackUnit` | `openra-sim/tests/test_attack_unit_no_teleport.rs` + `combat_one_v_one.rs` | many combat tests (`test_combat_*.py`) |
| `AttackMove` | covered via combat scenarios | covered via combat scenarios |
| `Guard` | covered via Move tests (Guard is follow-subset) | `tests/test_combat_protect_vip_escort.py` |
| `SetPrimary` | (no dedicated test โ€” exercised via `primary_buildings` set / `find_spawn_location` sort key) | `tests/test_repair_building_id.py` |
| `EnterTransport` | **NEW: `openra-sim/tests/test_apc_transport.rs`** | **NEW: `tests/test_apc_transport_end_to_end.py`** |
| `Unload` | **NEW: `openra-sim/tests/test_apc_transport.rs`** | **NEW: `tests/test_apc_transport_end_to_end.py`** |
| `Stop` | covered via Move tests | covered |
| `Deploy` | (covered via env-level integration) | `tests/test_mcv_deploy.py`, `test_mcv_deploy_*.py` |
| `Build` | `openra-sim/tests/test_parallel_production.rs` | `tests/test_parallel_production.py` (PRE-EXISTING FAILURE โ€” see ยง1) |
| `CancelProduction` | (no dedicated test; verb has small surface โ€” refunds last-queued item) | โ€” |
| `PlaceBuilding` | **NEW: `openra-sim/tests/test_proc_auto_spawn_at_new_proc.rs`** + `openra-sim/tests/test_pbox_fires.rs` | **NEW: `tests/test_proc_auto_spawn_python.py`** + `tests/test_build_*.py` packs |
| `Harvest` | `openra-sim/tests/test_resource_layer.rs` | `tests/test_resource_economy.py`, `test_economy_harvest.py` |
| `Sell` | (no dedicated test; exercised by `tests/test_maint_sell_and_recoup_cash.py`) | `tests/test_maint_sell_and_recoup_cash.py`, `test_build_sell_and_rebuild_elsewhere.py` |
| `Repair` | (no dedicated rust test; covered by repair pack tests) | `tests/test_build_repair_priority_under_fire.py`, `test_def_engineer_repair_under_fire.py`, `test_repair_building_id.py` |
| `PowerDown` | `openra-sim/tests/test_power_signals.rs` | `tests/test_power_signals_python.py`, `test_build_power_down_defensive.py` |
| `SetRallyPoint` | (no dedicated test; covered by rally-point pack tests) | `tests/test_build_rally_point_management.py` |
| `SetStance` | `openra-sim/tests/test_stance_semantics.rs` (4 tests) | `tests/test_stance_semantics_python.py` (4 tests) |
| `Patrol` | (no-op verb โ€” accepted, no behaviour) | โ€” |
| `Surrender` | (covered via env-level integration) | `tests/test_surrender.py` |
| `Observe` | covered everywhere (no-op verb) | covered everywhere |
| `C4Detonate` | `openra-sim/tests/test_tanya_c4.rs` (3 tests) | `tests/test_tanya_c4.py` (1) |
| `CaptureActor` | `openra-sim/tests/test_capture.rs` (3) | `tests/test_engineer_capture.py` (1) |
| `Infiltrate` | `openra-sim/tests/test_infiltrate.rs` (2) | `tests/test_infiltrate.py` (2) |
| `FireSuperweapon` | `openra-sim/tests/test_superweapons.rs` (5) | **NEW: `tests/test_superweapons_python.py` (4)** |
### Helicopter / Naval (transport-class verbs)
| Capability | Rust test | Python test | Status |
|------------|-----------|-------------|--------|
| Helicopter pickup / drop (passenger carry) | n/a โ€” engine `transport_capacity()` advertises `tran` (chinook) at 5 but the C# `Cargo` integration for helicopters is NOT wired into the Move activity (helicopters use `Aircraft` kind, not the ground-transport Mobile-board path) | n/a | **GAP โ€” DOCUMENTED**. Helicopters can attack ground targets (covered by `test_aircraft.rs::heli_flies_over_impassable_terrain`, `heli_kills_vehicle_behind_obstacle_wall`) but cannot carry passengers. Bench scenarios must not declare helicopter transport as a load-bearing capability. |
| Naval landing craft (LST) ship-to-shore unload | engine `transport_capacity()` advertises `lst` at 5; no dedicated test pins ship-to-shore unload | n/a | **GAP โ€” DOCUMENTED**. The `EnterTransport` activity tick uses `find_path` (ground), not naval; an infantry trying to board an LST in deep water cannot path there. The LST itself moves on water via `find_path_for_kind(naval=true)`. End-to-end ship-to-shore requires either (a) the LST docking adjacent to a shore cell the infantry can reach by land, or (b) an unload-while-on-water followed by a sink. Not currently exercised by any test. Flagged for Phase 2. |
---
## 3. Observation completeness matrix
`obs key` = the field on the `PyDict` returned by `OpenRAEnv.step`
(see `openra-train/src/observation.rs::to_pydict`). "Present" = the
field exists in every observation; "Tested" = at least one test
asserts on its value.
| Obs key | Present | Tested | Notes |
|---------|---------|--------|-------|
| `unit_positions` (own units `{id โ†’ {cell_x, cell_y, actor_type, activity, target?, attacking_target_id?}}`) | โœ“ | โœ“ | `actor_type` enables `unit_type_count_*` predicates; `attacking_target_id` distinguishes Attack from Move. |
| `unit_hp` (`{id โ†’ hp_fraction}`) | โœ“ | โœ“ | Adapter surfaces as `units_summary[].hp`. |
| `enemy_positions` (visible enemy mobile actors `[{cell_x, cell_y, id, actor_type}]`) | โœ“ | โœ“ | Fog-filtered through player_0's shroud. |
| `enemy_hp` (`{id โ†’ hp_fraction}`) | โœ“ | โœ“ | |
| `enemy_buildings_summary` (`[{cell_x, cell_y, id, type, hp_pct}]`) | โœ“ | โœ“ | Adapter merges into `enemy_summary` for the briefing. `hp_pct` is per-building 0..1. |
| `units_killed` (cumulative int) | โœ“ | โœ“ | Drives `units_killed_gte` predicate. |
| `game_tick` (int) | โœ“ | โœ“ | |
| `explored_percent` (float 0..100) | โœ“ | โœ“ | Drives `explored_percent_gte` predicate. |
| `explored_cells` (`[(x,y)]`) | โœ“ | โœ“ | Sticky per-cell reveal set. |
| `economy.cash` | โœ“ | โœ“ | Per-player; adapter surfaces as `cash`. |
| `economy.power_provided` / `power_drained` | โœ“ | โœ“ | `power_provided_gte` + `power_surplus_gte` predicates. |
| `economy.harvesters` (count int) | โœ“ | (covered indirectly via `units_summary` actor_type) | Standalone count; no dedicated `harvester_count_*` predicate today. |
| `economy.resources` / `resource_capacity` | โœ“ | โœ“ | `resources_full_pct` style predicates use these. |
| `own_buildings` (`[{id, type, cell_x, cell_y, hp_pct, is_primary}]`) | โœ“ | โœ“ | `id` is the REAL engine actor id (footgun closed in prior phase). |
| `production` (`[{item, progress, done}]`) | โœ“ | (partial) | Adapter currently collapses to `production_items: [str]`, dropping `progress` / `done`. The `done` flag IS in the raw obs (used by `tests/test_proc_auto_spawn_python.py` directly via `obs["production"]`); the adapter loss is by design (briefing simplicity). **GAP**: the briefing-level production view doesn't surface ETA; the model can see what's queued but not when it lands. |
| `map_info` (`{width, height}`) | โœ“ | โœ“ | Drives bounds-correct minimap rendering. |
| `spatial` (flat row-major `[y][x][c]` with `c=6`) + `spatial_shape` (h,w,c) | โœ“ | โœ“ | Channels: `0` passable, `1` fog (1 visible / 0.5 explored / 0 unknown), `2` own-unit density, `3` visible-enemy-unit density, `4` own building, `5` resource present. `SPATIAL_CHANNELS = 6` constant in `observation.rs`. Documented in `observation.rs` doc-comment. |
| `ore_cells` (`[{cell_x, cell_y, amount}]`) | โœ“ | โœ“ (`test_resource_economy.py`) | Global (NOT fog-gated) per-cell ore inventory. |
| minimap PNG | โœ“ (rendered by bench `minimap.py`) | โœ“ (`tests/test_minimap.py`, `test_battle_viewer.py`) | Bench-side; not in the raw obs dict but produced by `_render_minimap_b64` in `agent.py`. |
| bounds (playable rectangle) | โœ“ (via `map_info`) | โœ“ | |
| `enemy_summary` (broader enemy-actor list including units) | โœ“ via adapter `render_state()` (it concatenates `enemy_positions` + `enemy_buildings_summary` with `is_building` flag) | โœ“ | This is bench-side composition, not a raw-obs key. |
### Observation gaps flagged
1. **`production` ETA**: the briefing-level `production` field loses `progress` / `done` because `RustObsAdapter` collapses to a list of item names. For a "what's coming online next?" planning prompt, the model has to estimate from cash deltas. Recommend either surfacing the per-item ETA in `render_state()` or documenting that the model must use the raw obs.
2. **`spatial` documented but discoverability is low**: `SPATIAL_CHANNELS = 6` lives in the engine doc-comment; the bench doesn't surface a schema describing what channel means what. Add a sentence to `agent.py::build_briefing` or the prompt-v2 system text.
3. **Helicopter cargo & LST ship-to-shore unload**: out of scope for the observation pass but listed under ยง2 โ€” neither is exercised today.
---
## 4. Command surface matrix (Rust variant ร— Python static ร— agent tool entry)
Cross-check of `Command::*` in `openra-train/src/command.rs` against
`PyCommand` staticmethods (same file) against `_TOOL_SCHEMAS` and
`_to_commands` in `openra_bench/agent.py`.
| Rust variant | Python staticmethod | `_TOOL_SCHEMAS` entry | `_to_commands` case | Notes |
|--------------|---------------------|-----------------------|---------------------|-------|
| `MoveUnits` | `move_units` | โœ“ `move_units` | โœ“ | |
| `AttackUnit` | `attack_unit` | โœ“ `attack_unit` (+ alias `attack_target`) | โœ“ | |
| `AttackMove` | `attack_move` | โœ“ `attack_move` | โœ“ (generic case for `attack_move` / `harvest` / `set_rally_point`) | |
| `Guard` | `guard` | โœ“ `guard` | โœ“ | |
| `SetPrimary` | `set_primary` | โœ“ `set_primary` | โœ“ (generic `unit_ids` case) | |
| `EnterTransport` | `enter_transport` | โœ“ `enter_transport` | โœ“ | |
| `Unload` | `unload` | โœ“ `unload` | โœ“ (generic `unit_ids` case) | |
| `Stop` | `stop` | โœ“ `stop` (+ alias `stop_units`) | โœ“ | |
| `Deploy` | `deploy` | โœ“ `deploy` | โœ“ | |
| `Build` | `build` | โœ“ `build` | โœ“ | |
| `CancelProduction` | `cancel_production` | โœ“ `cancel_production` | โœ“ | |
| `PlaceBuilding` | `place_building` | โœ“ `place_building` | โœ“ | |
| `Harvest` | `harvest` | โœ“ `harvest` | โœ“ (generic case) | |
| `Sell` | `sell` | โœ“ `sell` | โœ“ | |
| `Repair` | `repair` | โœ“ `repair` | โœ“ | |
| `PowerDown` | `power_down` | โœ“ `power_down` | โœ“ | |
| `SetRallyPoint` | `set_rally_point` | โœ“ `set_rally_point` | โœ“ (generic case) | |
| `SetStance` | `set_stance` | โœ“ `set_stance` | โœ“ | |
| `Patrol` | `patrol` | โœ“ `patrol` | โœ“ | No-op verb in engine. |
| `Surrender` | `surrender` | โœ“ `surrender` | โœ“ | |
| `Observe` | `observe` | โœ“ `observe` (always force-included by `_tool_schemas`) | โœ“ | |
| `C4Detonate` | `c4_detonate` | โœ“ `c4_detonate` | โœ“ | |
| `CaptureActor` | `capture_actor` | โœ“ `capture_actor` | โœ“ | |
| `Infiltrate` | `infiltrate` | โœ“ `infiltrate` | โœ“ | |
| `FireSuperweapon` | `fire_superweapon` | **โœ“ `fire_superweapon` (ADDED THIS PHASE)** | **โœ“ (ADDED THIS PHASE)** | Previously: tool entry was missing โ€” the model could not fire superweapons even on a scenario that exposed `tools: ["*"]`. Fixed. |
**Total: 25 enum variants, 25 Python staticmethods, 25 tool entries, 25 `_to_commands` cases.** Bumped `tests/test_tools.py::test_wildcard_exposes_everything` from 21 โ†’ 25 (was already out of sync before this phase).
---
## 5. Prioritized fix queue
In rough priority order (P0 = scenario-blocking, P3 = nice-to-have):
### P0 โ€” scenario-blocking
1. **`place_building` "completion" race regression** โ€” the pre-existing failures in `tests/test_parallel_production.py` and `tests/test_pbox_fires.py` (engine logs `PLACE BLOCKED: <type> not completed in queue` even after the build timer should have expired) point at a regression in `order_place_building`'s `is_done()` check or in the production-queue tick advance. Likely landed in one of the recent merges (`2a1cd30` naval, `9f2181b` air, `0a13243` resource, `b828c3b` superweapon). Affects every build-and-place scenario.
- **Who-affected**: every `build-*` pack and the parallel-production / pbox guardrails.
- **Effort**: ~half day to bisect the merge that introduced it + targeted fix.
### P1 โ€” capability gap closing real-world packs
2. **Helicopter passenger carry** โ€” `transport_capacity("tran") == 5` but `EnterTransport` path uses ground pathfinding only; a `tran` actor cannot actually board passengers via the `Mobile` activity tick. Either implement aircraft-load (Aircraft kind needs its own board tick), or drop `tran` from `transport_capacity` to make the no-op explicit.
- **Who-affected**: any scenario that wants helicopter insert/extract (none today, but the bench has at least three drafted heli scenarios).
- **Effort**: 1โ€“2 days (aircraft activity surface).
3. **Naval landing craft (LST) ship-to-shore unload** โ€” `transport_capacity("lst") == 5` but the boarding path requires the passenger to reach the LST cell via ground pathfind; on water that's impossible. The C# parity here is "infantry boards at shore + LST docks โ†’ infantry rides + LST unloads back on shore". Needs either a shore-adjacency rule for `EnterTransport`, or an explicit `Dock` activity that puts the LST adjacent to a shore cell before boarding.
- **Who-affected**: naval scenarios (none today; was an aspirational pack).
- **Effort**: 2 days (touches the EnterTransport tick + naval move).
### P2 โ€” observability
4. **Production ETA surfacing in `render_state()`** โ€” adapter collapses `production` to item-name list; the briefing can't say "tank in 4 turns" without the model reading the raw obs. Surface as `production: [{item, eta_ticks, done}]` in `RustObsAdapter.render_state()`.
- **Who-affected**: every reasoning pack ("how many turns of cash do I have to spare?").
- **Effort**: ~1 hour.
5. **Spatial-tensor channel schema in prompt-v2** โ€” `SPATIAL_CHANNELS = 6` is documented in engine code but not in the system prompt the model sees. Add a one-line description to `briefing_image_primary` so an image-channel model knows what each plane means.
- **Who-affected**: `image-*` perception ablation cells.
- **Effort**: ~30 minutes.
### P3 โ€” small footguns
6. **`find_refinery_from` fallback when no path exists** โ€” currently falls back to Chebyshev-nearest then lowest-id. If the only proc has a path-blocked footprint (e.g. surrounded by walls), the harv binds anyway and then deadlocks. Could surface a warning in `last_warnings`.
- **Effort**: ~1 hour.
7. **Existing harvesters do NOT re-snap to the new proc after `place_building('proc')`** โ€” by design (avoids churning a stable supply chain), but documented as a footgun in bench `CLAUDE.md`. If the pack wants per-base supply chains, the model has to `set_primary` on the new proc.
- **Effort**: 0 โ€” already documented.
8. **`SetPrimary` lacks a dedicated Rust unit test** โ€” exercised indirectly via `find_spawn_location`'s `primary_buildings` sort key but never in isolation.
- **Effort**: ~1 hour to add.
9. **`CancelProduction` lacks any dedicated test** (Rust or Python). Small verb surface, but a model that frees up cash by cancelling the last queued item should be pinned.
- **Effort**: ~1 hour to add.
---
## Files touched in Phase 1
### Engine (rebuilt the wheel via `maturin develop --release`; verified `Installed openra_train` printed)
- `openra-sim/src/world.rs` โ€” added `spawn_unit_near_building`, `find_refinery_from`, `spawn_unit_at`; refactored `spawn_unit` to share `spawn_unit_at`; wired `order_place_building` proc-harv auto-spawn to use the new helpers; wired `harvester_start_delivery` stale-id resolve to prefer path-shortest.
### New tests
- `OpenRA-Rust/openra-sim/tests/test_proc_auto_spawn_at_new_proc.rs` โ€” 1 test
- `OpenRA-Rust/openra-sim/tests/test_apc_transport.rs` โ€” 1 test
- `OpenRA-Bench/tests/test_proc_auto_spawn_python.py` โ€” 1 test
- `OpenRA-Bench/tests/test_apc_transport_end_to_end.py` โ€” 1 test
- `OpenRA-Bench/tests/test_superweapons_python.py` โ€” 4 tests
### Bench surface
- `OpenRA-Bench/openra_bench/agent.py` โ€” added `fire_superweapon` tool entry + `_to_commands` case.
- `OpenRA-Bench/tests/test_tools.py` โ€” corrected `test_wildcard_exposes_everything` expectation (21 โ†’ 25).
- `OpenRA-Bench/CLAUDE.md` โ€” appended footgun bullets for proc auto-spawn, thief Infiltrate intent, stance:0 defender silent-death intent, per-player cash plumbing, `fire_superweapon` Python surface.
- `OpenRA-Bench/ENGINE_AUDIT.md` โ€” this file.
All changes uncommitted per the Phase 1 constraint.