diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 3f99aa8..16070be 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -72,6 +72,18 @@ jobs:
- name: Spellcheck
run: nix develop -c codespell docs-source README.md
+ format:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+
+ - uses: cachix/install-nix-action@v27
+ with:
+ nix_path: nixpkgs=channel:nixos-unstable
+
+ - name: Check formatting
+ run: nix develop -c mdformat --check --wrap 120 docs-source README.md
+
deploy:
if: github.event_name == 'push'
needs: build
diff --git a/.gitignore b/.gitignore
index d0d0921..d0fee01 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,12 @@
# Sphinx build output
_build/
+_built/
.doctrees/
+# Generated light/dark drawio SVG exports (rendered at build time, see conf.py)
+docs-source/**/*.light.svg
+docs-source/**/*.dark.svg
+
# Python
__pycache__/
*.pyc
@@ -18,6 +23,10 @@ result-*
Thumbs.db
# Editors
-.vscode/
+.vscode/*
+!.vscode/settings.json
.idea/
*.swp
+
+# Claude Code local overrides
+.claude/settings.local.json
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..d520ecb
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,5 @@
+{
+ "editor.wordWrap": "wordWrapColumn",
+ "editor.wordWrapColumn": 120,
+ "editor.rulers": [120]
+}
diff --git a/README.md b/README.md
index ae8bb33..91e15ab 100644
--- a/README.md
+++ b/README.md
@@ -6,10 +6,12 @@
See the [Contributing Guide](CONTRIBUTING.md) and the [Code of Conduct](CODE_OF_CONDUCT.md)!
-RoboCup Small Size League docs. [Sphinx](https://www.sphinx-doc.org/) + [MyST](https://myst-parser.readthedocs.io/), deployed to GitHub Pages via Actions.
+RoboCup Small Size League docs. [Sphinx](https://www.sphinx-doc.org/) + [MyST](https://myst-parser.readthedocs.io/),
+deployed to GitHub Pages via Actions.
-This repository documents top level items, such as field architecture, new team guides, etc. Highly technical documentation regarding the usage of a
-league software tool should be in the repository for that tool. Any top level integration/usages guides would be welcome here.
+This repository documents top level items, such as field architecture, new team guides, etc. Highly technical
+documentation regarding the usage of a league software tool should be in the repository for that tool. Any top level
+integration/usages guides would be welcome here.
## Build
@@ -60,11 +62,17 @@ nix develop -c sphinx-build -b linkcheck docs-source _build/linkcheck
# spelling (codespell)
nix develop -c codespell docs-source README.md
+
+# formatting (mdformat, 120-col wrap)
+nix develop -c mdformat --check --wrap 120 docs-source README.md
```
+Auto-fix formatting: drop `--check` from the last command.
+
## Deploy
-`main` push → [`.github/workflows/docs.yml`](.github/workflows/docs.yml) builds + deploys via GitHub Pages Actions. Not RTD-hosted.
+`main` push → [`.github/workflows/docs.yml`](.github/workflows/docs.yml) builds + deploys via GitHub Pages Actions. Not
+RTD-hosted.
## Diff versions
@@ -73,6 +81,13 @@ scripts/diff-versions.sh v1.0.0 v1.1.0
scripts/diff-versions.sh v1.0.0 main rules.md
```
+## External Links Manual Checks
+
+If a website blocks bots, then the external links Ci check will fail. You can add these links to `linkcheck_ignore` in
+`conf.py`. Periodically, anyone can run `./scripts/reverify-ignored-links.sh` which will find a browser and open all the
+ignored links to be checked manually. This usually passes the cloudflare bot check. Currently no clear way to do this in
+CI.
+
## License
[CC BY 4.0](LICENSE).
diff --git a/docs-source/_static/camera-calc.css b/docs-source/_static/camera-calc.css
new file mode 100644
index 0000000..2c31405
--- /dev/null
+++ b/docs-source/_static/camera-calc.css
@@ -0,0 +1,39 @@
+.camera-calc { border: 1px solid var(--color-background-border, #ccc); border-radius: 6px; padding: 1em; margin: 1em 0; }
+.camera-calc-group { border: 1px solid var(--color-background-border, #ddd); border-radius: 6px; margin: 0 0 1em 0; padding: 0.75em 1em 1em; }
+.camera-calc-group legend { font-weight: bold; padding: 0 0.4em; }
+.camera-calc-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.75em; }
+.camera-calc-grid label { display: flex; flex-direction: column; font-size: 0.9em; gap: 0.25em; }
+.camera-calc-grid label > span:first-child { display: flex; align-items: center; height: 1.4em; gap: 0.3em; }
+.camera-calc-grid mjx-container {
+ vertical-align: middle !important;
+ min-height: 0 !important;
+ max-height: 1.4em;
+ overflow: hidden;
+}
+.camera-calc-grid input { padding: 0.3em; }
+.camera-calc-grid input:disabled {
+ background: var(--color-background-secondary, #e9e9e9);
+ color: var(--color-foreground-muted, #767676);
+ border-color: var(--color-background-border, #ccc);
+ cursor: not-allowed;
+ opacity: 0.85;
+}
+.camera-calc-radio { display: flex; gap: 1.5em; margin: 0.75em 0; font-size: 0.9em; flex-wrap: wrap; }
+.camera-calc-radio label { display: flex; align-items: center; gap: 0.4em; font-weight: normal; }
+.camera-calc-note { font-size: 0.85em; opacity: 0.75; margin: 0.25em 0 0; }
+.camera-calc button { margin-top: 0.25em; padding: 0.5em 1.2em; cursor: pointer; }
+.camera-calc-preset {
+ margin-top: 0 !important;
+ padding: 0.3em 0.7em !important;
+ font-size: 0.85em;
+ background: var(--color-background-secondary, #f0f0f0);
+ color: var(--color-foreground-primary, #000) !important;
+ border: 1px solid var(--color-background-border, #ccc);
+ border-radius: 4px;
+}
+.camera-calc-output { margin-top: 1em; }
+.camera-calc-output dt { font-weight: bold; }
+.camera-calc-output dd { margin: 0 0 0.6em 0; }
+.camera-calc-output .cc-pass { color: #1a7f37; }
+.camera-calc-output .cc-fail { color: #cf222e; }
+.camera-calc-output .cc-subhead { margin-top: 1em; padding-top: 0.5em; border-top: 1px solid var(--color-background-border, #ddd); }
diff --git a/docs-source/_static/camera-calc.js b/docs-source/_static/camera-calc.js
new file mode 100644
index 0000000..3d5a502
--- /dev/null
+++ b/docs-source/_static/camera-calc.js
@@ -0,0 +1,285 @@
+// Loaded site-wide via html_js_files; no-op on pages without the calculator.
+if (document.getElementById('cc-calc')) {
+
+function ccSensorMode() {
+ return document.querySelector('input[name="cc-sensor-mode"]:checked').value;
+}
+
+function ccCameraCount() {
+ return parseInt(document.querySelector('input[name="cc-n"]:checked').value, 10);
+}
+
+function ccLensMode() {
+ return document.querySelector('input[name="cc-lens-mode"]:checked').value;
+}
+
+function ccMarkerMode() {
+ return document.querySelector('input[name="cc-marker-mode"]:checked').value;
+}
+
+function ccLoadFieldPreset(w, h) {
+ document.getElementById('cc-tw').value = w;
+ document.getElementById('cc-th').value = h;
+}
+
+document.getElementById('cc-preset-a').addEventListener('click', function () {
+ ccLoadFieldPreset(13400, 10400);
+});
+
+document.getElementById('cc-preset-b').addEventListener('click', function () {
+ ccLoadFieldPreset(10400, 7400);
+});
+
+function ccUpdateDerivedSensorSize() {
+ if (ccSensorMode() !== 'pixel') return;
+ const rh = parseFloat(document.getElementById('cc-rh').value);
+ const rv = parseFloat(document.getElementById('cc-rv').value);
+ const px = parseFloat(document.getElementById('cc-px').value) / 1000; // µm -> mm
+ if (isNaN(rh) || isNaN(rv) || isNaN(px)) return;
+ document.getElementById('cc-sh').value = (rh * px).toFixed(3);
+ document.getElementById('cc-sv').value = (rv * px).toFixed(3);
+}
+
+function ccUpdateDerivedPixelSize() {
+ if (ccSensorMode() !== 'size') return;
+ const rh = parseFloat(document.getElementById('cc-rh').value);
+ const rv = parseFloat(document.getElementById('cc-rv').value);
+ const sh = parseFloat(document.getElementById('cc-sh').value);
+ const sv = parseFloat(document.getElementById('cc-sv').value);
+ if (isNaN(rh) || isNaN(rv) || isNaN(sh) || isNaN(sv) || rh === 0 || rv === 0) return;
+ // average of the two axes' implied pitch, since pixel size here assumes square pixels
+ const pxMm = ((sh / rh) + (sv / rv)) / 2;
+ document.getElementById('cc-px').value = (pxMm * 1000).toFixed(3); // mm -> µm
+}
+
+function ccUpdateDerivedFields() {
+ ccUpdateDerivedSensorSize();
+ ccUpdateDerivedPixelSize();
+}
+
+function ccSyncSensorModeDisabled() {
+ const isPixel = ccSensorMode() === 'pixel';
+ document.getElementById('cc-sh').disabled = isPixel;
+ document.getElementById('cc-sv').disabled = isPixel;
+ document.getElementById('cc-px').disabled = !isPixel;
+}
+
+document.querySelectorAll('input[name="cc-sensor-mode"]').forEach(function (radio) {
+ radio.addEventListener('change', function () {
+ ccSyncSensorModeDisabled();
+ ccUpdateDerivedFields();
+ });
+});
+
+['cc-px', 'cc-sh', 'cc-sv', 'cc-rh', 'cc-rv'].forEach(function (id) {
+ document.getElementById(id).addEventListener('input', ccUpdateDerivedFields);
+});
+
+// Browsers restore previously-checked radios on a plain page refresh, which
+// doesn't fire 'change' — resync disabled state to whatever's actually
+// checked now, not just the HTML's hardcoded default.
+ccSyncSensorModeDisabled();
+ccUpdateDerivedFields();
+
+function ccUpdateDerivedVarifocalRange() {
+ if (ccLensMode() !== 'focal') return;
+ const f = parseFloat(document.getElementById('cc-f').value);
+ if (isNaN(f)) return;
+ document.getElementById('cc-f-min').value = f;
+ document.getElementById('cc-f-max').value = f;
+}
+
+function ccUpdateDerivedFixedFocal() {
+ if (ccLensMode() !== 'varifocal') return;
+ const fMin = parseFloat(document.getElementById('cc-f-min').value);
+ const fMax = parseFloat(document.getElementById('cc-f-max').value);
+ if (isNaN(fMin) || isNaN(fMax)) return;
+ document.getElementById('cc-f').value = ((fMin + fMax) / 2).toFixed(2);
+}
+
+function ccUpdateDerivedLensFields() {
+ ccUpdateDerivedVarifocalRange();
+ ccUpdateDerivedFixedFocal();
+}
+
+function ccSyncLensModeDisabled() {
+ const mode = ccLensMode();
+ document.getElementById('cc-f').disabled = mode !== 'focal';
+ document.getElementById('cc-f-min').disabled = mode !== 'varifocal';
+ document.getElementById('cc-f-max').disabled = mode !== 'varifocal';
+ document.getElementById('cc-fov-d').disabled = mode !== 'fov';
+ document.getElementById('cc-valid-range-note').hidden = mode !== 'fov';
+}
+
+document.querySelectorAll('input[name="cc-lens-mode"]').forEach(function (radio) {
+ radio.addEventListener('change', function () {
+ ccSyncLensModeDisabled();
+ ccUpdateDerivedLensFields();
+ });
+});
+
+['cc-f', 'cc-f-min', 'cc-f-max'].forEach(function (id) {
+ document.getElementById(id).addEventListener('input', ccUpdateDerivedLensFields);
+});
+
+// Same refresh-restores-radios issue as sensor mode above.
+ccSyncLensModeDisabled();
+ccUpdateDerivedLensFields();
+
+function ccSyncMarkerModeDisabled() {
+ const isLeague = ccMarkerMode() === 'league';
+ document.getElementById('cc-d').disabled = isLeague;
+ document.getElementById('cc-nmin').disabled = isLeague;
+}
+
+document.querySelectorAll('input[name="cc-marker-mode"]').forEach(function (radio) {
+ radio.addEventListener('change', function () {
+ ccSyncMarkerModeDisabled();
+ if (ccMarkerMode() === 'league') {
+ document.getElementById('cc-d').value = 40;
+ document.getElementById('cc-nmin').value = 6;
+ }
+ });
+});
+
+// Resync disabled state on load too (see comments above) — deliberately
+// doesn't reset values here, only on an actual user click, so a
+// browser-restored "custom" selection keeps its restored values.
+ccSyncMarkerModeDisabled();
+
+function ccWHFromFocal(h, f, sh, sv) {
+ return [(h * sh) / f, (h * sv) / f];
+}
+
+function ccWHFromDiagonalFov(h, fovD, aspect) {
+ // aspect = W/H, taken from the resolution's aspect ratio (Rh/Rv).
+ const toRad = deg => (deg * Math.PI) / 180;
+ const diag = 2 * h * Math.tan(toRad(fovD) / 2);
+ const H = diag / Math.sqrt(aspect * aspect + 1);
+ const W = aspect * H;
+ return [W, H];
+}
+
+function ccComputeFromWH(h, W, H, rh, rv, d, nMin, tw, th, n) {
+ const toDeg = r => (r * 180) / Math.PI;
+ const fovH = 2 * toDeg(Math.atan(W / (2 * h)));
+ const fovV = 2 * toDeg(Math.atan(H / (2 * h)));
+ const rho = Math.min(rh / W, rv / H);
+ const pxAcross = rho * d;
+ const passes = pxAcross >= nMin;
+
+ const result = { fovH, fovV, W, H, rho, pxAcross, passes };
+ if (!isNaN(tw) && !isNaN(th) && tw > 0 && th > 0) {
+ result.margin = (n * W * H) / (tw * th) * 100;
+ result.marginOk = result.margin > 100;
+ }
+ return result;
+}
+
+function ccCompute(h, f, sh, sv, rh, rv, d, nMin, tw, th, n) {
+ const [W, H] = ccWHFromFocal(h, f, sh, sv);
+ return ccComputeFromWH(h, W, H, rh, rv, d, nMin, tw, th, n);
+}
+
+function ccComputeDiagonalFov(h, fovD, rh, rv, d, nMin, tw, th, n) {
+ const [W, H] = ccWHFromDiagonalFov(h, fovD, rh / rv);
+ return ccComputeFromWH(h, W, H, rh, rv, d, nMin, tw, th, n);
+}
+
+function ccPerCameraTarget(n, tw, th) {
+ // 1 camera: full field. 2: split the wider axis in half (half field).
+ // 4: split both axes in half (quadrants). Matches the convention used
+ // throughout the Cameras and Lenses worked examples.
+ if (n === 2) return [tw / 2, th];
+ if (n === 4) return [tw / 2, th / 2];
+ return [tw, th];
+}
+
+function ccValidFocalRange(h, sh, sv, rh, rv, d, nMin, targetW, targetH) {
+ // Coverage needs W(f) >= targetW and H(f) >= targetH; since W,H shrink as
+ // f grows, this caps f from above.
+ const fMaxCoverage = Math.min((h * sh) / targetW, (h * sv) / targetH);
+ // Density needs rho(f) >= nMin/d; since rho grows as f grows, this bounds
+ // f from below.
+ const fMinDensity = Math.max((nMin * h * sh) / (d * rh), (nMin * h * sv) / (d * rv));
+ return { fMinDensity, fMaxCoverage, feasible: fMinDensity <= fMaxCoverage };
+}
+
+function ccRenderValidRange(range, n) {
+ let html = '
Valid Focal Length Range
';
+ if (range.feasible) {
+ html += `
${range.fMinDensity.toFixed(2)}–${range.fMaxCoverage.toFixed(2)} mm `
+ + `— any lens in this range covers its assigned tile (of ${n}) and meets pixel density
`;
+ } else {
+ html += `
None — coverage needs f ≤ ${range.fMaxCoverage.toFixed(2)} mm, but density `
+ + `needs f ≥ ${range.fMinDensity.toFixed(2)} mm. No single focal length satisfies both; you need a `
+ + `wider sensor, higher resolution, lower mount, or more cameras.
`;
+ }
+ return html;
+}
+
+function ccRenderResult(r, nMin) {
+ let html = '';
+ html += `
`;
+ html += ccRenderResult(rWide, nMin);
+ html += `
At narrowest (${fMax} mm)
`;
+ html += ccRenderResult(rNarrow, nMin);
+
+ if (range) {
+ const usableMin = Math.max(range.fMinDensity, fMin);
+ const usableMax = Math.min(range.fMaxCoverage, fMax);
+ html += '
Usable dial range on this lens
';
+ if (usableMin <= usableMax) {
+ html += `
Dial to
${usableMin.toFixed(2)}–${usableMax.toFixed(2)} mm on this `
+ + `lens works
`;
+ } else {
+ html += `
Dial to
No setting on this lens's ${fMin}–${fMax} mm range works `
+ + `— see Valid Focal Length Range above for what would
`;
+ }
+ }
+ }
+
+ document.getElementById('cc-output').innerHTML = html;
+});
+
+}
diff --git a/docs-source/_static/site.css b/docs-source/_static/site.css
new file mode 100644
index 0000000..4736d8e
--- /dev/null
+++ b/docs-source/_static/site.css
@@ -0,0 +1,38 @@
+.full-width-table table {
+ width: 100%;
+}
+
+.full-width-table img {
+ width: 100%;
+ height: auto;
+}
+
+/* Two-column diagram layout: label paragraphs on row 1, diagrams on row 2,
+ auto-flowing into a 2-col grid. Reusable across pages that need to compare
+ two diagrams side by side. Defaults to even columns; add the modifier
+ class below when one diagram is meaningfully more complex. */
+.two-col-diagram-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ column-gap: 1.5em;
+ align-items: start;
+}
+
+/* Modifier: widen the right column when it holds a more complex diagram
+ (e.g. the VLAN core diagram, which has 3 branches vs the physical core's 1). */
+.two-col-diagram-grid.wide-right {
+ grid-template-columns: 3fr 5fr;
+}
+
+.two-col-diagram-grid > p {
+ margin: 0 0 0.5em 0;
+ padding-bottom: 0.4em;
+ font-weight: bold;
+ text-align: center;
+ border-bottom: 1px solid var(--color-background-border, #ddd);
+}
+
+.two-col-diagram-grid pre.mermaid,
+.two-col-diagram-grid .mermaid-container {
+ width: 100%;
+}
diff --git a/docs-source/conf.py b/docs-source/conf.py
index 6500ed8..8fe8096 100644
--- a/docs-source/conf.py
+++ b/docs-source/conf.py
@@ -1,9 +1,19 @@
-project = "SSL Docs"
+import os
+import shutil
+import subprocess
+import time
+from pathlib import Path
+
+project = "RoboCup Small Size League"
copyright = "2026, RoboCup Small Size League"
author = "RoboCup Small Size League"
extensions = [
"myst_parser",
+ "sphinxcontrib.youtube",
+ "sphinxcontrib.mermaid",
+ "sphinxcontrib.drawio",
+ "sphinx.ext.mathjax",
]
source_suffix = {
@@ -14,9 +24,161 @@
myst_enable_extensions = [
"colon_fence",
"deflist",
+ "dollarmath",
]
+myst_heading_anchors = 4
+
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
html_theme = "furo"
-html_static_path = []
+html_static_path = ["_static"]
+html_css_files = ["site.css", "camera-calc.css"]
+html_js_files = ["camera-calc.js"]
+
+# Diagrams using `layout: elk` in their frontmatter need the ELK layout
+# plugin explicitly opted into — sphinxcontrib-mermaid doesn't load it by
+# default even when a diagram requests it, silently falling back to dagre.
+mermaid_include_elk = True
+mermaid_version = "latest"
+
+# Without this, mermaid assigns each diagram's SVG id via Date.now() with no
+# counter or dedup. Pages with several diagrams reliably render two of them
+# in the same millisecond, so they collide on the exact same id and the
+# second diagram's output gets written into the first's DOM location instead
+# of its own. deterministicIds switches id generation to a simple incrementing
+# counter, which can't collide.
+mermaid_init_config = {"startOnLoad": False, "deterministicIds": True}
+
+# drawio's export binary is Electron, and nixpkgs doesn't set up the
+# setuid chrome-sandbox helper Electron's sandbox needs outside of NixOS
+# itself (local dev shells and the GitHub Actions runner alike), so it
+# segfaults on startup without --no-sandbox. It also segfaults hunting for
+# a GPU/DRI driver that doesn't exist in these headless environments,
+# hence disabling GPU use as well.
+drawio_no_sandbox = True
+drawio_disable_gpu = True
+
+# Diagrams that need to track the site's light/dark toggle correctly. An
+# -embedded SVG's light-dark() CSS resolves against the browser's OS/UA
+# color-scheme preference, not the host page — and Furo's manual toggle never
+# sets the CSS color-scheme property, only a data-theme attribute + CSS
+# classes. So a live light-dark() SVG can go dark while the page is
+# explicitly toggled to light. Each of these gets pre-rendered as two static
+# SVGs (one per theme, via $DRAWIO_SVG_THEME on the nix-wrapped drawio
+# binary) and picked between with Furo's own only-light/only-dark image
+# classes — see field/network/compnetwork.md.
+DUAL_THEME_DIAGRAMS = [
+ "field/network/diagrams/ssl_field_network_fanout_truss.drawio",
+ "field/network/diagrams/ssl_field_network_fanout_direct.drawio",
+]
+
+
+def render_dual_theme_diagrams(app):
+ # Unlike sphinxcontrib-drawio's own directive, these are plain {image}
+ # references (see below) — Sphinx's image collector needs the file to
+ # exist on disk for every builder, not just ones that emit images, so
+ # this can't be skipped for the dummy/linkcheck builders.
+ drawio_bin = shutil.which("drawio")
+ if drawio_bin is None:
+ raise RuntimeError("drawio binary not found on PATH — run inside `nix develop`")
+
+ xvfb_run_bin = shutil.which("xvfb-run")
+ if xvfb_run_bin is None:
+ raise RuntimeError("xvfb-run binary not found on PATH — run inside `nix develop`")
+
+ for rel_source in DUAL_THEME_DIAGRAMS:
+ source = Path(app.srcdir) / rel_source
+ for theme in ("light", "dark"):
+ # Written alongside the .drawio source (not html_static_path) so
+ # the normal {image} directive can find and copy it like any
+ # other source-tree image.
+ out_path = source.with_suffix(f".{theme}.svg")
+ if out_path.exists() and out_path.stat().st_mtime > source.stat().st_mtime:
+ continue
+
+ env = os.environ.copy()
+ env["DRAWIO_SVG_THEME"] = theme
+ # ELECTRON_RUN_AS_NODE (set by some terminal/editor hosts, e.g.
+ # VS Code) makes Electron run as plain Node instead of launching
+ # drawio's UI, so the export silently produces nothing. Also
+ # drop WAYLAND_DISPLAY: Electron's ozone "auto" platform picks
+ # Wayland whenever a live compositor socket exists, which
+ # collides with Xvfb's X11 display and crashes the export.
+ env.pop("ELECTRON_RUN_AS_NODE", None)
+ env.pop("ELECTRON_NO_ATTACH_CONSOLE", None)
+ env.pop("WAYLAND_DISPLAY", None)
+
+ args = [
+ xvfb_run_bin,
+ "-a",
+ drawio_bin,
+ "--export",
+ "--crop",
+ "--page-index",
+ "0",
+ "--format",
+ "svg",
+ "--output",
+ str(out_path),
+ str(source),
+ "--disable-gpu",
+ "--disable-software-rasterizer",
+ "--disable-features=DefaultPassthroughCommandDecoder",
+ "--no-sandbox",
+ ]
+
+ # Running several of these back-to-back within one process
+ # occasionally hits a transient Xvfb display race between
+ # consecutive xvfb-run invocations (each one succeeds fine in
+ # isolation) — retry a couple of times before giving up, and
+ # surface real stderr on the final failure instead of Sphinx's
+ # generic "exited with error" message.
+ attempts = 3
+ for attempt in range(1, attempts + 1):
+ result = subprocess.run(args, env=env, capture_output=True, text=True)
+ if result.returncode == 0:
+ break
+ if attempt == attempts:
+ raise RuntimeError(
+ f"drawio export failed after {attempts} attempts for {source} ({theme}):\n"
+ f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
+ )
+ time.sleep(2)
+
+
+def setup(app):
+ # The calculator page's math lives inside raw HTML labels, which Sphinx's
+ # math domain never sees, so its per-page MathJax inclusion heuristic
+ # misses it. Load MathJax (and other extension assets) unconditionally on
+ # every page instead.
+ app.set_html_assets_policy("always")
+ app.connect("builder-inited", render_dual_theme_diagrams)
+
+
+linkcheck_user_agent = (
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/124.0.0.0 Safari/537.36"
+)
+
+# Some vendor sites sit behind bot-mitigation (WAF/CDN) that rejects every
+# automated request with an instant 403 regardless of user-agent or headers,
+# even though the pages load fine in a real browser (both curl with a real
+# browser UA and the WebFetch tool get the same instant 403 — this isn't a
+# UA-sniffing check we can spoof our way past). Each URL below was manually
+# opened in a browser and confirmed good on the noted date; re-verify and
+# bump the date if one starts 404ing for real.
+linkcheck_ignore = [
+ # rolling vs global shutter write-up — verified 2026-08-09
+ r"^https://www\.teledynevisionsolutions\.com/learn/learning-center/imaging-fundamentals/rolling-vs-global-shutter/$",
+ # Spinnaker SDK product page — verified 2026-08-09
+ r"^https://www\.teledynevisionsolutions\.com/products/spinnaker-sdk/\?model=Spinnaker%20SDK&vertical=machine%20vision&segment=iis$",
+ # dual-channel PoE GigE host adapter (ACC-01-1105) — verified 2026-08-09
+ r"^https://www\.teledynevisionsolutions\.com/products/ethernet-network-interface-card/\?model=ACC-01-1105&vertical=machine%20vision&segment=iis$",
+ # Computar E3Z4518CS-MPIR lens product page — verified 2026-08-09
+ r"^https://www\.bhphotovideo\.com/c/product/1091754-REG/computar_e3z4518cs_mpir_1_2_4_5_13_2mm_f1_8_dn\.html$",
+ # Computar E3Z3915CS-MPWIR 4K lens product page — verified 2026-08-09
+ r"^https://www\.bhphotovideo\.com/c/product/1524951-REG/computar_e3z3915cs_mpwir_4k_1_1_8_3_9_10mm_f1_5\.html$",
+ # Intel Core i7-7567U spec sheet — verified 2026-08-09
+ r"^https://www\.intel\.com/content/www/us/en/products/sku/97541/intel-core-i77567u-processor-4m-cache-up-to-4-00-ghz/specifications\.html$",
+]
diff --git a/docs-source/field.md b/docs-source/field.md
new file mode 100644
index 0000000..7d9c2c4
--- /dev/null
+++ b/docs-source/field.md
@@ -0,0 +1,56 @@
+# Field Technical Documentation
+
+This page serves as a directory for common resources related to field mechanics, networks, and overall setup. As always,
+the [SSL Rules](https://ssl.robocup.org/rules/) are the authoritative source, but many items are left formally
+unconstrained.
+
+## Finding a Space
+
+The following page provide a brief list of considerations for finding appropriate lab space to host an SSL team or an
+SSL event.
+
+- Lab Space (coming soon!)
+- Competition Space (coming soon!)
+
+## Mechanics and Materials
+
+The following pages assist with materials and camera selection.
+
+- [Subflooring and Carpet](field/carpet.md)
+- [Camera and Lens Selection](field/cameras.md)
+- [Camera Field of View Calculator](field/camera-calculator.md)
+
+## Layout
+
+Two reference layouts are provided. The competition layout reflects what's expected to be found at a global competition,
+though smaller events may omit some functionality. The lab layout reflects a more realistic and modest setup seen at
+most university or community labs for weekly practice.
+
+- [Competition Layout](field/complayout.md)
+- [Lab Layout](field/lablayout.md)
+
+## Compute and Physical Network
+
+See the compute page for recommended machine specifications and brands.
+
+- [Compute](field/network/compute.md)
+
+See the network pages below for detailed descriptions of competition networks and home lab networks.
+
+- [Competition Network](field/network/compnetwork.md)
+- [Lab Network](field/network/labnetwork.md)
+
+```{toctree}
+---
+maxdepth: 1
+caption: Contents
+---
+field/carpet.md
+field/cameras.md
+field/camera-calculator.md
+field/complayout.md
+field/lablayout.md
+field/network/compute.md
+field/network/compnetwork.md
+field/network/labnetwork.md
+```
diff --git a/docs-source/field/camera-calculator.md b/docs-source/field/camera-calculator.md
new file mode 100644
index 0000000..e7b8a30
--- /dev/null
+++ b/docs-source/field/camera-calculator.md
@@ -0,0 +1,100 @@
+# Camera Field of View Calculator
+
+Helps sanity check a given camera configuration for a desired field size.
+
+Enter numbers instead of doing the arithmetic in
+[Steps 1-5 of Cameras and Lenses](cameras.md#calculating-major-factors-for-your-lab) by hand.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs-source/field/cameras.md b/docs-source/field/cameras.md
new file mode 100644
index 0000000..f1b7a37
--- /dev/null
+++ b/docs-source/field/cameras.md
@@ -0,0 +1,405 @@
+# Cameras and Lenses
+
+Picking cameras (and lenses) is one of the most asked questions by teams new and old alike. Often by the time a camera
+upgrade is needed the team member who specd and built the original field is long gone. This page is intended to cover
+the factors in choosing the number of cameras, parameters, vendors, and other complex and commonly asked about items.
+
+If you already know what you're doing and just want to check on configuration, see the
+[camera calculator](camera-calculator.md).
+
+## Prerequisites
+
+Before choosing a camera you must have committed to a lab or event space. Ceiling height will determining factor.
+
+You should also have an idea of the field size. Are you doing a small patch in your lab for testing or are you hosting
+an event with a regulation field. Either way, you need to know the size. This combined with ceiling height will
+determine the number of cameras needed.
+
+## Major Factors
+
+First, we'll cover the major factor before doing an example calculation. This section covers the "why", not the "how".
+Jump ahead if you'd like.
+
+### Pixel Density
+
+[Pixel density](https://en.wikipedia.org/wiki/Pixel_density) describes the number of pixels in a unit area, typically
+pixels per inch (PPI) or pixels per cm (PPCM). This matters because interpreting the robot jersey requires enough pixels
+covering its features; without enough, the robot's position cannot be recovered. Cameras have a
+[field of view](https://en.wikipedia.org/wiki/Field_of_view), a cone that expands outward from the camera sensor. The
+lens determines the properties of this cone, the same way your eyes resolve close objects in more detail than distant
+ones. Because the camera's height above the field is fixed, knowing the height and the cone's properties is enough to
+compute pixel density, or to solve for any other unknown in that relationship.
+
+| Field of View | Robot Jersey |
+| ----------------------------------------------- | ----------------------------------------------------------- |
+|  |  |
+
+The left image shows a camera's field of view; the right shows an SSL robot's jersey (GreenTea 2021). If camera
+resolution is too low to distinguish the yellow, pink, and green dots, the robot's position cannot be recovered. In the
+left image, the green box grows larger the farther it sits from the camera. Since the sensor has a fixed number of
+pixels, and that same pixel count spreads across a larger area farther from the camera, pixel density decreases with
+distance. Eventually density drops below the size of a single dot, and position can no longer be recovered. The final
+camera configuration must provide enough pixels to resolve each dot and the ball.
+
+### Field of View
+
+Field of view (FoV) refers to the properties of the cone or pyramid expanding from the image sensor, described above. It
+is almost always given as either a single angle for a cone, or a horizontal and vertical angle for a pyramid. Because
+FoV determines how much pixel density is lost per unit distance from the sensor, it is a key camera parameter: a wider
+FoV loses more pixel density per unit distance but sees more of its surroundings. This is a geometry tradeoff — a low
+ceiling calls for a wider FoV, since a narrow cone or pyramid under a low ceiling will not reach the outer parts of the
+field. No single FoV is inherently good or bad; it is a tradeoff against mounting height and cost. Cheaper webcams
+typically have an integrated lens with a fixed, wide FoV, which may not suit every space. High-end cameras separate the
+camera body and lens, giving full control over FoV, at greater expense.
+
+### Shutter Type
+
+In this context, "shutter type" refers to a virtual shutter; physical shutters are not present on the cameras we buy.
+The two major types are rolling and global. A rolling shutter converts one row of pixels at a time, whereas a global
+shutter converts the whole image at once. A rolling shutter is cheaper, but when objects move quickly, different parts
+of the object are converted at different times, an artifact sometimes called the jello effect. A global shutter avoids
+this, at substantial cost and engineering complexity in the image sensor. **Global shutters are universally preferred
+for robotics and computer vision tasks, but many teams do not buy them for their lab due to cost (sometimes 5-10x
+cost).** The league's competition cameras are global shutter. **Anyone hosting an event should try to acquire global
+shutter cameras.**
+
+The tradeoff is robot speed. The jello effect worsens as an object moves faster. On a small lab test patch, where robot
+speed is limited by field size, global shutters offer no real benefit. On a regulation field with more open space,
+robots reach much higher speeds, and global shutter cameras become necessary. The exact magnitude of the jello effect
+depends on several environmentally dependent settings, including exposure, so it is not possible to state a reliable
+threshold like "global shutter is needed above robot speed X".
+
+Teledyne-FLIR, our competition camera provider, has a
+[detailed write-up of this effect](https://www.teledynevisionsolutions.com/learn/learning-center/imaging-fundamentals/rolling-vs-global-shutter/),
+including the animation below.
+
+
+
+Sony makes the global shutter image sensors that Teledyne-FLIR integrates. Their real-world example below shows the
+difference between the shutter types in the curving of the helicopter rotor blades.
+
+```{youtube} YmEH8z1JWgc
+```
+
+### Frame Rate
+
+**Recommend >=60fps.** The competitions typically run 72fps, but this isn't guaranteed.
+
+### Software Support by League Vision Software
+
+The last key factor in purchasing a camera is software support. Most USB webcams use a standard protocol, supported via
+[Video4Linux](https://en.wikipedia.org/wiki/Video4Linux) (v4l); the league vision tools integrate with v4l drivers
+natively, so no development is needed for support. Ensure your camera's controls, such as white balance and exposure,
+can be set from Linux and commanded not to auto-calibrate. Auto-calibration at the camera level is likely to break the
+league software's color calibration, which expects those settings to remain constant after initial setup.
+
+Global shutter and other high-end machine vision cameras typically do not support standard USB drivers, since they do
+not operate as standard USB devices; the vendor instead supplies a custom API that must be used. For the league cameras,
+Teledyne-FLIR provides the
+[Spinnaker SDK](https://www.teledynevisionsolutions.com/products/spinnaker-sdk/?model=Spinnaker%20SDK&vertical=machine%20vision&segment=iis)
+for their USB3 and GigE cameras. The league software already supports this API, though sometimes small tweaks are needed
+for a given camera model. Picking a high-end machine vision camera not supported by the Spinnaker SDK will probably
+require writing your own driver interface code for the league software. This is possible, but not a trivial task.
+
+## Calculating Major Factors for Your Lab
+
+This section turns [Pixel Density](#pixel-density) and [Field of View](#field-of-view) above into a repeatable
+calculation. Given a camera, lens, and mounting height, it tells you whether that combination resolves the robot
+markers, and if not, what to change.
+
+### Inputs
+
+You need six numbers:
+
+- (h) — mounting height above the field surface (m)
+- (f) — lens focal length (mm)
+- $s_h, s_v$ — sensor width and height (mm), from the sensor datasheet
+- $R_h, R_v$ — sensor resolution (pixels), horizontal and vertical
+- (d) — diameter of the smallest feature you need to resolve (the robot ID/team marker dots)
+
+If your lens/camera is specified directly as a field of view angle instead of focal length + sensor size, skip to step
+2\.
+
+### Step 1: Focal Length and Sensor Size to Field of View
+
+A rectilinear lens's field of view is:
+
+$$
+\mathrm{FoV}_h = 2 \arctan\left(\frac{s_h}{2f}\right) \qquad
+\mathrm{FoV}_v = 2 \arctan\left(\frac{s_v}{2f}\right)
+$$
+
+### Step 2: Field of View to Ground Coverage
+
+For a camera mounted at height (h), looking straight down (nadir) at a flat field, the width and height of the ground
+area it covers are:
+
+$$
+W = 2 h \tan\left(\frac{\mathrm{FoV}_h}{2}\right) \qquad
+H = 2 h \tan\left(\frac{\mathrm{FoV}_v}{2}\right)
+$$
+
+This is an approximation: it assumes the camera points straight down and ignores lens distortion. It's accurate enough
+to plan a purchase; the league software's calibration step corrects the remaining error at setup time.
+
+### Step 3: Ground Coverage to Pixel Density
+
+Pixel density is resolution divided by the ground distance it's spread across:
+
+$$
+\rho_h = \frac{R_h}{W} \qquad \rho_v = \frac{R_v}{H}
+$$
+
+Use $\rho = \min(\rho_h, \rho_v)$ as your working density — whichever dimension is tighter is what limits you.
+
+### Step 4: Check Against the Marker Size
+
+You need enough pixels across a marker dot to reliably find its center and classify its color, not just detect that
+something is there. This page uses a minimum of 6 pixels across the diameter (d) — enough margin for motion blur and
+rolling shutter that you don't also need to budget for those separately. The required density is:
+
+$$
+\rho_{\text{required}} = \frac{N_{\text{min}}}{d}
+$$
+
+The configuration works if $\rho \geq \rho_{\text{required}}$. If it doesn't, you have four options, in order of
+usually-cheapest-first:
+
+- Lower the mounting height (h) (shrinks coverage, raises density; may not be possible if the ceiling is fixed)
+- Use a longer focal length (f) (narrows FoV, raises density; also shrinks coverage — you may need more cameras to cover
+ the same field)
+- Use a higher-resolution sensor (raises $R_h, R_v$ directly; no coverage tradeoff, but costs more)
+- Split coverage across more cameras, each covering a smaller tile of the field at higher density (see
+ [Known Good Configurations](#known-good-configurations) for how the league does this at competition height)
+
+### Step 5: Field Coverage
+
+If a configuration needs $N$ cameras to cover a field of area $A_{\text{field}}$, a quick sanity check is the total
+footprint budget relative to the field:
+
+$$
+\text{Coverage Margin} = \frac{N \times W \times H}{A_{\text{field}}}
+$$
+
+A margin at or below 100% means the cameras' combined footprint can't even cover the field once, let alone overlap — it
+will not work regardless of how they're arranged. A margin comfortably above 100% (the excess is overlap budget, spent
+on stitching adjacent frames together and tolerating calibration/mounting error) is necessary, but not sufficient by
+itself — it doesn't check that the footprints can actually be arranged to eliminate gaps, only that there's enough total
+area to make that arrangement possible.
+
+### Interactive Calculator
+
+Plug in numbers instead of doing the arithmetic in [Steps 1-5](#calculating-major-factors-for-your-lab) by hand, using
+the [Camera Field of View Calculator](camera-calculator.md).
+
+## Known Good Configurations
+
+Below are some examples of camera configurations that meet the pixel density and software compatibility requirements.
+Each configuration below is actively used by at least one team or event. Additional known good configurations are
+welcome. Please include any known limitations if you make a pull request.
+
+### League Cameras and Lenses (Division A and Division B)
+
+Worked example using a common league camera and one of two compatible 5mm lenses.
+
+**Camera:**
+[FLIR Blackfly S BFS-U3-51S5C-C](https://www.edmundoptics.com/p/bfs-u3-51s5c-c-usb3-blackfly-reg-s-color-camera/37240/)
+— 2/3" sensor, $s_h = 8.45\ \text{mm}$, $s_v = 7.07\ \text{mm}$, $R_h = 2448\ \text{px}$, $R_v = 2048\ \text{px}$ (3.45
+µm pixels).
+
+**Lens:** either of Navitar's two 5 mm, 2/3"-format C-mount lenses (`1-19552` or `NMV-5M23`). They differ in aperture
+range, not focal length or target sensor format, so $\mathrm{FoV}$ is the same either way for planning purposes:
+$f = 5\ \text{mm}$.
+
+**Mounting height:** $h = 6000\ \text{mm}$, within the [6.0-6.25 m official range](complayout.md#cameras).
+
+**Marker size:** per the [SSL Rules vision pattern](https://ssl.robocup.org/rules/), the center (team) dot is Ø50 mm and
+the four ID dots are Ø40 mm. The ID dots are the smaller, limiting feature: $d = 40\ \text{mm}$.
+
+Using the [general method](#calculating-major-factors-for-your-lab):
+
+$$
+\mathrm{FoV}_h = 2 \arctan\left(\frac{8.45}{2 \times 5}\right) \approx 80.4°
+\qquad
+\mathrm{FoV}_v = 2 \arctan\left(\frac{7.07}{2 \times 5}\right) \approx 70.5°
+$$
+
+$$
+W = 2(6000) \tan(40.2°) \approx 10{,}140\ \text{mm}
+\qquad
+H = 2(6000) \tan(35.3°) \approx 8{,}484\ \text{mm}
+$$
+
+$$
+\rho_h = \frac{2448}{10{,}140} \approx 0.241\ \text{px/mm}
+\qquad
+\rho_v = \frac{2048}{8{,}484} \approx 0.241\ \text{px/mm}
+$$
+
+At $\rho \approx 0.241\ \text{px/mm}$, the 40 mm ID dots resolve to about $0.241 \times 40 \approx 9.7$ pixels across,
+and the 50 mm center dot to about 12 pixels across — both above the 6 px minimum from
+[Step 4](#step-4-check-against-the-marker-size). **This configuration passes**, with roughly 1.6-2x margin over the
+minimum threshold.
+
+**Field coverage** ([Step 5](#step-5-field-coverage)): this configuration's single-camera footprint is
+$W \times H \approx 10.14 \times 8.48\ \text{m} \approx 86.0\ \text{m}^2$. Per the
+[SSL Rules field dimensions](https://ssl.robocup.org/rules/), Division A's field of play is
+$13.4 \times 10.4\ \text{m} = 139.4\ \text{m}^2$ and Division B's is $10.4 \times 7.4\ \text{m} = 77.0\ \text{m}^2$:
+
+| Division | Cameras ($N$) | Total Footprint | Field Area | Coverage Margin |
+| -------- | ------------- | --------------- | ---------- | --------------- |
+| A | 2 | 172.1 m² | 139.4 m² | 123% |
+| B | 1 | 86.0 m² | 77.0 m² | 112% |
+
+Both divisions clear 100% with a positive overlap budget, consistent with these being real, working configurations —
+Division B's margin is noticeably tighter, leaving less room for stitching/calibration error with only one camera.
+
+### Japan Open Cameras and Lenses (Division A)
+
+Same camera as [League Cameras and Lenses](#league-cameras-and-lenses-division-a-and-division-b)
+([FLIR Blackfly S BFS-U3-51S5C-C](https://www.edmundoptics.com/p/bfs-u3-51s5c-c-usb3-blackfly-reg-s-color-camera/37240/)),
+paired with a different 5 mm lens vendor: [Kowa LM5JC1M / LM5JCM](https://www.kowavision.com/products/lm5jc1m-lm5jcm) —
+2/3" max sensor format, F2.8-16, 0.5% TV distortion.
+
+Since the focal length ($f = 5\ \text{mm}$) and sensor are identical to the League configuration, the
+[FoV, ground coverage, and pixel density](#league-cameras-and-lenses-division-a-and-division-b) work out the same:
+$\rho \approx
+0.241\ \text{px/mm}$, about 9.7 px across the 40 mm ID dots. The lenses differ in aperture range and mechanical build,
+not focal length or target format, so this doesn't change the optics.
+
+**Field coverage:** Division A uses 2 cameras, same as League — coverage margin 123% (see
+[Step 5](#step-5-field-coverage)). Division B count for this configuration is not yet documented here.
+
+### Peachtree Open Cameras and Lenses (Division B)
+
+Four cameras cover Division B at Peachtree Open, split evenly into quadrants: two brought by RoboJackets, two by The
+A-Team. Both teams use the same camera but different lenses, since each team's home lab ceiling differs.
+
+**Camera (both teams):**
+[FLIR Blackfly BFLY-PGE-13E4C-CS](https://www.edmundoptics.com/p/bfly-pge-13e4c-cs-118-blackflyreg-poe-gige-color-camera-d7aa6dab/30322/)
+— 1/1.8" sensor, $s_h = 6.78\ \text{mm}$, $s_v = 5.43\ \text{mm}$, $R_h = 1280\ \text{px}$, $R_v = 1024\ \text{px}$ (5.3
+µm pixels).
+
+Both lenses are **varifocal** (adjustable focal length, unmarked dial) rather than fixed, so there's no single focal
+length to plug into the method — instead, this solves the [general method](#calculating-major-factors-for-your-lab) in
+reverse: given the lens's widest available zoom setting, what's the minimum mounting height needed to cover a full
+quadrant with no gap?
+
+#### At Peachtree Open
+
+Cameras mount on arms with adjustable height, 10-12 ft ($3048$-$3658\ \text{mm}$) — this is the actual competition
+mounting range, independent of either team's home lab ceiling.
+
+**Target coverage:** a quadrant of Division B's $10.4 \times 7.4\ \text{m}$ field of play, split evenly:
+$\text{target}_W = 5200\ \text{mm}$, $\text{target}_H = 3700\ \text{mm}$.
+
+Rearranging [Step 2](#step-2-field-of-view-to-ground-coverage) ($W = h s_h / f$) for the minimum height that satisfies
+both axes at a given focal length (f):
+
+$$
+h_{\text{required}} = \max\left(\frac{\text{target}_W \cdot f}{s_h},\ \frac{\text{target}_H \cdot f}{s_v}\right)
+$$
+
+**RoboJackets** —
+[Computar E3Z4518CS-MPIR](https://www.bhphotovideo.com/c/product/1091754-REG/computar_e3z4518cs_mpir_1_2_4_5_13_2mm_f1_8_dn.html),
+4.5-13.2mm, F1.8. At the widest end ($f = 4.5\ \text{mm}$):
+
+$$
+h_{\text{required}} = \max\left(\frac{5200 \times 4.5}{6.78},\ \frac{3700 \times 4.5}{5.43}\right)
+= \max(3451,\ 3066)\ \text{mm} \approx 3.45\ \text{m}
+$$
+
+This sits near the top of the 3.05-3.66 m arm range — consistent with RoboJackets' cameras running high on the arm
+(reachable by ladder, so not an installation problem).
+
+**The A-Team** —
+[Computar E3Z3915CS-MPWIR](https://www.bhphotovideo.com/c/product/1524951-REG/computar_e3z3915cs_mpwir_4k_1_1_8_3_9_10mm_f1_5.html),
+3.9-10mm, F1.5. At the widest end ($f = 3.9\ \text{mm}$):
+
+$$
+h_{\text{required}} = \max\left(\frac{5200 \times 3.9}{6.78},\ \frac{3700 \times 3.9}{5.43}\right)
+= \max(2991,\ 2657)\ \text{mm} \approx 2.99\ \text{m}
+$$
+
+This clears even the arm's minimum height (3.05 m), so The A-Team's cameras don't need to run high — any arm position
+works.
+
+Using RoboJackets near the top of the range (12 ft) and The A-Team near the bottom (10 ft) as representative positions,
+the aggregate [coverage margin](#step-5-field-coverage) is:
+
+$$
+\frac{2(5512 \times 4414) + 2(5300 \times 4245)}{10{,}400 \times 7400}\ \text{mm}^2 \approx \frac{93.6\ \text{m}^2}{77.0\ \text{m}^2} \approx 122\%
+$$
+
+This is comfortably above 100%, consistent with this being the actual working competition configuration.
+
+This can be seen practically in the below photo of the Peachtree Open Field, where the cameras are at different heights.
+
+
+
+#### In Each Team's Home Lab
+
+Both teams practice on a much smaller field than Division B — approximately $4.5 \times 3.5\ \text{m}$ each, still
+covered by the same two cameras, split along the longer axis: $\text{target}_W = 2250\ \text{mm}$,
+$\text{target}_H = 3500\ \text{mm}$.
+
+At home, RoboJackets mounts around $h = 3000\ \text{mm}$ (close to competition height) and The A-Team around
+$h = 2438\ \text{mm}$ (8 ft, a much lower home ceiling) — using the same lenses as at competition, at their widest
+setting.
+
+| Team | (h) | (f) | $\rho$ (px/mm) | Px across 40 mm dot | Coverage vs. target |
+| ----------- | ------- | ------ | -------------- | ------------------- | ------------------------------------------ |
+| RoboJackets | 3000 mm | 4.5 mm | 0.283 | ≈11.3 | passes, ≈3% margin (H-axis limited) |
+| The A-Team | 2438 mm | 3.9 mm | 0.302 | ≈12.1 | short ≈3% (H-axis; needs $h \geq 2515$ mm) |
+
+Pixel density is never the constraint at either team's home setup — it's roughly 1.9-2x the
+[Step 4](#step-4-check-against-the-marker-size) minimum in both cases. Coverage geometry is the real limiter, and it's
+tight: RoboJackets clears their smaller home target with only a few percent to spare, and The A-Team's low ceiling falls
+just short even on this much smaller field. This matches what you'd expect if a wider lens simply isn't available to buy
+— the ceiling height is fixed, so the only lever left is focal length, and both teams are already at their lens's widest
+setting.
+
+### A-Team Seattle Field Office (Living Room Testing Patch)
+
+**Camera:**
+[MOKOSE UC70](https://www.mokose.com/products/mokose-4k-30fps-usb-camera-webcam-uvc-free-drive-compatible-windows-mac-os-x-linux)
+with the 4.2mm wide-angle fixed lens option — a consumer USB webcam rather than a machine vision camera. 1/1.8" sensor,
+$R_h = 3840\ \text{px}$, $R_v = 2160\ \text{px}$ (4K), 2 µm pixels. Sensor size isn't published directly, but is exact
+from resolution × pixel pitch: $s_h = 3840 \times 0.002 = 7.68\ \text{mm}$, $s_v = 2160 \times 0.002 = 4.32\ \text{mm}$.
+
+**Mounting height:** not precisely known — estimated at $h = 2438\ \text{mm}$ (8 ft, a typical US apartment ceiling).
+Treat the result below as provisional until the real height is confirmed.
+
+**Target coverage:** the full testing patch, one camera, no split: $\text{target}_W = 2500\ \text{mm}$,
+$\text{target}_H = 1500\ \text{mm}$ (the stated $1.5 \times 2.5\ \text{m}$ upper bound).
+
+$$
+\mathrm{FoV}_h = 2 \arctan\left(\frac{7.68}{2 \times 4.2}\right) \approx 84.9°
+\qquad
+\mathrm{FoV}_v = 2 \arctan\left(\frac{4.32}{2 \times 4.2}\right) \approx 54.4°
+$$
+
+$$
+W = \frac{2438 \times 7.68}{4.2} \approx 4458\ \text{mm}
+\qquad
+H = \frac{2438 \times 4.32}{4.2} \approx 2508\ \text{mm}
+$$
+
+Both axes clear the target with room to spare: $4458 / 2500 \approx 178\%$ and $2508 / 1500 \approx 167\%$. Pixel
+density is not close to a limit either — $\rho \approx 0.861\ \text{px/mm}$ puts about 34 pixels across the 40 mm ID
+dot, roughly 5.7x the [Step 4](#step-4-check-against-the-marker-size) minimum.
+
+Solving [Step 2](#step-2-field-of-view-to-ground-coverage) in reverse, this lens only needs
+$h_{\text{required}} \approx 1.46\ \text{m}$ to cover the patch at all — well under the assumed 2.44 m mount.
+
+**This doesn't come out geometry-limited**, unlike every other configuration on this page — the 4.2mm lens is wide
+enough that, at the assumed height, both coverage and density have large margin. That's the opposite of what was
+expected here, which most likely means either the actual mounting height is lower than 8 ft (e.g. shelf- or arm-mounted
+well below the ceiling, not ceiling-mounted), or the real testing patch is smaller than the stated upper bound in a way
+that matters less than height does. The real height is worth confirming if this configuration is meant to illustrate a
+geometry-limited case.
+
+## Additional Resources
+
+- [ThorLabs Guide on Sensors and Lens Parameters](https://www.thorlabs.com/camera-lens-tutorial)
+- [Wiki on Lens and Mounts](https://en.wikipedia.org/wiki/Camera_lens)
diff --git a/docs-source/field/carpet.md b/docs-source/field/carpet.md
new file mode 100644
index 0000000..5de6d98
--- /dev/null
+++ b/docs-source/field/carpet.md
@@ -0,0 +1 @@
+# Flooring and Carpet
diff --git a/docs-source/field/complayout.md b/docs-source/field/complayout.md
new file mode 100644
index 0000000..24908d9
--- /dev/null
+++ b/docs-source/field/complayout.md
@@ -0,0 +1,199 @@
+# Competition Field
+
+This page describes a full feature competition field setup and layout.
+[Field dimensions](https://robocup-ssl.github.io/ssl-rules/sslrules.html#_dimensions) are described in the SSL Rules.
+
+Aspects of the described field are optional, particularly audience facing items. When something is optional, it is so
+noted.
+
+## Top Level Diagram
+
+A full diagram is provided below. Scale is approximately relative to the field, but omitted to allow for local variance.
+
+
+
+There's a lot to dissect here, but lets define some abbreviations:
+
+- GC - Game Controller
+- VIS - Vision Computer
+- NE - Networking Equipment
+- SC - Streaming Computer
+
+The colors hold meaning as well:
+
+- Yellow - Yellow Team resource
+- Blue - Blue Team resource
+- Cyan - Event/league resource
+- Orange - Role provided by neutral party
+- Pink - Role provided by neurtal party
+
+## Placement and Layout Guidance
+
+The above layout is intentionally crafted to provide a smooth experience for officials, teams, and spectators.
+
+### Field Placement
+
+The field should placed in the venue such that a clear separation between spectators and teams is achieved. There's
+should be a team area away from the field, often called "the pits", where mechanical and electrical work is done. Teams
+spend most of their time in the pits when not competing in a match or testing during an official testing time slot.
+Spectators should be granted a clear view of the competition field, but distanced from the pits. Occasionally robots
+malfunction so this separation is key. A physical barrier is recommended between spectators and the field and pits.
+
+### Tables
+
+Enough tables should be provided such that the vision and game controller each have at least half a table. Each team
+should have at least 1 full table at the field during their match or registered practice slot. A permanent dedicated
+field table for the full duration of the event is not guaranteed; teams may need to share between matches. Tables should
+be no smaller than ~1m x 2m (3 x 6ft).
+
+A gap should be present at the midfield line so game officials and robot handles have easy access to enter and leave the
+field. Care should be taken to secure netwokring cables on the ground at this entry point in particular. This is also
+the area most likely to be used to put in and remove robots (compared to the far side).
+
+Organizers should avoid placing tables between the field and spectators. Typically with specified field size and number
+of teams in attendance it is not necessary to place tables all the way around the field. This means it is possible to
+keep clear line of sight between spectators and the field. This also has the advantage of placing the robot handlers and
+remote controls in closer proximity to the referee and game controller. If issues arise via challenge flags, or field
+network faults all of the relevant parties are in closer proximity to each other to resolve any issues.
+
+Each table should be provided with one power outlet and one networking cable. It is the team's responsibility to bring a
+power strip or network switch if they need more than one connection when arriving at their table for the match.
+
+### Event Equipment
+
+The vision and game controller computers should be placed close to each other, the midfield line, and the referee. This
+has several advantages:
+
+- The Referee and GCO can communicate easily
+- The VE and GCO can communicate as necessary
+- Networking is centrally located and can be patched more easily
+- A physical space buffer is provided between the teams and the midfield, lowering congestion
+
+It's shown in the diagram as being split across the midfield line, but combining them into one table on a single side is
+also common. If this is done, it's preferable to have one empty table on the opposing side, or to omit it to provide the
+referee a buffer.
+
+Ideally, at least one status board is positioned such that the audience, referees, and teams have good visibility into
+the game state. While most events provide at least one status board, a first time event or casual meetup for scrimamge
+may not set up any status boards.
+
+If possible, the Audio Referee (at least the speaker) should be placed on the far side of the field so it speaks only to
+the audience, not the field *and* audience. It can speak fairly often and this can be overwhelming to the referee and
+assistant referee who may be attempting to communicate both across the field and a language barrier.
+
+## Function of Components
+
+Only the function of components, not people are described here. For the purpose of specific human roles, consult the
+rules.
+
+### Networking Equipment
+
+The networking equipment is the small cyan box labeled "NE" on the diagram. It is typically centrally located near the
+ground. The networking equipment is responsible for connecting all of the computers at the field and providing essential
+network services. See the [Network and Compute](network/compute.md) for physical network details.
+
+### Cameras
+
+One or more overhead cameras are needed to observe the field and pass video frames to league processing software. These
+cameras are typically mounted between 2.5m and 6.5m above the field. At official tournaments, they are typically mounted
+around 6.0-6.25m. When cameras are mounted near the top of the range, a small computer is mounted with the camera on the
+truss or building features. This is usually to overcome difficulties in transmitting raw camera data long distances.
+More information is present in the [Cameras and Lenses](cameras.md) page.
+
+### Vision Computer
+
+The vision computer is the device labeled "VIS" in the diagram. The vision computer is responsible for providing robot
+and ball position data on the network. League software processes camera data and field geometry to produce accurate
+positions. This computer needs a connection to one or more cameras directly, or their associated mounted computer. The
+specifics of this connection can be very field dependent and are described in more detail under the possible
+configurations section below. See the [Vision Protocol](/protocol/vision.md) page for the league software that fills
+this role.
+
+### Game Controller Computer
+
+The game controller computer is the device labeled "GC" in the diagram. The game controller computer manages the game
+state and publishes this information to the network. It also accepts requests from the teams via the remote control or
+optionally the team AI software. See the [Referee Protocol](/protocol/referee.md) page for the league software that
+fills this role.
+
+### Remote Control
+
+Each team is provided a remote control at the field to interact with the controller/referee software. These are labeled
+"RC" in the diagram. Teams use these to request timeouts and robot exchanges. If possible, the 3D printed cases should
+be color matched to the team so it's clear which remote is which. Additionally, cables should be color matched as well.
+Often times networking contractors at large events are unable to accommodate the remote color matching.
+
+
+
+### Team Computer
+
+Teams are responsible for bringing their own computer to run their software and AI. Teams should expect only one network
+and one power connection at their table.
+
+### Status Board (Recommended, Optional)
+
+The status board displays the score, game time, and events such as infractions and goals. At least one display should be
+available to the referees and teams. Additional displays for the audience are optional.
+
+### Streaming Computer (Optional)
+
+The streaming computer is labeled "SC" on the diagram. It is an optional system to record and stream video in realtime
+to one or more livestreams. It is capable of switching between camera feeds and can receive the game state on the field
+network to produce a scoring overlay for viewiers. This is the only computer to which an internet connection is
+guaranteed to be provided. Because it is optional, some competitions completely omit this system.
+
+### Audio Referee (Optional)
+
+The [audio referee](https://github.com/TIGERs-Mannheim/AudioRef) receives the game state and announces key events to the
+audience automatically. Because it is optional, some competitions completely omit this system.
+
+## Possible Configurations
+
+A key challenge with setting up a RoboCup SSL events or lab space is the camera arrangement. Mounting cameras high up
+carries strong advantages in producing a low-distoration image with a fewer number of cameras. This is a massive benefit
+when setting up many fields at the international event. However, for many lab spaces and lower-cost events mounting
+cameras at 6m simply isn't possible (ceiling is too low) or carries significant expense (renting overhead mounts and
+certified personnel). As such, camera configuration may vary from event to event, though the main international event is
+fiarly consistent. Some common configurations are listed below.
+
+### International Event - Division B
+
+The simplest configuration is the international event Division B field. In this configuration, a single camera is
+mounted at ~6.25m in position "A" in the layout diagram. A computer is mounted with it on the overhead truss. The vision
+computer remotely accesses the camera computer mounted overhead. In this configuration, the vision computer is only used
+for remote access.
+
+### International Event - Division A
+
+The second simplest configuration is the international event Division A field. In this configuration, a two cameras are
+mounted at ~6.25m in positions "B" in the layout diagram. Each camera covers half a field. A single camera is not used
+because it would need to be nearly quadruple the resolution and mounted even higher, which often practically impossible.
+A computer is mounted with each camera on the overhead truss in positions "B". The vision computer remotely accesses the
+camera computers mounted overhead, one at a time. In this configuration, the vision computer is only used for remote
+access.
+
+### Regional Event
+
+Regional events may cut costs by mounting a higher number of cameras lower to the ground. These cameras can also be
+lower resolution because they are covering a smaller area of the field. This configuration is used at the North American
+Peachtree Open Event. In this configuration, 4 cameras are mounted at positions "C". Because the data is lower and
+mounting is lower, computers are not mounted overhead with the cameras, but instead the raw camera data is sent directly
+to the vision computer. In this case, the vision computer is not used for remote access, but directly hosts the software
+that processes the vision data.
+
+
+
+This picture is taken from the 1st Annual Peachtree Open. Notice there are four overhead cameras, none with associated
+computers overhead. The vision and game controller computers are on the back field side, though they are obstructing the
+midfield line which is not ideal. Note team tables to the left (and right, not pictured) do not block the spectator
+view. Blue pipe and drape separates the spectators from the field. Team pits are located along the back wall.
+
+Not all regional events use this modified configuration. For example the Japan Open and German Open do not, as they have
+established infrastructure and event space to do the higher mounting.
+
+### University Lab Space
+
+University lab space also has ceiling height and cost constraints most of the time. A typical lab configuration looks
+similar to the regional event configuration, but with only a partial field and even fewer cameras. Often labs have a
+half or quarter Division B field, with one or two cameras. Sometimes the cameras are mounted to the side and not
+overhead. More details on this configuration can be seen in the [lab layout](lablayout.md) page.
diff --git a/docs-source/field/images/Angle_of_view.svg.webp b/docs-source/field/images/Angle_of_view.svg.webp
new file mode 100644
index 0000000..6dbd6da
Binary files /dev/null and b/docs-source/field/images/Angle_of_view.svg.webp differ
diff --git a/docs-source/field/images/comp-field-arch-icons.svg b/docs-source/field/images/comp-field-arch-icons.svg
new file mode 100644
index 0000000..208be23
--- /dev/null
+++ b/docs-source/field/images/comp-field-arch-icons.svg
@@ -0,0 +1,1456 @@
+
+
+
+
diff --git a/docs-source/field/images/comp-field-full.png b/docs-source/field/images/comp-field-full.png
new file mode 100644
index 0000000..d5b98fb
Binary files /dev/null and b/docs-source/field/images/comp-field-full.png differ
diff --git a/docs-source/field/images/comp-field-simpl.svg b/docs-source/field/images/comp-field-simpl.svg
new file mode 100644
index 0000000..7b1d493
--- /dev/null
+++ b/docs-source/field/images/comp-field-simpl.svg
@@ -0,0 +1,1418 @@
+
+
+
+
diff --git a/docs-source/field/images/greentea-2021bot-jersey.jpg b/docs-source/field/images/greentea-2021bot-jersey.jpg
new file mode 100644
index 0000000..0dd8135
Binary files /dev/null and b/docs-source/field/images/greentea-2021bot-jersey.jpg differ
diff --git a/docs-source/field/images/peachtree-open-field.jpg b/docs-source/field/images/peachtree-open-field.jpg
new file mode 100644
index 0000000..4d6457b
Binary files /dev/null and b/docs-source/field/images/peachtree-open-field.jpg differ
diff --git a/docs-source/field/images/peachtree-open-remote-controls.png b/docs-source/field/images/peachtree-open-remote-controls.png
new file mode 100644
index 0000000..699425c
Binary files /dev/null and b/docs-source/field/images/peachtree-open-remote-controls.png differ
diff --git a/docs-source/field/images/teledyne-rolling-vs-global-shutter_images_1.gif b/docs-source/field/images/teledyne-rolling-vs-global-shutter_images_1.gif
new file mode 100644
index 0000000..9600cbb
Binary files /dev/null and b/docs-source/field/images/teledyne-rolling-vs-global-shutter_images_1.gif differ
diff --git a/docs-source/field/lablayout.md b/docs-source/field/lablayout.md
new file mode 100644
index 0000000..6a29ec2
--- /dev/null
+++ b/docs-source/field/lablayout.md
@@ -0,0 +1,64 @@
+# Lab/Home Field
+
+This page describes a minimal functional field for use in the lab and at home. It emphasized the minimum required to get
+started and to minimize cost. Generally speaking, everything on this page and diagram is required.
+
+If you haven't already read the [competition layout](complayout.md) page, you should read that one first to understand
+all of the pieces at play. This minimal setup condenses that functionality.
+
+## Top Level Diagram
+
+A diagram is provided below. Scale is approximately relative to the field.
+
+**Picture coming soon!**
+
+
+
+There's a lot to dissect here, some abbreviations:
+
+- FC - Field Computer
+- TC - Team Computer
+- NE - Networking Equipment
+
+## Placement and Layout Guidance
+
+This is mostly a factor of what works for your team and space. You should checkout the section on
+[Carpet and Materials](carpet.md) and [Cameras](cameras.md). There's some advice there if you floor is not level or you
+have an unusual ceiling. **Remember, a practice field does not need to be standard dimensions or aspect ratio!** You
+seek to maximize the field size given your space and cost constraints. The vision software can be configured for
+arbitrary dimensions.
+
+## Function of Components
+
+In a typical lab setup, lots of equipment does double duty.
+
+### Networking Equipment
+
+The networking equipment is the small cyan box labeled "NE" on the diagram. This will connect the team computers
+together, provide networking services, and also provide an internet uplink. This is often preferred over wireless.
+
+### Field Computer
+
+The lab field computer combines multiple services into one compared to a competition config. Typically the field
+computer hosts at least vision and the game controller, the two services essential to test the team AI and ensure it
+responds to events. The GC is also used to trigger/force out of sequence events, like kickoffs or penalties to test
+specific plays or responses to match state.
+
+### Team Computer
+
+The team computer may be a member's laptop or dedicated machine. This will run the team AI. It's worth noting that if
+possible the team and field computers should be separate. Hopefully you have at least two team members. Often times
+vision and the AI are each computationally demanding enough they may effect the behavior of the other. Game controller
+can be hosted on either, though most teams keep it with vision.
+
+### Cameras
+
+Most teams have one or two lower end cameras, like a webcam, mounted above the field. These are connected directly to
+the field computer. If the team's lab is an office environment, they are typically mounted as close to the ceiling as
+possible. The [Cameras](cameras.md) page has advice on camera and lens selection and calculating a mounting height.
+
+### Optional Items
+
+Lab setups almost always omit the status board, remote controls, audio ref, and stream equipment.
+
+## Recommended Equipment
diff --git a/docs-source/field/network/compnetwork.md b/docs-source/field/network/compnetwork.md
new file mode 100644
index 0000000..4e7c62b
--- /dev/null
+++ b/docs-source/field/network/compnetwork.md
@@ -0,0 +1,213 @@
+# Competition Network
+
+This page describes the logical and physical network that runs the fields at the international competition. At the
+international event, the league typically supports:
+
+- 1 Division A match field
+- 1-2 Division B match fields
+- 1 Division B practice field
+
+This document will first cover a top level connection diagram, then a physical connection diagram. League protocols that
+transmit on the network are covered in a separate page.
+
+If you have not already familiarized yourself with the [competition layout](../complayout.md), please do so first. Also
+checkout the [lab network](labnetwork.md) page for a simpler version of the field network that aligns with lab or
+regional event single-field deployments.
+
+## High Level Overview
+
+The competition layout is re-posted here for convenience.
+
+
+
+### Networking Equipment
+
+Every field needs networking equipment to provide key services:
+
+- Physical connectivity across all field components
+- Addresses for computers
+- Multicast and broadcast support for distributing league traffic
+- Isolation from other networks and fields
+
+A switch at each field provides physical connectivity to all computers at the field via Gigabit Ethernet. These
+connections are almost always copper Cat 5e or Cat 6. Switches should only service one field, no cross over.
+
+A router provides the rest of the functionality. The router hosts a
+[DHCP](https://en.wikipedia.org/wiki/Dynamic_Host_Configuration_Protocol) server to dynamically allocate addresses to
+all machines at the field. It may provide management services for broadcast and multicast as well (e.g. an
+[IGMP](https://en.wikipedia.org/wiki/Internet_Group_Management_Protocol) quierier). **Teams at the field should expect
+to be able to walk up to an unused cable and receive an IP address.** Teams should expect the addresses of field
+computers and their own computer to change overnight or between matches, as DHCP leases may expire.
+
+The router's firewall also typically isolates traffic. It will restrict broadcast and multicast to a specific field. It
+may also be used to prohibit connection to the internet if affects the reliability of a field during a match. Any event
+that runs multiple fields needs to provide strong partitioning between each field network. Failure to do so results in
+conflicting vision and referee data and makes isolating offending services very difficult. There are two ways to do this
+with a router, physical isolation and virtual isolation.
+
+The diagrams below show the options.
+
+````{container} two-col-diagram-grid wide-right
+**Physical Network Core**
+
+**VLAN Network Core**
+
+```{mermaid} diagrams/net-core.mmd
+```
+
+```{mermaid} diagrams/net-core-vlan.mmd
+```
+````
+
+Left, providing a physical router/firewall per field solves the isolation problem by default. Nearly all routers will
+have default rules in place to disallow broadcast and multicast traffic between the WAN (upstream) and LAN ports. This
+is the configuration most teams use in their lab, and many regional events use as well. Again, if multiple field
+switches are connected via the LAN ports, the isolation is broken.
+
+Right, big event competitions will use a more complex configuration. This is mostly the result of a logistics
+complication; big event spaces will have a dedicated networking contractor/company and this company provides the field
+networking equipment. It is generally easier for them to provide *virtual isolation* between fields as a part of the
+bigger picture that provides networking at the venue to multiple leagues and may utilitze existing infrastructure in the
+building. The mechanism that provides this isolation is called a [VLAN](https://en.wikipedia.org/wiki/VLAN). When this
+mode of isolation is used, the SSL committees typically have no information or control regarding the backing
+configuration. Compatible switches combined with a router can tag traffic to specific VLANs and networking equipment is
+configured to prohibit cross-VLAN communication. A reasonable VLAN configuration would be one VLAN per field, though a
+networking contractor has never confirmed this for sure at an international event. The router and switches must both
+support VLAN features for this system to work. When receiving a quote for services from a big contractor, this option is
+expected to be cheaper than having them provide dedicated equipment.
+
+### Cameras
+
+There are two paradigms for camera mounting. In both cases the cameras are mounted on a ceiling or truss. The main
+difference is the location of the computer ingesting raw video data. In most competition configs, small computers are
+suspended on the truss with the camera. In most lab configs the cameras stand alone on the ceiling (or truss) and the
+computer is field side. Putting distributed compute on the truss sounds unnecessairly complex and expensive. Why pay for
+multiple computers? Why force remote access to them? The reason is uncompressed video bandwidth. Machine vision cameras
+typically don't compress their video. This means means the data link between the camera and vision computer must
+reliably transport the full bitrate. Take for example the league camera that have a resolution of 2448 x 2024. This
+means an image is 5MP. At 12bit (1.5 byte) color depth, and 75 fps. The data rate is 4.45 gbit/s excluding medium
+[line code](https://en.wikipedia.org/wiki/Line_code) and
+[error correction code](https://en.wikipedia.org/wiki/Error_correction_code) which add additional overhead. As of 2026,
+carrying multiple 5gbit/s signals more than a few meters is still an expensive proposition. It was even more so when the
+cameras were purchased in ~2019. 10GbE networking equipment is hundreds to thousands of dollars, and 5 and 10GbE machine
+vision cameras are relatively new. USB 3.1+ cable extenders are also many hundreds of dollars per extender. This means
+the cost to buy a small truss computer is about the same as methods to extend high-bitrate signals to the ground
+reliably. As such, at the time of purchasing the league equipment, the committees concluded the on truss computer was a
+more reliable option. The league purchased [NUCs](https://en.wikipedia.org/wiki/Next_Unit_of_Computing).
+
+For most lab setups, the length of USB cable needed is short enough, that a USB 3.1+ extender is not needed, just a nice
+cable. Alternatively, the small field size means 1Gbit PoE is enough to carry the raw video frames, and expensive
+networking equipment is not needed. As such, competitions run distributed vision systems, with primary processing being
+on the truss and the field vision computer just providing a human interface for remote access. Most lab configurations
+have the field side computer run everything, but the cable length and bitrates involved support this simpler cheaper
+configuration.
+
+Strictly talking about competition configs, the on truss mounting is used for Division A and Division B fields at the
+international event. Those configurations run 2-camera, 2-NUC and 1-camera, 1-NUC config respectively. Regional events
+may use the 4-camera direct config for a Division B field if a sufficiently high truss cannot be secured. Limits on
+optics and distortion force more cameras at lower resolution when truss height is low.
+
+These options are shows in the table below.
+
+````{container} two-col-diagram-grid
+**Truss NUC — 1–2 Cameras**
+
+**Direct Wiring — 4 Cameras**
+
+```{mermaid} diagrams/cam-nuc.mmd
+```
+
+```{mermaid} diagrams/cam-direct.mmd
+```
+````
+
+### Field Side
+
+The networking of the remaining computers field side is straightforward. At this point, the complex requirements of
+isolation and high bitrates are handled. The rest of the system is latency sensitive, but the data rate is so low this
+is virtually never a problem when using wired connectivity. All equipment is linked via GbE copper to the field-side
+switch. The remote controls need [Power over Ethernet (PoE)](https://en.wikipedia.org/wiki/Power_over_Ethernet) for
+power.
+
+```{mermaid} diagrams/field-side-equipment.mmd
+```
+
+## Detailed Diagram
+
+The below diagrams show near full instantiations of above general architecture. As noted in the legend of the diagrams,
+some items like IP addresses and VLANs are examples. In reality IP addresses will be dynamically allocated and will
+change throughout the competition. Other such information is noted in the legend box.
+
+### Truss NUC Config — Division A / Division B
+
+```{image} diagrams/ssl_field_network_fanout_truss.light.svg
+---
+class: only-light
+alt: SSL field network fan-out diagram, truss-mounted camera config
+---
+```
+
+```{image} diagrams/ssl_field_network_fanout_truss.dark.svg
+---
+class: only-dark
+alt: SSL field network fan-out diagram, truss-mounted camera config
+---
+```
+
+### Direct Wiring Config — Regional Division B
+
+```{image} diagrams/ssl_field_network_fanout_direct.light.svg
+---
+class: only-light
+alt: SSL field network fan-out diagram, direct-wired camera config
+---
+```
+
+```{image} diagrams/ssl_field_network_fanout_direct.dark.svg
+---
+class: only-dark
+alt: SSL field network fan-out diagram, direct-wired camera config
+---
+```
+
+## Technical Configurations
+
+This section contains technical configurations for the network and computers that aren't captured by the physical and
+logical layouts above.
+
+### Hostnames
+
+The hostname convention for machines is `--`. Examples are included below.
+
+- truss NUCs: `ssl-vision-a`, `ssl-vision-b` etc.
+- field vision computer: `ssl-vision-b0` where "b0" is the field name
+- field game controller computer: `ssl-gc-b0` where "b0" is the field name
+
+Host names may also reflect the machine owner or event name `pto-vision` where "pto" refers to the Peachtree Open.
+
+### LAN Config
+
+Field LAN should always be [IPv4](https://en.wikipedia.org/wiki/IPv4) and never
+[IPv6](https://en.wikipedia.org/wiki/IPv6), though the upstream WAN/venue network may be IPv6. Field LAN should supply
+at least a /24 subnet, with /16 preferred. For an example network, 10.10.0.0/16:
+
+- Subnet address CIDR notation: `10.10.0.0/16`
+- Gateway: 10.10.0.1
+- Reserved for management addressing: `10.10.0.2`-`10.10.0.5`
+- DHCP range: `10.10.0.6`-`10.10.255.249`
+- Broadcast address: `10.10.255.255`
+
+[CIDR standardized notation and allocations](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing)
+
+### IGMP Querier and IGMP Snooping Settings
+
+Somebody knowledgeable needs to root cause, data cap, and show mitigation settings. Look at Incheon stuff...
+
+## Additional Resources
+
+- [Network Router]()
+- [Network Switch](https://en.wikipedia.org/wiki/Network_switch)
+- [IPv4](https://en.wikipedia.org/wiki/IPv4)
+- [Classless Inter-Domain Routing](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing)
+- [List of Reserved IP Addresses](https://en.wikipedia.org/wiki/List_of_reserved_IP_addresses)
+- [Power over Ethernet](https://en.wikipedia.org/wiki/Power_over_Ethernet)
diff --git a/docs-source/field/network/compute.md b/docs-source/field/network/compute.md
new file mode 100644
index 0000000..18f8617
--- /dev/null
+++ b/docs-source/field/network/compute.md
@@ -0,0 +1,69 @@
+# Compute Resources
+
+This brief article lists vendors and approximiate specification of league computing resources.
+
+Below are the minimum specifications of league equipment. In for everything but the streaming computer, the minimum
+specification is often driven by supporting a stable and smooth desktop environment.
+
+## Truss Vision Computer (Next Unit of Computing - NUC)
+
+The Intel NUCs are the truss computers. The league owns
+[7th generation NUCs](https://www.intel.com/content/www/us/en/products/sku/97541/intel-core-i77567u-processor-4m-cache-up-to-4-00-ghz/specifications.html),
+fist releasing in 2017. As such, their specification is quite modest.
+
+- CPU:
+ [i7-7567U](https://www.intel.com/content/www/us/en/products/sku/97541/intel-core-i77567u-processor-4m-cache-up-to-4-00-ghz/specifications.html)
+ @3.5GHz [Benchmark](https://www.cpubenchmark.net/cpu.php?cpu=Intel+Core+i7-7567U+%40+3.50GHz&id=2990)
+- GPU: Intel® Iris® Plus Graphics 650
+- OpenCL Version 3.0
+- RAM: 16GB DDR4
+- OS: Xubuntu 22.04 LTS
+
+## Field Side Vision Computer
+
+The league doesn't own field side computers. They are typically provided by the venue contract. Approximate minimum
+specification is provided below:
+
+- GPU: 4-core/8-thread Processor from within the last 5 years
+- GPU: Integrated GPU
+- OpenCL: Version 3.0+
+- RAM: 16GB DDR5
+- OS: Ubuntu LTS
+
+When deployed as a vision processor host:
+
+- 1-2x
+ [Teledyne FLIR Dual Channel PoE GigE Host Adapter](https://www.teledynevisionsolutions.com/products/ethernet-network-interface-card/?model=ACC-01-1105&vertical=machine%20vision&segment=iis)
+
+## Game Controller Computer
+
+The league doesn't own field side computers. They are typically provided by the venue contract. Approximate minimum
+specification is provided below:
+
+- GPU: 4-core/8-thread Processor from within the last 5 years
+- GPU: Integrated GPU
+- OpenCL: Version 3.0+
+- RAM: 16GB DDR5
+- OS: Ubuntu LTS
+
+## Remote Controls
+
+Remote controls are Raspberry Pi 4, 2GB or 4GB model. Accessory hardware (screens, PoE) is described on the
+[ssl-remote-control repository](https://github.com/RoboCup-SSL/ssl-remote-control). These device need at least PoE+ to
+deliver enough power.
+
+## Status Board
+
+Status board computers are Raspberry Pi 4, 2GB or 4GB model. Any computer capable of hosting a small webpage can fill
+this role.
+
+## Streaming Computer
+
+The league doesn't own field side computers. They are typically provided by the venue contract. Approximate minimum
+specification is provided below:
+
+- GPU: 8-core/16-thread Processor >4.0GHz
+- GPU: Nvidia 2060 or better, capable of 4k video transcoding
+- OpenCL: Version 3.0+
+- RAM: 32GB DDR5
+- OS: Ubuntu LTS
diff --git a/docs-source/field/network/diagrams/cam-direct.mmd b/docs-source/field/network/diagrams/cam-direct.mmd
new file mode 100644
index 0000000..12c2b27
--- /dev/null
+++ b/docs-source/field/network/diagrams/cam-direct.mmd
@@ -0,0 +1,12 @@
+flowchart TD
+ subgraph TrussDirect["Truss — Elevated (4-Camera Config)"]
+ Cameras["Cameras ×4 Direct wiring"]
+ end
+
+ Cameras -- "Direct — bypasses switch PoE or USB w/ extenders" --> Vision["Vision Computer Dedicated interfaces ×4"]
+ Vision -- "GbE" --> Switch["Field-Side Switch (see network core diagram)"]
+
+ classDef purple fill:#faf5ff,stroke:#9333ea,color:#581c87;
+ classDef cyan fill:#ecfeff,stroke:#0891b2,color:#164e63;
+ class Cameras purple;
+ class Vision,Switch cyan;
\ No newline at end of file
diff --git a/docs-source/field/network/diagrams/cam-nuc.mmd b/docs-source/field/network/diagrams/cam-nuc.mmd
new file mode 100644
index 0000000..be96392
--- /dev/null
+++ b/docs-source/field/network/diagrams/cam-nuc.mmd
@@ -0,0 +1,13 @@
+flowchart TD
+ subgraph TrussNuc["Truss — Elevated (1–2 Camera Config)"]
+ Camera["Camera ×1–2 1 NUC per camera"]
+ NUC["Truss NUC Dedicated, 1:1"]
+ Camera -- "USB 3.1" --> NUC
+ end
+
+ NUC -- "GbE (via switch, remote access)" --> Switch["Field-Side Switch (see network core diagram)"]
+
+ classDef purple fill:#faf5ff,stroke:#9333ea,color:#581c87;
+ classDef cyan fill:#ecfeff,stroke:#0891b2,color:#164e63;
+ class Camera purple;
+ class NUC,Switch cyan;
\ No newline at end of file
diff --git a/docs-source/field/network/diagrams/field-side-equipment.mmd b/docs-source/field/network/diagrams/field-side-equipment.mmd
new file mode 100644
index 0000000..6f01258
--- /dev/null
+++ b/docs-source/field/network/diagrams/field-side-equipment.mmd
@@ -0,0 +1,52 @@
+---
+config:
+ layout: elk
+ elk:
+ nodePlacementStrategy: NETWORK_SIMPLEX
+---
+flowchart TD
+ subgraph Switch["Field-Side Switch (see network core diagram) — IGMP Snooping"]
+ Note["All Links: GbE · PoE: Remotes Only"]
+
+ subgraph Officiating["Officiating Core"]
+ Vision["Vision Computer"]
+ GC["Game Controller"]
+ BR["Blue Remote GbE · PoE"]
+ YR["Yellow Remote GbE · PoE"]
+ SB["Status Board"]
+ Vision ~~~ GC ~~~ BR ~~~ YR ~~~ SB
+ end
+
+ subgraph TeamFacing["Team-Provided"]
+ BT["Blue Team PC"]
+ YT["Yellow Team PC"]
+ BT ~~~ YT
+ end
+
+ subgraph Broadcast["Broadcast / AV — Optional"]
+ Stream["Streaming PC"]
+ Audio["Audio Referee"]
+ Boards["Boards ×(N+1)"]
+ Stream ~~~ Audio ~~~ Boards
+ end
+
+ Note ~~~ Officiating
+ Note ~~~ TeamFacing
+ Note ~~~ Broadcast
+ end
+
+ classDef cyan fill:#ecfeff,stroke:#0891b2,color:#164e63;
+ classDef blue fill:#eff6ff,stroke:#2563eb,color:#1e3a8a;
+ classDef yellow fill:#fefce8,stroke:#ca8a04,color:#713f12;
+ classDef orange fill:#fff7ed,stroke:#ea580c,color:#7c2d12;
+
+ class Vision,GC,SB,Stream,Boards cyan;
+ class BR,BT blue;
+ class YR,YT yellow;
+ class Audio orange;
+
+ style Note fill:none,stroke:none,color:#475569,font-style:italic
+ style Switch fill:none,stroke:#0891b2,stroke-dasharray:4 3,stroke-width:1.5px
+ style Officiating fill:none,stroke:#0891b2,stroke-dasharray:3 3,stroke-width:1.5px
+ style TeamFacing fill:none,stroke:#64748b,stroke-dasharray:3 3,stroke-width:1.5px
+ style Broadcast fill:none,stroke:#ea580c,stroke-dasharray:3 3,stroke-width:1.5px
\ No newline at end of file
diff --git a/docs-source/field/network/diagrams/net-core-vlan.mmd b/docs-source/field/network/diagrams/net-core-vlan.mmd
new file mode 100644
index 0000000..6a6b275
--- /dev/null
+++ b/docs-source/field/network/diagrams/net-core-vlan.mmd
@@ -0,0 +1,26 @@
+---
+config:
+ layout: elk
+ elk:
+ mergeEdges: false
+ nodePlacementStrategy: BRANDES_KOEPF
+---
+flowchart TB
+ Upstream["Upstream / Venue Network"] --> Router["Router / Firewall Shared — Per-Venue"]
+ Router -- "Trunk — 802.1Q Tagged" --> S1["Field 1 Switch VLAN 10 · Tagged"]
+ Router -- "Trunk — 802.1Q Tagged" --> S2["Field 2 Switch VLAN 20 · Tagged"]
+ Router -- "Trunk — 802.1Q Tagged" --> SN["Field N Switch VLAN N0+"]
+
+ S1 --> Truss1["Truss Cameras"]
+ S1 --> FS1["Field-Side Devices"]
+ S2 -.-> Note2["(Same downstream pattern as Field 1)"]
+ SN -.-> NoteN["(Same downstream pattern as Field 1)"]
+
+ classDef amber fill:#fffbeb,stroke:#d97706,color:#78350f;
+ classDef cyan fill:#ecfeff,stroke:#0891b2,color:#164e63;
+ classDef gray fill:#f1f5f9,stroke:#64748b,color:#1e293b;
+ classDef slate fill:#f8fafc,stroke:#475569,color:#0f172a;
+ class Upstream slate;
+ class Router amber;
+ class S1,S2,SN cyan;
+ class Truss1,FS1,Note2,NoteN gray;
\ No newline at end of file
diff --git a/docs-source/field/network/diagrams/net-core.mmd b/docs-source/field/network/diagrams/net-core.mmd
new file mode 100644
index 0000000..5b23cd8
--- /dev/null
+++ b/docs-source/field/network/diagrams/net-core.mmd
@@ -0,0 +1,20 @@
+---
+config:
+ layout: elk
+ elk:
+ nodePlacementStrategy: NETWORK_SIMPLEX
+---
+flowchart TB
+ Upstream["Upstream / Venue Network"] --> Router["Router / Firewall DHCP · Isolates Multicast"]
+ Router --> Switch["Field-Side Switch PoE · IGMP Snooping"]
+ Switch --> TrussLink["To Truss Cameras (previous diagram)"]
+ Switch --> Endpoints["To Field-Side Devices (next diagram)"]
+
+ classDef amber fill:#fffbeb,stroke:#d97706,color:#78350f;
+ classDef cyan fill:#ecfeff,stroke:#0891b2,color:#164e63;
+ classDef gray fill:#f1f5f9,stroke:#64748b,color:#1e293b;
+ classDef slate fill:#f8fafc,stroke:#475569,color:#0f172a;
+ class Upstream slate;
+ class Router amber;
+ class Switch cyan;
+ class TrussLink,Endpoints gray;
\ No newline at end of file
diff --git a/docs-source/field/network/diagrams/ssl_field_network_fanout_direct.drawio b/docs-source/field/network/diagrams/ssl_field_network_fanout_direct.drawio
new file mode 100644
index 0000000..fa2a11e
--- /dev/null
+++ b/docs-source/field/network/diagrams/ssl_field_network_fanout_direct.drawio
@@ -0,0 +1,206 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs-source/field/network/diagrams/ssl_field_network_fanout_truss.drawio b/docs-source/field/network/diagrams/ssl_field_network_fanout_truss.drawio
new file mode 100644
index 0000000..3e016d5
--- /dev/null
+++ b/docs-source/field/network/diagrams/ssl_field_network_fanout_truss.drawio
@@ -0,0 +1,220 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs-source/field/network/images/FLIR_logo.png b/docs-source/field/network/images/FLIR_logo.png
new file mode 100644
index 0000000..6081af5
Binary files /dev/null and b/docs-source/field/network/images/FLIR_logo.png differ
diff --git a/docs-source/field/network/images/FLIR_logo.svg.webp b/docs-source/field/network/images/FLIR_logo.svg.webp
new file mode 100644
index 0000000..061b97a
Binary files /dev/null and b/docs-source/field/network/images/FLIR_logo.svg.webp differ
diff --git a/docs-source/field/network/images/Xubuntu.png b/docs-source/field/network/images/Xubuntu.png
new file mode 100644
index 0000000..5ddaefa
Binary files /dev/null and b/docs-source/field/network/images/Xubuntu.png differ
diff --git a/docs-source/field/network/images/Xubuntu.svg b/docs-source/field/network/images/Xubuntu.svg
new file mode 100644
index 0000000..aef12bb
--- /dev/null
+++ b/docs-source/field/network/images/Xubuntu.svg
@@ -0,0 +1,24 @@
+
+
\ No newline at end of file
diff --git a/docs-source/field/network/images/raspberry-pi.png b/docs-source/field/network/images/raspberry-pi.png
new file mode 100644
index 0000000..f5a6f66
Binary files /dev/null and b/docs-source/field/network/images/raspberry-pi.png differ
diff --git a/docs-source/field/network/images/raspberry-pi.svg b/docs-source/field/network/images/raspberry-pi.svg
new file mode 100644
index 0000000..99331e8
--- /dev/null
+++ b/docs-source/field/network/images/raspberry-pi.svg
@@ -0,0 +1,34 @@
+
+
+
\ No newline at end of file
diff --git a/docs-source/field/network/images/ssl_physical_truss_direct_config.png b/docs-source/field/network/images/ssl_physical_truss_direct_config.png
new file mode 100644
index 0000000..5db9ebf
Binary files /dev/null and b/docs-source/field/network/images/ssl_physical_truss_direct_config.png differ
diff --git a/docs-source/field/network/images/ssl_physical_truss_direct_config.svg b/docs-source/field/network/images/ssl_physical_truss_direct_config.svg
new file mode 100644
index 0000000..518750d
--- /dev/null
+++ b/docs-source/field/network/images/ssl_physical_truss_direct_config.svg
@@ -0,0 +1,28 @@
+
\ No newline at end of file
diff --git a/docs-source/field/network/images/ssl_physical_truss_nuc_config_v2.png b/docs-source/field/network/images/ssl_physical_truss_nuc_config_v2.png
new file mode 100644
index 0000000..588566a
Binary files /dev/null and b/docs-source/field/network/images/ssl_physical_truss_nuc_config_v2.png differ
diff --git a/docs-source/field/network/images/ssl_physical_truss_nuc_config_v2.svg b/docs-source/field/network/images/ssl_physical_truss_nuc_config_v2.svg
new file mode 100644
index 0000000..3b3db57
--- /dev/null
+++ b/docs-source/field/network/images/ssl_physical_truss_nuc_config_v2.svg
@@ -0,0 +1,37 @@
+
\ No newline at end of file
diff --git a/docs-source/field/network/images/ubuntu.png b/docs-source/field/network/images/ubuntu.png
new file mode 100644
index 0000000..90d3578
Binary files /dev/null and b/docs-source/field/network/images/ubuntu.png differ
diff --git a/docs-source/field/network/images/ubuntu.svg b/docs-source/field/network/images/ubuntu.svg
new file mode 100644
index 0000000..02e8546
--- /dev/null
+++ b/docs-source/field/network/images/ubuntu.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/docs-source/field/network/images/win11.png b/docs-source/field/network/images/win11.png
new file mode 100644
index 0000000..11ff368
Binary files /dev/null and b/docs-source/field/network/images/win11.png differ
diff --git a/docs-source/field/network/images/win11.webp b/docs-source/field/network/images/win11.webp
new file mode 100644
index 0000000..3c1d393
Binary files /dev/null and b/docs-source/field/network/images/win11.webp differ
diff --git a/docs-source/field/network/labnetwork.md b/docs-source/field/network/labnetwork.md
new file mode 100644
index 0000000..59f2bfe
--- /dev/null
+++ b/docs-source/field/network/labnetwork.md
@@ -0,0 +1,17 @@
+# Lab Network
+
+## Detailed Diagram
+
+```{image} diagrams/ssl_field_network_fanout_direct.light.svg
+---
+class: only-light
+alt: SSL field network fan-out diagram, direct-wired camera config
+---
+```
+
+```{image} diagrams/ssl_field_network_fanout_direct.dark.svg
+---
+class: only-dark
+alt: SSL field network fan-out diagram, direct-wired camera config
+---
+```
diff --git a/docs-source/index.md b/docs-source/index.md
index d4ee2ce..6cd1316 100644
--- a/docs-source/index.md
+++ b/docs-source/index.md
@@ -1,8 +1,34 @@
-# SSL Docs
+# Home Page
-Welcome to the RoboCup Small Size League documentation.
+This is the documentation for [RoboCup](https://www.robocup.org/) [Small Size League](https://ssl.robocup.org/). It
+contains motivation, extended documentation, and detailed first time setup and usage guidance for a variety of topics
+common to the league as a whole.
+
+## Common Pages of Interest
+
+- [Field Technical Documentation](field.md)
+- [Field Camera Calculator](field/camera-calculator.md)
+- [Protocol/Coms Technical Documentation](protocol.md)
+
+## Motivation
+
+All SSL teams are required to interact with shared field software in order to play a match. This includes
+[vision](https://github.com/TIGERs-Mannheim/vision-processor) (robot and ball positions) and
+[game controller](https://github.com/robocup-ssl/ssl-game-controller) (game state). There are also a variety of other
+league tools that need to establish communication links, some of which are helpful for teams like autoref. This raises a
+key engineering challenge: standardizing the communication so that dozens of teams and tools can all show up and expect
+to communicate correctly.
+
+The SSL uses [Protobuf](https://protobuf.dev/) to standardize the tool data formats. Protobuf defines an interface
+description language, and the league creates packet definitions for each tool using this interface language. These
+packet definitions are defined here. A single source of truth for packet definitions provides clarity to the standard,
+and helps avoid diverging versions and licensing incompatibility.
```{toctree}
-:maxdepth: 2
-:caption: Contents
+---
+maxdepth: 0
+caption: Contents
+---
+field
+protocol
```
diff --git a/docs-source/protocol.md b/docs-source/protocol.md
new file mode 100644
index 0000000..ba27bee
--- /dev/null
+++ b/docs-source/protocol.md
@@ -0,0 +1,12 @@
+# League Protocols and Communication
+
+```{toctree}
+---
+maxdepth: 1
+caption: Contents
+---
+protocol/vision.md
+protocol/referee.md
+protocol/teamclient.md
+protocol/simulator.md
+```
diff --git a/docs-source/protocol/referee.md b/docs-source/protocol/referee.md
new file mode 100644
index 0000000..8793b19
--- /dev/null
+++ b/docs-source/protocol/referee.md
@@ -0,0 +1,10 @@
+# Referee Protocol
+
+## Transport
+
+## Message Format
+
+## League Software
+
+The league's game management software, [Game Controller](https://github.com/RoboCup-SSL/ssl-game-controller), manages
+game state and publishes it as this protocol.
diff --git a/docs-source/protocol/simulator.md b/docs-source/protocol/simulator.md
new file mode 100644
index 0000000..fef8b28
--- /dev/null
+++ b/docs-source/protocol/simulator.md
@@ -0,0 +1,7 @@
+# Simulator Protocol
+
+## Transport
+
+## Message Format
+
+## League Software
diff --git a/docs-source/protocol/teamclient.md b/docs-source/protocol/teamclient.md
new file mode 100644
index 0000000..cc13712
--- /dev/null
+++ b/docs-source/protocol/teamclient.md
@@ -0,0 +1,7 @@
+# Team Client Protocol
+
+## Transport
+
+## Message Format
+
+## League Software
diff --git a/docs-source/protocol/vision.md b/docs-source/protocol/vision.md
new file mode 100644
index 0000000..b03d690
--- /dev/null
+++ b/docs-source/protocol/vision.md
@@ -0,0 +1,12 @@
+# Vision Protocol
+
+## Transport
+
+## Message Format
+
+## League Software
+
+There are two pieces of league maintained software that interpret camera data:
+
+- (Recommended) [Vision Processor](https://github.com/TIGERs-Mannheim/vision-processor)
+- (Legacy) [SSL Vision](https://github.com/RoboCup-SSL/ssl-vision)
diff --git a/flake.nix b/flake.nix
index 6a36503..07fa2ef 100644
--- a/flake.nix
+++ b/flake.nix
@@ -43,6 +43,64 @@
]);
venv = pythonSet.mkVirtualEnv "ssl-docs-env" workspace.deps.default;
+
+ # drawio is Electron, and Electron's "auto" ozone platform detection
+ # picks Wayland whenever it finds a live compositor socket under
+ # $XDG_RUNTIME_DIR — which sphinxcontrib-drawio's own Xvfb (X11) then
+ # collides with, crashing the export. Force X11 unconditionally so
+ # this works headless in CI and on any contributor's Wayland desktop.
+ #
+ # drawio's SVG export always wraps every fill/stroke/font color in a
+ # light-dark(light, dark) CSS function, even for colors the diagram
+ # sets explicitly — --svg-theme light only changes a couple of
+ # built-in defaults, not this. sphinxcontrib-drawio has no config
+ # knob to reach it either, so the exported SVG is post-processed
+ # here to collapse light-dark(a, b) down to a single static color.
+ #
+ # Which side of the pair survives is controlled by $DRAWIO_SVG_THEME
+ # (light [default] or dark) rather than always picking the light
+ # value: light-dark() inside an -embedded SVG resolves against
+ # the *browser's* OS/UA color-scheme preference, not the host page's
+ # — and Furo's manual light/dark toggle never sets the CSS
+ # color-scheme property, only a data-theme attribute + CSS classes.
+ # So a live light-dark() SVG can show dark colors while the page is
+ # explicitly toggled to light. conf.py renders one static SVG per
+ # theme via this switch and picks between them with Furo's own
+ # only-light/only-dark image classes instead.
+ drawio = pkgs.writeShellScriptBin "drawio" ''
+ ${pkgs.drawio}/bin/drawio --ozone-platform=x11 "$@"
+ status=$?
+ if [ $status -ne 0 ]; then
+ exit $status
+ fi
+
+ out=""
+ prev=""
+ for arg in "$@"; do
+ if [ "$prev" = "--output" ]; then
+ out="$arg"
+ fi
+ prev="$arg"
+ done
+
+ case "$out" in
+ *.svg)
+ if [ -f "$out" ]; then
+ if [ "''${DRAWIO_SVG_THEME:-light}" = "dark" ]; then
+ ${pkgs.gnused}/bin/sed -E -i \
+ -e 's/light-dark\(((#[0-9a-fA-F]+)|(rgb\([0-9, ]+\))), *((#[0-9a-fA-F]+)|(rgb\([0-9, ]+\)))\)/\4/g' \
+ -e 's/--ge-adaptive-bg: light-dark\([^,]+, var\(--ge-dark-color, *([^)]+)\)\)/--ge-adaptive-bg: \1/g' \
+ "$out"
+ else
+ ${pkgs.gnused}/bin/sed -E -i \
+ -e 's/light-dark\(((#[0-9a-fA-F]+)|(rgb\([0-9, ]+\))), *((#[0-9a-fA-F]+)|(rgb\([0-9, ]+\)))\)/\1/g' \
+ -e 's/--ge-adaptive-bg: light-dark\(([^,]+), var\([^)]*\)\)/--ge-adaptive-bg: \1/g' \
+ "$out"
+ fi
+ fi
+ ;;
+ esac
+ '';
in
{
devShells.default = pkgs.mkShell {
@@ -50,6 +108,9 @@
venv
pkgs.uv
pkgs.codespell
+ drawio
+ pkgs.xvfb
+ pkgs.xvfb-run
];
shellHook = ''
diff --git a/pyproject.toml b/pyproject.toml
index 0e04fb5..0f9cdaa 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,7 +8,18 @@ dependencies = [
"myst-parser>=2.0",
"furo>=2024.1.29",
"sphinx-autobuild>=2024.10.3",
+ "mdformat>=0.7",
+ "mdformat-myst>=0.2",
+ "mdformat-gfm>=0.4",
+ "sphinxcontrib-youtube>=1.4",
+ "sphinxcontrib-mermaid>=0.9",
+ "sphinxcontrib-drawio>=0.0.17",
]
[tool.uv]
package = false
+
+[tool.codespell]
+# Generated per-theme drawio SVG exports (see conf.py) embed base64 font
+# data whose glyph byte runs randomly collide with codespell's dictionary.
+skip = "*.light.svg,*.dark.svg"
diff --git a/scripts/check.sh b/scripts/check.sh
index 85ca7ca..302fbac 100755
--- a/scripts/check.sh
+++ b/scripts/check.sh
@@ -30,6 +30,7 @@ run_check build nix develop -c sphinx-build -W -b html docs-source _build/html
run_check internal-links nix develop -c sphinx-build -W -n -b dummy docs-source _build/dummy
run_check external-links nix develop -c sphinx-build -b linkcheck docs-source _build/linkcheck
run_check spellcheck nix develop -c codespell docs-source README.md
+run_check format nix develop -c mdformat --check --wrap 120 docs-source README.md
echo
echo "== Summary =="
diff --git a/scripts/reverify-ignored-links.sh b/scripts/reverify-ignored-links.sh
new file mode 100755
index 0000000..00e547b
--- /dev/null
+++ b/scripts/reverify-ignored-links.sh
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+# Open the linkcheck-ignored URLs (docs-source/conf.py's linkcheck_ignore) in
+# a browser for periodic human re-verification. These are WAF-blocked sites
+# that reject every automated request, so linkcheck can't check them itself
+# — see the comment above linkcheck_ignore for details.
+#
+# After checking, bump the "verified YYYY-MM-DD" comment on each entry you
+# confirmed still resolves correctly.
+#
+# Usage:
+# scripts/reverify-ignored-links.sh [-y|--yes]
+#
+# -y, --yes Skip the confirmation prompt (for automation/CI use).
+
+set -euo pipefail
+
+assume_yes=0
+for arg in "$@"; do
+ case "$arg" in
+ -y|--yes)
+ assume_yes=1
+ ;;
+ *)
+ echo "Usage: $0 [-y|--yes]" >&2
+ exit 1
+ ;;
+ esac
+done
+
+repo_root="$(git rev-parse --show-toplevel)"
+conf_py="$repo_root/docs-source/conf.py"
+
+if [[ ! -f "$conf_py" ]]; then
+ echo "Can't find $conf_py" >&2
+ exit 1
+fi
+
+# Pull the quoted regex strings out of the linkcheck_ignore = [ ... ] block,
+# then de-regex them back into plain URLs. Each entry is an anchored,
+# backslash-escaped literal URL (^...$ with \. \? etc.), so stripping the
+# anchors and any backslash before a single char recovers the original URL.
+mapfile -t urls < <(
+ awk '/^linkcheck_ignore = \[/{flag=1; next} /^\]/{flag=0} flag' "$conf_py" \
+ | grep -oP '(?<=r")[^"]+(?=")' \
+ | sed -E 's/^\^//; s/\$$//; s/\\(.)/\1/g'
+)
+
+if [[ ${#urls[@]} -eq 0 ]]; then
+ echo "No entries found in linkcheck_ignore ($conf_py)." >&2
+ exit 0
+fi
+
+browsers=(google-chrome google-chrome-stable chrome chromium chromium-browser firefox)
+browser=""
+for candidate in "${browsers[@]}"; do
+ if command -v "$candidate" >/dev/null 2>&1; then
+ browser="$candidate"
+ break
+ fi
+done
+
+if [[ -z "$browser" ]]; then
+ echo "No browser found on PATH (looked for: ${browsers[*]})." >&2
+ echo "Check these manually instead:" >&2
+ printf ' %s\n' "${urls[@]}" >&2
+ exit 1
+fi
+
+echo "About to open ${#urls[@]} ignored link(s) in $browser for manual re-verification:"
+printf ' %s\n' "${urls[@]}"
+
+if [[ "$assume_yes" -ne 1 ]]; then
+ if [[ ! -t 0 ]]; then
+ echo "Not running interactively — pass -y/--yes to skip this prompt." >&2
+ exit 1
+ fi
+ read -r -p "Open these in $browser? [y/N] " reply
+ case "$reply" in
+ y|Y|yes|YES) ;;
+ *)
+ echo "Aborted." >&2
+ exit 1
+ ;;
+ esac
+fi
+
+"$browser" "${urls[@]}" &
+disown
diff --git a/uv.lock b/uv.lock
index 598880b..47e538c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -288,6 +288,78 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
+[[package]]
+name = "mdformat"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz", hash = "sha256:4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928", size = 56953, upload-time = "2025-10-16T12:05:03.695Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl", hash = "sha256:bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b", size = 53288, upload-time = "2025-10-16T12:05:02.607Z" },
+]
+
+[[package]]
+name = "mdformat-footnote"
+version = "0.1.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdformat" },
+ { name = "mdit-py-plugins" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3f/c0/0fe461e3c53eb72b35b642add6c3eaf1f90e43a574a18633955f9e89791d/mdformat_footnote-0.1.3.tar.gz", hash = "sha256:70617e61af87f59d7dea93a392c5093089a6d4551126fa18025c6fddb18bf742", size = 6430, upload-time = "2026-01-30T14:13:05.983Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/ac/60d47237a0e37fe991cbf311f10f096a28cfdbbe25d1932dadf8e9abdac8/mdformat_footnote-0.1.3-py3-none-any.whl", hash = "sha256:3033184237cbaf2a71cc02a4c37f043a97c1f16d3ed6939bc104964e77aad0b3", size = 7767, upload-time = "2026-01-30T14:13:04.944Z" },
+]
+
+[[package]]
+name = "mdformat-front-matters"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdformat" },
+ { name = "mdit-py-plugins" },
+ { name = "ruamel-yaml" },
+ { name = "toml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2c/53/008805510d3006d9aec6c602fa40305750cb96daba8ea9abae477149909b/mdformat_front_matters-2.0.0.tar.gz", hash = "sha256:46efabe93707699120c67d4b33b51fdb4cfe0e040acfb0f8212eb33c9a3e8a9a", size = 12576, upload-time = "2025-12-04T11:44:52.265Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/00/05/4c197c000dab41f1f58290221a4c02bd57804ff42e9374d3dd0f5408aa40/mdformat_front_matters-2.0.0-py3-none-any.whl", hash = "sha256:8ea92d23d1e9427fe6548b9044e5fbf7bcf6c217941f1a9442e0efd8b0a76e61", size = 13411, upload-time = "2025-12-04T11:44:51.209Z" },
+]
+
+[[package]]
+name = "mdformat-gfm"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "mdformat" },
+ { name = "mdit-py-plugins" },
+ { name = "wcwidth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz", hash = "sha256:d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623", size = 10112, upload-time = "2025-10-16T09:12:22.402Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl", hash = "sha256:7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005", size = 10970, upload-time = "2025-10-16T09:12:21.276Z" },
+]
+
+[[package]]
+name = "mdformat-myst"
+version = "0.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdformat" },
+ { name = "mdformat-footnote" },
+ { name = "mdformat-front-matters" },
+ { name = "mdformat-gfm" },
+ { name = "mdit-py-plugins" },
+ { name = "ruamel-yaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a8/e1/e41d0a8acb2485c9da0fb194545c9827ce1399534110a5e45c465221318f/mdformat_myst-0.3.0.tar.gz", hash = "sha256:8195dec075683cac689c5586a5e4c6700ec8d6df0f9bc34a1e2f3514d5e745b0", size = 30032, upload-time = "2025-11-27T18:52:26.466Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/7b/2e8b6fa15fc47f5a512ebbe4bba4e2d5dfc988092269fc1cd3d22c9dad45/mdformat_myst-0.3.0-py3-none-any.whl", hash = "sha256:45e305cb1e742e712c0a8410315c351f3ab43e702d979d4f0eea9d9831245adc", size = 7088, upload-time = "2025-11-27T18:52:25.618Z" },
+]
+
[[package]]
name = "mdit-py-plugins"
version = "0.6.1"
@@ -414,6 +486,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" },
]
+[[package]]
+name = "ruamel-yaml"
+version = "0.19.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" },
+]
+
[[package]]
name = "snowballstemmer"
version = "3.1.1"
@@ -507,6 +588,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" },
]
+[[package]]
+name = "sphinxcontrib-drawio"
+version = "0.0.17"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ea/9b/45a4e9f7d5b3f3e8e3fa792f99b20d2f6d337c4b84f9e55044aecf0e60c8/sphinxcontrib-drawio-0.0.17.tar.gz", hash = "sha256:1a3f82efd1ab4b41d1ee8dd27c296bae0944a10faca3568c462dc6b9a77748f5", size = 25645, upload-time = "2023-09-12T16:48:52.488Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/34/d4/81b8c94f216ef2fb68475b2f5a2836495e5da4d4ce011b3e41e663b89d1f/sphinxcontrib_drawio-0.0.17-py3-none-any.whl", hash = "sha256:c42e3fee5398da52836e8ee5c61c2f19bce947abb2d17b857057fb32137c1096", size = 10727, upload-time = "2023-09-12T16:48:50.826Z" },
+]
+
[[package]]
name = "sphinxcontrib-htmlhelp"
version = "2.1.0"
@@ -525,6 +618,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" },
]
+[[package]]
+name = "sphinxcontrib-mermaid"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jinja2" },
+ { name = "pyyaml" },
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/07/9d/bf3a48a657682c7e0445c71d916bca7ab8194454276cc22b16e7f974e502/sphinxcontrib_mermaid-2.1.0.tar.gz", hash = "sha256:13c5f9ac395cb6abf403eca34e228dc9fb3a30c9d960dbf3e40e9a8cef969549", size = 21695, upload-time = "2026-07-18T23:08:11.265Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f3/bd/d3348d62296e73a91420f0edf671450c9003ecd8704da40b1e3d9b868459/sphinxcontrib_mermaid-2.1.0-py3-none-any.whl", hash = "sha256:417cd144ec4b28852f46ba653f02ce8e538881c812111671a4c30344e87f2112", size = 16190, upload-time = "2026-07-18T23:08:10.098Z" },
+]
+
[[package]]
name = "sphinxcontrib-qthelp"
version = "2.0.0"
@@ -543,23 +650,48 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" },
]
+[[package]]
+name = "sphinxcontrib-youtube"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/54/5c/61d1f11ad543f3a4da3b463de69f743051164087b5b9615aff9a1d2e49b8/sphinxcontrib_youtube-1.5.0.tar.gz", hash = "sha256:541b5880096e0a8759f49711f45087c91d6992ff45f03c178e974c66c8f5e326", size = 6088, upload-time = "2026-04-01T13:57:38.1Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/98/5a7457f5c57da4d30185a63fca7beff54e35fca8328473a92e90a1d621de/sphinxcontrib_youtube-1.5.0-py2.py3-none-any.whl", hash = "sha256:58d92bec536fbf029b642de3c9bbd32c7de307e0e13686015a0c0e91b7dfe3b8", size = 7680, upload-time = "2026-04-01T13:57:37.201Z" },
+]
+
[[package]]
name = "ssl-docs"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "furo" },
+ { name = "mdformat" },
+ { name = "mdformat-gfm" },
+ { name = "mdformat-myst" },
{ name = "myst-parser" },
{ name = "sphinx" },
{ name = "sphinx-autobuild" },
+ { name = "sphinxcontrib-drawio" },
+ { name = "sphinxcontrib-mermaid" },
+ { name = "sphinxcontrib-youtube" },
]
[package.metadata]
requires-dist = [
{ name = "furo", specifier = ">=2024.1.29" },
+ { name = "mdformat", specifier = ">=0.7" },
+ { name = "mdformat-gfm", specifier = ">=0.4" },
+ { name = "mdformat-myst", specifier = ">=0.2" },
{ name = "myst-parser", specifier = ">=2.0" },
{ name = "sphinx", specifier = ">=7.0" },
{ name = "sphinx-autobuild", specifier = ">=2024.10.3" },
+ { name = "sphinxcontrib-drawio", specifier = ">=0.0.17" },
+ { name = "sphinxcontrib-mermaid", specifier = ">=0.9" },
+ { name = "sphinxcontrib-youtube", specifier = ">=1.4" },
]
[[package]]
@@ -575,6 +707,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
]
+[[package]]
+name = "toml"
+version = "0.10.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" },
+]
+
[[package]]
name = "typing-extensions"
version = "4.16.0"
@@ -692,6 +833,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
]
+[[package]]
+name = "wcwidth"
+version = "0.8.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" },
+]
+
[[package]]
name = "websockets"
version = "17.0.1"