Metadata-Version: 2.4
Name: ctf-analysis
Version: 0.2.0
Summary: Spatial analysis of Coworld CTF replays: mining, arena geometry, map layers.
License: MIT
Requires-Python: >=3.12
Provides-Extra: analysis
Requires-Dist: matplotlib>=3.8; extra == 'analysis'
Requires-Dist: numpy>=2.0; extra == 'analysis'
Requires-Dist: polars>=1.0; extra == 'analysis'
Requires-Dist: pyarrow>=15.0; extra == 'analysis'
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: scikit-learn>=1.4; extra == 'dev'
Requires-Dist: scipy>=1.11; extra == 'dev'
Provides-Extra: gpu
Requires-Dist: torch>=2.5; extra == 'gpu'
Provides-Extra: notebook
Requires-Dist: jupyter>=1.0; extra == 'notebook'
Requires-Dist: nbformat>=5.10; extra == 'notebook'
Description-Content-Type: text/markdown

# ctf-analysis

Spatial analysis of [Coworld CTF](https://github.com/Metta-AI/coworld-ctf) league
replays. Mines replays into per-tick state, models the arena's geometry on the
GPU, and bakes both into the map layers behind
**Analyzing the Geography of CTF** in `softmax-notebooks`.

## The one fact everything rests on

**A `.replay` holds only inputs and one 64-bit hash per tick.** No positions, no
events, no scores. All of that exists only after the game engine *re-simulates*
the recording — and only on the **engine build that recorded it**, which the
header pins as a `gameVersion` string. The per-tick hash is there to prove the
re-sim is bit-exact; a mismatch means the build is wrong and every number
downstream is fiction.

The league corpus this was built against is 995 replays split **GV22 (557) /
GV23 (438)**, all on the hand-tuned `arena` map. The GV22 and GV23 arenas are
byte-identical, so one terrain raster covers the whole corpus — but the two
engines are not, so both builds are needed.

## Pipeline

```
replays ──► mine_geo (Nim, per game version) ──► frames.bin + events.jsonl + meta.json
                                                          │
   arena mask ──► bake (GPU) ──► geometry.npz             ├──► aggregate ──► layers.npz
   (dump_geometry, Nim)          static fields            │                  episodes.parquet
                                                          │                  events.parquet
                                                          └──► routes / render
```

Expensive simulation once, cheap exploration forever.

### 0. Engines

Two git worktrees off the game repo, pinned at the last commit of each
GameVersion, then built `-d:release` (debug is 10–50× slower through the
per-pixel map code):

```sh
cd ~/Dev/coworld-ctf
git worktree add ~/Dev/ctf-gv22 d0f1827     # GameVersion "22"
git worktree add ~/Dev/ctf-gv23 feb2ef1     # GameVersion "23"

nimby --global sync nimby.lock              # nothing compiles until this runs
```

`nimby --global` installs to `~/.nimby/pkgs` without writing a config, so each
worktree needs a `nim.cfg` pointing at it (one `--path:` per package). Then, in
each worktree:

```sh
export PATH="$HOME/.nimby/nim/bin:$PATH"
for t in mine_geo dump_geometry los_probe; do
  nim c -d:release --hints:off -o:bin/$t tools/$t.nim
done
```

The three tools are added by this project and are copies of the same file in
both worktrees:

| tool | what it does |
|---|---|
| `mine_geo.nim` | re-simulates one replay, dumps per-tick per-seat state, the tier-2 event stream, and a meta record. Records hash divergence instead of dying on it. |
| `dump_geometry.nim` | exports the arena terrain raster from the sim's **own** `wallMask`, plus pedestals, spawns, capture zones, pickup points and tuning constants |
| `los_probe.nim` | answers line-of-sight queries with the sim's `lineOfSightClear`, so the offline model can be checked against the engine rather than against a re-reading of its source |

### 1–4. Mine, bake, aggregate

```sh
uv venv && uv pip install -e '.[analysis]'
uv pip install torch --index-url https://download.pytorch.org/whl/cu130   # optional

ctf-mine ~/ctf-replays data --engines ~/Dev        # ~16 s for 995 replays, 20 cores
ctf-geometry --assets assets --out data/geometry.npz
ctf-aggregate --out data/layers.npz
```

`ctf-mine` routes each replay to the build matching its header, shards across
processes, and reports hash-clean counts. Any divergence is printed as a loud
banner and the episode is dropped — never worked around.

## What is checked, and how

| claim | check |
|---|---|
| the re-sim is faithful | every episode's per-tick hash is validated; `index.json` records `hash_ok` |
| the frame dump is faithful | shot rays reconstructed from `fireWindup`/`windupBrads` are compared against the engine's own `Shot` events — tick, seat and position must match exactly |
| our line of sight is the engine's | `los_probe` answers random walkable pairs; agreement must be 100% |
| the terrain raster is the engine's | it *is* the engine's `wallMask`, split by `isArenaWindowPixel` |
| the map is fair | the raster is pixel-exact under the x-mirror, and every baked field is folded so ray-marching noise cannot masquerade as a red/blue difference |

## Two predicates, not one

The arena has two kinds of wall and the difference is the whole game:

- **stone** blocks movement, bullets *and* vision.
- **glass windows** block movement and bullets but are **transparent to fog**.

So there are two line-of-sight relations — `arena.opaque` (sight) and
`arena.solid` (fire) — and their difference is `glass_delta`: ground you can be
watched on but not shot on. The engine's `lineOfSightClear` is the *fire*
predicate; the fog grid drops window pixels before shadowcasting.

## Layout

```
src/ctf_analysis/
  replay.py     pure-stdlib header/record reader — no engine needed
  frames.py     the miner's binary frame format, plus shot-ray reconstruction
  mine.py       version-matched re-sim driver
  arena.py      terrain raster, landmarks, named zone decomposition
  geometry.py   line of sight, distance transforms, geodesics, visibility (GPU)
  bake.py       one-shot static field bake -> geometry.npz
  aggregate.py  corpus-wide map layers + tidy tables -> layers.npz
  routes.py     flag runs: carry spans, paths, route efficiency
  render.py     map layer drawing
notebooks/
  build_notebook.py   generates the shipped .ipynb (the notebook is a build artifact)
  build_bundle.py     packs the self-contained data bundle
```

GPU is a build-time accelerator only. Every routine falls back to numpy with
identical output, and the shipped notebook never touches CUDA — it loads
pre-baked grids.
