# CLAUDE.md - Hostbound

Durable context for working in this repo. Read this first.

## What this is

Hostbound is a single-page browser **game** that teaches intro-networking
students the SSH *perspective shift*: once you `ssh` into a machine, commands
run there, and the network you're on decides who you can reach. The player
explores a procedurally generated network to find a hidden **golden machine**,
using `ip a` / `ping` / `nmap` / `ssh`. Fewer hops and fewer claims = better.

The current implementation is **v2** (a seeded network-exploration game). An
earlier v1 (scripted SSH-stack story) was fully discarded - ignore it.

## Hard constraints (do not break)

- **Single file, no build, no server.** Everything lives in `index.html`
  (embedded CSS + vanilla JS, Canvas for the map). It must run by
  double-clicking (`file://`) and as a static file. **No external resources**
  (no CDN, no fonts, no libraries) - verified by a "no http src/href" check.
- **Public deploy.** This directory IS the docroot served at
  <https://www.its.ohio.edu/tools/hostbound/>. Anything committed here is
  public. **Never leave scratch files** - delete `_*.py` etc. before finishing.
- **Persistence.** Hard reset on reload. Only **accessibility
  preferences** persist in `localStorage`: `hostbound.hivis` (hi-vis
  flag) and `hostbound.theme` (U4 - `dark`/`light`/`auto`, default
  `auto`). No other keys; game progress is never persisted.
  **Code system (REWORKED, single source of truth for shareable
  state):** the **code itself** now carries family + difficulty +
  HHM + Firewalls + topology seed - no URL params, no out-of-band
  knowledge. A student pasting just `HB1-2DEMOFW0` into the footer
  reproduces the sender's EXACT playable state. Format:
  `HB1-<M><SEED>` pure IPv4, `<M>` = 1 base36 mode char, `<SEED>`
  = 7 base36 chars topology seed (8-char body total);
  `HB2-<M><SEED>` pure IPv6 and `HB3-<M><SEED>` N5 dual-stack have
  the same body shape; `HB4-<PACKED-6><M><NONCE>` P3 sandbox = 6
  packed-param chars + 1 mode char + base36 nonce, with the mode
  char BETWEEN packed and nonce so seedFromCode can strip it and
  keep the topology invariant under HHM/FW toggles.
  Mode-char encoding (`modeChar`/`parseModeChar`):
    HB1-3 `idx = diffIdx*6 + hhmIdx*2 + fwBit` (0..29 base36 0-T).
    HB4   `idx = hhmIdx*2 + fwBit`             (0..5  base36 0-5).
  `seedFromCode` strips the mode char(s) and uses only the seed
  body (parseInt for HB1-3, FNV-1a of packed+nonce for HB4) so
  HHM (render-only) and FW (off-state) toggles never perturb the
  topology seed - the proven generator behaviour is unchanged at
  the same seed body. `applyModeFromCode(code)` runs at the top of
  `startGame` and syncs `gDiff`/`gHHM`/`gFW` from the code's bits
  BEFORE `genWorld`, so the world is built at exactly the pinned
  mode. The toggles (`cycleDiff`/`toggleHHM`/`toggleFW`) each
  rebuild `W.code` via `rebuildCodeWithMode()` (same seed body,
  fresh mode char) and call `startGame` (diff/FW regenerate;
  `toggleHHM` is render-only and skips `genWorld`, just updates
  `W.code` + `location.hash` + redraws). `CODE_RE` is unchanged
  `/^HB[1234]-/i`. **Copy Share Link** and **Copy Challenge Link**
  both emit `<base>#<code>` - no `?d=`/`?h=` URL params (those
  were a transitional hack and were removed in the rework). Game
  progress / view state during a session is still in-memory only.
  **Address
  mode is in-memory only** the same way (`gFamily`, default `ipv4`;
  values `ipv4`/`ipv6`/`dual`): a clickable **`#fambadge`** (top
  right, in the `#topctl` flex cluster, just right of the
  difficulty badge - colour per mode `f-ipv4`/`f-ipv6`/`f-dual`;
  the old menu item was removed) cycles IPv4→IPv6→Dual-stack and
  only steers NEW codes; it is NOT persisted
  and NOT a separate hash field - `familyOfCode()` derives it from the
  code's `HB1-`/`HB2-`/`HB3-` prefix, so reload with no code → IPv4,
  an `HB2-` code → IPv6, an `HB3-` code → dual-stack. Caveat: a shared code reproduces a world only at
  the SAME difficulty (difficulty is mixed into the seed; family is
  NOT - it only changes addressing, not topology shape). If cross-peer
  reproducibility at a chosen level is ever needed, that's a future
  hash-format change.
- **Determinism.** A network code **+ difficulty** maps to one mulberry32
  stream (`seedFromCode(code) ^ imul(diffIdx+1,0x9E3779B9)`); the same
  code+level must regenerate a byte-identical world. Keep all randomness
  flowing
  from that single seeded RNG in a fixed order. **Derived streams**
  are spun off it by XORing a distinct constant into the seed so they
  never perturb the topology draw order: the lattice embedder
  (`^0x9E3779B9`), the **address pass** (`^0x5BD1E995`,
  `assignAddresses` - N5: in an HB1 world this stream also draws each
  LAN's family before its address, so families are deterministic per
  code+level and never perturb topology), the **name pass**
  (`^0x27D4EB2F`,
  `assignNames`, HO1), and the **hint-file pass** (`^0x85EBCA6B`,
  H3 - per-host `<word>.txt` filename + `hintBody`). The
  topology-shaping draws (`G`, `leftArm`,
  fillers, branches, loops, then the G1 **router pass** appended LAST
  on this same main stream) happen BEFORE any IP is assigned, so
  subnet sizing/addressing can change freely without altering world
  shape; the separate address/name/hint streams iterate the FINAL
  host/LAN arrays so the extra router decoys don't desync them.
  Family is NOT mixed into the seed - `assignAddresses` just branches
  v4/v6 on `fam`, so `HB1-X` and `HB2-X` at the same level share the
  exact same topology and differ only in addressing.
- **No commits unless asked.** `git-go.bat` (add/commit/push) is the user's to
  run. Branch off `main` if asked to commit.

## Conventions

- Docs live in `docs/` (e.g. `docs/TODO.md`). `README.md`, `LICENSE`,
  `CLAUDE.md` stay at root by convention.
- Attribution is **Ohio University · ECT Dept** - NOT Ohio State, NOT ITS.
- **No ellipsis in menu/UI labels** - never use `…`/`&hellip;`/`...` in
  menu items, buttons, or labels (user preference). Plain text only.
- Assets in `images/` (reference by relative path): `ducky-small-canvas-
  clear-ssh.png` = favicon + About/win logo; `switch.png` = LAN switch
  icon; `client-{blue,green,orange,purple,red,yellow}.png` = host icons
  (colour picked deterministically per host via `netHash`); `cloud.svg`
  currently unused (no internet node in the model). Icons are preloaded
  with a fallback shape if not yet loaded.
- Code comments only on non-obvious mechanics (RNG, generator connectivity,
  reachability rules, map layout). No narrating obvious DOM code.
- Canvas text must be ASCII-safe - glyphs like `←`/`◀` render
  inconsistently; use `<<<`, `(YOU)`, plain words instead. (HTML/CSS text is
  fine for non-ASCII.)

## Game model (agreed with the user)

- **Reachability: same-LAN only.** You can ping/nmap/ssh a host only if it
  shares one of your current host's directly-connected subnets. No routing in
  v2 (routing is a future idea in `docs/TODO.md`). `ssh` accepts an IP in
  any world and ALSO a bare **hostname in IPv6 worlds** (N6,
  `resolveSshTarget`) - name resolution still enforces same-LAN, it is
  not a routing bypass. Only interfaces with an active connection to
  another host (LAN ≥2 hosts) list an address (U1); `ip a` omits the
  rest.
- **Map: fog-of-war.** Subnets/hosts appear only as discovered via
  `ip a` (your subnets), `ping` (one host), `nmap` (a connected subnet's
  hosts), `ssh` (the host you land on).
- **Hints: mixed, file-delivered (H3).** Every non-golden host's
  EXACT hop-distance to the golden machine plus a rotating true
  IP-octet of it (guarantees a solvable greedy descent) is NO
  longer printed on ssh-in - it lives in the host's home as one
  seeded `<word>.txt` file (`h.hintFile`/`h.hintBody`), read via
  `ls`/`cat`/`nano` (`nano` = a full-screen read-only overlay).
  Clue text byte-identical to the old banner line, so solvability
  is unchanged. EVERY host (incl. golden) has a file so presence
  is not a tell; golden's body is the clue-free "No recon data
  here." and its banner stays clue-free. The relative
  `Bearing: warmer/colder` ssh cue is a separate, weaker mechanic
  and intentionally remains in `banner()`.
- **Claim:** `claim` (or the gold button). Wrong → "Not the golden machine",
  `claims++`, continue. Right → win panel. Lower hops/claims is the score.
- **Generator:** a guaranteed **chain of LANs** joined by dedicated
  bridge hosts. The START is a filler on an **interior** chain LAN
  (`chain[leftArm]`): the spine runs **both ways** from it -
  `chain[0..leftArm-1]` is a dead-end direction, `chain[leftArm..C-1]`
  leads to golden. `C = G + leftArm`, `G` is the real start→golden hop
  distance (score floor); **`G`, `leftArm`, `numFill`, `numDead`,
  `numBranch`, `numLoop`, `numRouter` are all `rng.int` over
  per-DIFFICULTY ranges** (`DIFF_CFG`, see Difficulty below).
  **Subnets vary in size (N3) and span the whole IPv4 range (N4).**
  LANs are created address-less; after the WHOLE topology is built,
  `assignAddresses()` (own RNG stream, see Determinism) gives each LAN
  a prefix drawn from the per-difficulty `cfg.prefixes` pool, **clamped
  larger if the LAN has more NICs than the subnet holds** (a /30 can
  never overflow a busy LAN), then a non-overlapping prefix-aligned
  base drawn from the **entire routable IPv4 space** (first octet
  1..223), excluding reserved/special ranges via `RESV` (0/8, 127/8
  loopback, 169.254/16 link-local, 224/4 multicast, 240/4 reserved
  incl. 255.255.255.255). RFC1918 still occurs naturally; the lo NIC
  keeps 127.0.0.1/8. Every NIC IP + `lan.cidr` is set in this pass;
  nothing reads an IP before it. Reachability keys off each NIC's own
  prefix (`ipReachesFrom`, `lanFromArg` match by containment) - never
  assume /24. **IPv6 (N1):** when `fam==='ipv6'` (HB2- code)
  `assignAddresses` takes a separate branch - every LAN a unique ULA
  `fd00::/8` **/64** (N3's variable prefixes are v4-only), host IID a
  small int (`::a3`); lo is `::1/128`. Address math is a parallel
  BigInt path (`v6ToBig`/`bigToV6`/`v6Net`); `sameLan` short-circuits
  on `family` so v4 and v6 never compare. `cidrStr`, the hint clue
  (group vs octet), `ip a` (`inet6`), nmap ordering (`addrCmp`),
  `validTarget`, ping `::1` are all family-aware. **N5 (dual-stack)
  SHIPPED - 3 address MODES by code prefix: `HB1-` pure IPv4,
  `HB2-` pure IPv6, `HB3-` dual-stack.** `assignAddresses`:
  `fam==='ipv6'` (HB2) → the byte-unchanged pure-v6 early-return
  branch; otherwise the v4/dual branch picks each LAN's family on the
  address RNG stream - `v6pct = (fam==='dual') ? cfg.v6pct : 0`, so
  HB1 stays all-v4 and only HB3 mixes in v6 (START LAN forced to v4;
  `cfg.v6pct` per difficulty Casual 0, Normal 0.4, Hard 0.5, Brutal
  0.55, Extreme 0.6) - AND the first chain LAN past the start on the
  start→golden path (`chain[leftArm+1]`) is FORCED v6 so winning
  always crosses a v4↔v6 boundary one hop in (sparse-v6 worlds were
  reaching golden without ever touching v6 - the mode looked
  invisible; the chain bridge there is the dual-stack crossing host,
  solvable since the graph is family-agnostic). Then runs the v4
  (pool+non-overlap) or v6 (unique ULA /64) assignment per LAN with
  independent bookkeeping. `W.hasV6` = any v6 LAN. The 5
  ex-whole-world spots are now per-object:
  `addrCmp`/`validTarget`/ping-loopback infer family from the
  literal (`:`→v6), `nmapHelp`'s example from the host's primary
  NIC, the `ssh <hostname>` gate uses `W.hasV6`. `sameLan` blocking
  v4↔v6 IS the teaching: to cross families you `ssh` a dual-stack
  bridge (auto-formed where a bridge's two LANs differ) and continue
  via its other-family NIC; solvable because every consecutive chain
  LAN already shares that bridge and `dist(start,golden)` is
  family-agnostic. The top-right **`#fambadge`** (not a menu item)
  cycles IPv4→IPv6→Dual-stack (`gFamily` ipv4/ipv6/dual,
  `codePrefix` HB1/HB2/HB3, `familyOfCode` parses all three,
  CODE_RE `/^HB[123]-/i`).
  **TRUE dual-stack LANs (HB3, `dualPct` per level):** some LANs
  are ONE segment carrying BOTH networks - `lan.cidr` (v4) +
  `lan.cidr6` (v6), and every host NIC there gets `nic.ip` (v4) +
  `nic.ip6` (v6) (assignV4Lan then assignV6Lan(li,`overlay`=true)).
  `ipOwner` maps BOTH addresses; `ipReachesFrom`/the ping
  same-LAN guard test every `[ip,ip6]`; `lanFromArg` matches a LAN
  by either cidr; `ip a` prints an `inet` AND an `inet6` line (each
  with its `net`); `nmap` reports the scanned family's network +
  that family's host addresses; the square label shows BOTH CIDR
  lines. Single-v6 LAN labels show "· IPv6", dual show
  "· dual-stack". On-map host addresses are compacted to the v6
  host suffix `::iid` (`shortIp` - the full /64 is on the square
  label; fixes long-v6 packed-cell overlap). A dual-stack-LAN host
  is still single-NIC (one nic/one lan) so the embedder is
  unaffected.
  Plus: filler/decoy hosts, private leaf LANs, single-NIC dead-end
  hosts, **dead-end network branches** (trees of shared LANs off an
  interior chain LAN - no shortcut), and **loop networks** (a ring of
  ≥3 shared LANs that leaves one client and returns to that SAME
  client - no shortcut; ring length = a rectangle perimeter so the
  embedder can close it). **3+-NIC routers (G1):** a final ROUTER
  pass (after loops, before addressing; own draws on the main
  topology RNG, deterministic per code+level) picks `numRouter`
  (per-difficulty range, see Difficulty) *true* bridges - hosts
  with **≥2 shared LANs** AND a private leaf (chain/branch bridges;
  NOT 1-shared fillers, NOT the leaf-less loop `oc`) - and adds ONE
  single-NIC decoy to that bridge's private leaf, upgrading it to a
  SHARED dead-end stub so the bridge renders as a genuine
  3-connected router. The stub is a tree leaf reachable only via
  that one bridge → no cycle, no new path. Branches/loops/routers
  add NO shortcut: `dist(start, golden)` stays `G` and every
  hop-hint stays exact (branches/loops verified 800 seeds; the
  router pass re-verified 1500 = 300×5). A purely random share-a-LAN graph collapses to ~diameter 2
  and must NOT be used.
- **Difficulty.** 5 levels `Casual / Normal / Hard / Brutal / Extreme`
  (`DIFFS`, `DIFF_CFG`). A clickable `#diffbadge` lives in the
  top-right **`#topctl`** fixed flex cluster (order: diffbadge ·
  `#fambadge` IP toggle · `#menubtn`; the flex row replaced the old
  per-element `right:` offsets so they always sit flush;
  colour per level via `.d-<Level>`);
  clicking `cycleDiff()` advances level → `startGame(W.code)` regen at
  the new level. `gDiff` (default Normal, in-memory) is mixed into the
  seed and selects the rng ranges. Spec (per-level `[lo,hi]`):
  Casual `G2`, nothing else (leftArm 0, 0 branches, 0 loops - boring,
  2 hops); Normal `G2-4`, a few branches, 0 loops; Hard `G4-6`,
  several branches, 0-1 loop; Brutal `G6-8`, many branches, 1-2 loops;
  Extreme `G10-13`, many branches, 3-4 loops (huge map, ~120 hosts).
  **`numRouter`** (G1, 3+-NIC routers, see Generator) per level:
  Casual `[1,1]`, Normal `[1,2]`, Hard `[2,3]`, Brutal `[3,5]`,
  Extreme `[5,8]` - clamped to the available true-bridge count (so
  even tiny Casual shows one router; stub is a dead-end so Casual
  stays topologically boring). **`v6pct`** (N5, HB3 only; per-LAN
  P(single-family IPv6)): Casual `0`, Normal `0.4`, Hard `0.5`,
  Brutal `0.55`, Extreme `0.6`. **`dualPct`** (HB3 only; per-LAN
  P(TRUE dual-stack LAN - one segment with BOTH a v4 and a v6
  network, every host given `ip`+`ip6`)): Casual `0`, Normal
  `0.25`, Hard `0.3`, Brutal `0.35`, Extreme `0.4`. Per LAN (HB3,
  not START/forced): roll dual first, else v6, else v4 - two arng
  draws/LAN always. START forced v4, `chain[leftArm+1]` forced
  single-v6 (guaranteed v4↔v6 bridge-cross on the win path);
  HB1/HB2 ignore both pcts. `numBranch` is clamped to available
  chain LANs (`cand.length`); loops need `C≥3`. Verified: 5×300
  seeds, all invariants per level (incl. the G1 router pass:
  `dist==G`, ≥3 shared NICs per router).
  Each level also carries a **`prefixes`** pool (subnet-size weights):
  Casual all `/24` (boring on purpose); harder levels add tight
  (`/30`/`/29`/`/28`) and a big (`/22`/`/20`/`/16`) net so students
  must read masks. Pool pick is clamped larger to fit the LAN's NIC
  count - see Generator/N3.
- **Map style: EVER-GROWING fixed map (ducky travels).** Reference is
  the ENE editor at `w:\ene` (Packet-Tracer-like; **READ ONLY - never
  write there**). NOTE: the `placeLan`/`S.lanCell` and the later
  left→right-spine descriptions in this bullet are **HISTORICAL** -
  superseded by the **deterministic LATTICE embedder** (see "Agreed
  map model" + Status below). Keep the durable invariants (squares
  never overlap, cells never move, ducky travels, fog-of-war, no empty
  squares, no ellipsis); ignore the obsolete placement mechanics.
  Earlier FINAL MODEL text (kept for context): the map
  **grows outward and NEVER reshuffles** - each LAN gets a PERMANENT grid
  cell the first time it's discovered; the ducky/current host moves
  through the fixed map. HARD RULES: (a) **LAN squares never overlap**
  (touch via a small `GAP`); (b) **no empty/orphan squares** - only LANs
  with ≥1 discovered host drawn; (c) **a cell, once assigned, never
  moves**; (d) a LAN square **starts small and grows to fit** its
  discovered single-NIC hosts - when it grows, its whole column/row
  grows so the map expands but the arrangement is unchanged; (e) the
  **ducky marks `S.curId`** and visibly travels; the topology stays put.
  Implementation (`draw`): `placeLan(li)` assigns a permanent `S.lanCell`
  (`{c,r}`) - first LAN at `(0,0)`; thereafter placed to complete a ≤2×2
  box around the cells of the host that connects it (prefer current
  host), else a free neighbour, else spiral. `S.lanCell`/`S.occ` persist
  for the run (reset in `startGame`). Per-LAN desired size from its
  single-NIC count; `colW[c]`/`rowH[r]` = max over that column/row;
  `region[li]` = that cell's rect (squares fill the cell so a host's
  clustered cells share full edges → its centroid lands on the shared
  edge/corner and it straddles/corners all its LANs). Squares **touch**
  (`SPREAD=0` - user: do NOT separate the LAN squares); `GAP` is just a
  2px in-cell stroke inset. Readability comes from BIG squares + spread
  icons instead: per-host cell ≈168w×128h, `MINW/MINH` 210/158
  (HISTORICAL - `sizeShared` was enlarged to
  `w=max(320,cols*208+36)`, `h=max(196,84+rows*140+20)` so a bridge's
  wide stacked v4+v6 label on a shared edge no longer covers a
  neighbour square's packed host; squares still TOUCH, cells never
  move, no reshuffle - bigger is the sanctioned lever, not SPREAD).
  A square
  GROWS to ENCOMPASS ALL its
  single-NIC hosts (no "+N more" - packing uses the same `cols=⌈√n⌉` the
  cell was sized for). The host you're ON is **pinned**: each frame the
  focused host's world delta is cancelled via pan (`S.anchor`) so it
  never jumps on nmap/redraw/grow; only ssh/exit (curId change) lets the
  ducky travel to the new client - client icons themselves never move.
  Single-NIC host packed inside its cell; multi-homed host sits on the
  **shared grid BOUNDARY** of its cluster cells (col/row boundary where
  its squares touch) - NOT the centroid of cell centres (that drifts into
  whichever LAN grew; the start-client-nmap bug). So it stays at the
  4-way corner even as a LAN expands. Ducky on `S.curId`. NIC ports (e0/e1…)
  straddle the icon edge (`sz*0.46`); link lines only to switches.
  Switch (only once ≥2 of a LAN's hosts known) fully inside its square,
  label centred on it. LAN label (`lanLabel()`/`LB[li]`) auto-sizes each
  line to span the FULL square width (10px-measured, capped 26px); its
  height is a dynamic band - hosts/switch sit below it so they never
  overlap the label, and the square grows to fit both. `S.fit` centres the
  view on the start host on the FIRST frame only; afterwards fixed -
  pan/zoom roams it. Do NOT reintroduce ego-recentering / bbox / force
  / partition / hull models - all rejected. (A *deterministic lattice
  walk* IS now the agreed model - see "Agreed map model" below; that
  is NOT the rejected "global-BFS fixed grid" which reshuffled.) nmap
  must NOT pull in a remote host's other LANs (only the scanned LAN).
  LANs are **numbered LAN1, LAN2… in discovery order**
  (`S.lanNum`/`S.lanSeq` in `revealLan`); label is "LAN n" + the
  **bare `<cidr>`** (U3.4 dropped the "Network #" prefix) + "Local
  Network" when ssh-reachable. Colours = ENE's fixed 8-entry pastel palette (`LAN_PALETTE`,
  fill+stroke) indexed by the LAN's discovery number - NOT random, and do
  NOT recolour squares gold for reachability (rejected); show a small
  "ssh reachable" label. Host/switch/LAN labels use a white halo. Cached
  by discovered-host-set + canvas size. The user iterated HARD on this
  (circles → boxes → ego → LAN grid → ENE bbox); the bbox/hull model is
  the current agreed target - keep it; keep "where am I" unmistakable.
- **LAN label placement (U3.3 - DYNAMIC, supersedes the old fixed
  TOP-far-X-corner):** `lanLabel()` sizes the block to its NATURAL
  width (no longer stretched to span the full square; capped at `15*F`
  = 1.25× the hostname font per the earlier tweak) and also returns
  its `W`. After `HP`/`SZ`/`swPos` are known, `LBpos[li]` picks the
  best of **8 edge slots** - `TC TL TR LM RM BC BL BR` (4 corners + 4
  edge-mids) - as the one whose rect has the least overlap with the
  square's object boxes (host icons + a ~30px under-label band each, +
  the switch). Preference order is top-first (`TC` then corners/mids,
  bottom last) because the floating terminal docks low and the packer
  still reserves the top band (so `TC` is collision-free in the common
  sparse case → behaviour ≈ unchanged there). The block is drawn LAST
  (top-most, haloed) in its own pass, NOT inside the square loop. This
  fixes the old "bridge on a square's TOP edge sits on the title band"
  minor (the label now slides to a clear slot). **Do NOT** re-stretch
  the label to full width or move it back into pass 1. Client icons are sized
  55/46/×1.2 (current/multi/single - "20% bigger"); NIC port dots
  **straddle the icon edge** at `sz*0.46` (ENE-style, half-on/half-off).
  **SUPERSEDED (N5 follow-up): the U2 per-bubble `ethN: ip` text is
  GONE.** The scattered at-the-bubble tags split a single address's
  fragments across positions with 3+-NIC routers / dual-stack v4+v6
  (user: "impossible to read" / "part of a v4 address on one line
  and part on another"). NIC bubbles now draw only the port DOT +
  link line - NO text. A multi-homed host's addresses are a clean
  **stacked list under the icon**: hostname, then **ONE line PER
  NIC** = `ethN  <full v4>  <full v6>` (the entirety of that NIC's
  address(es) on a single line, never split, NOT compacted) - lines
  stack so they can't overlap each other. **Single-NIC** packed
  in-square hosts keep the **compact `::iid`** v6 (full v6 there is
  the original side-by-side LAN-28 overlap; the /64 is on the square
  label). `shortIp` is now used ONLY for that single-NIC packed
  case. The `hasDown`/`tagBot`/`ly0`/`gap`/`ipY` bubble-aware push
  logic was removed (label block = `nameY = p.y + sz*0.44+2`, then
  stacked address lines). The historical text below
  (U3.2/U2 "label the bubble", `hasDown` name-push) is kept for
  context but no longer applies. - (orig U3.2:) the bare single-NIC
  `eth*` port tag was REMOVED; the under-icon block is
  hostname-only;
  single-rendered-NIC hosts now show **no port tag at all** (U3.2) -
  just the under-icon hostname + IP. The port DOT is still drawn for
  every connected NIC; only its text label is gated on `multi`. The
  `hasDown` name-push (below) is therefore also gated on `multi`
  (single-NIC labels just hug the icon now). Only connected interfaces ever get
  a port/IP (U1: their LAN has ≥2 hosts; private single-host leaf LANs
  have no `region` so were never drawn anyway). Host name+IP label
  placement (single-NIC case; user-tuned, don't regress):
  • name→IP `gap = Math.max(sz*0.34, 13*F*lf) + 3*F` - scales with the
    ICON so the block stays proportional as icons grow/shrink with
    discovery (the old fixed `14*F*lf+7*F` looked "much farther apart"
    once the map filled and icons shrank). The `13*F*lf` term is a
    font-height FLOOR so the IP never overlaps the name - do NOT drop
    that floor (the old overlap bug was a flat gap with no lf term).
  • NO downward NIC bubble (`!hasDown`, common up-to-switch case):
    block tight to the icon - `nameY = p.y + sz*0.44+2`, `ipY = nameY
    + gap`.
  • A NIC bubble points DOWN (its `eth*` tag lands under the icon, e.g.
    a bridge on a square's top edge): IP at `p.y + (sz*0.5+6) + gap`
    (not pushed down); only the NAME moves below the tag (`tagBot`),
    clamped so it never drops past the IP.
- **Terminal is a floating window** (ENE `.floating-console` pattern):
  `#termwrap` is `position:fixed`, drag by its `#termbar`, resizable.
  **Minimize** (`#term-min`) pulls it fully off-screen (`.min`
  display:none) and shows a small `#termrestore` tab to bring it back -
  not a thin collapsed bar. Map canvas uses the full work area behind it.
- **Canvas pan/zoom:** hold the RIGHT mouse button on `#map` and drag to
  scroll (`S.panX/panY`, applied via `ctx2d.translate`; `#mapwrap.panning`
  grab cursor; contextmenu suppressed). Mouse **wheel zooms**
  (`S.zoom`, `ctx2d.scale`), keeping the point under the cursor fixed,
  clamped 0.3-3.5. Both reset on new game.

## Verification

**Headless visual harness (preferred for canvas/UI work):**
`w:\hbdev\shot.py` (Playwright Chromium, installed for `/c/Python314/python`;
kept OUTSIDE the served `w:\tools\hostbound`). Run:
`cd /w/tools/hbdev && /c/Python314/python shot.py HB1-ABC123 "ip a" "nmap"`
- loads the app at that network code, types the given commands into
`#cmdline`, writes `w:\tools\hbdev\hbshot.png`, and prints console/page
errors. Then `Read` the PNG to actually see the rendered map and self-check
layout instead of relying on user screenshots. Use a fixed code for
repeatable diffs.

**Generator logic (no JS runtime needed):** Python 3.14 at
`/c/Python314/python`. The pattern that works:

1. Port `genWorld`'s call order to a scratch `_gen_check.py` (kept in
   `w:\hbdev`, OUTSIDE the docroot) and assert **per difficulty**
   (~300 seeds × 5 levels): determinism (per code+level), single
   connected component, `dist(start,golden)==G` in that level's range
   (branches & loops add **no shortcut**), `numBranch`/`numLoop` match
   the level spec, Casual is boring (leftArm 0, no branch/loop), left
   arm is a true dead-end direction (cut start's chain LAN ⇒ chain[0]
   severed from golden), every branch/ring LAN renders (≥2 hosts), each
   loop is a real ≥3 cycle reachable only via its client, golden never
   in a ring, hints truthful, **greedy descent solvable**.
   (Structural invariants hold for any RNG of the same call structure,
   so the check may use a stand-in deterministic RNG.)
2. String/regex/comment-aware brace scan of the `<script>` to confirm
   balanced `{}` and well-formed HTML (single script, ends `</html>`, no
   external http resources).
3. **Delete every scratch file** (public docroot).
4. State clearly that browser rendering/UI is written but not runtime-tested
   here; ask the user to load it and eyeball the map/menu.

## Agreed map model - DETERMINISTIC LATTICE (authoritative, supersedes prior)

User direction (latest): the spine must **meander** (each step may turn,
any of 8 directions), grow **both ways from start**, and the topology
includes dead-end **branches** and **loops**. The embedder is a
**deterministic lattice walk** computed once per world (cached on
`W._emb = {lr, hp}`), seeded from the network code via a *separate* RNG
stream (`makeRng(seedFromCode(W.code) ^ 0x9E3779B9)`):

- Each shared LAN (≥2 hosts) gets one integer **cell** `(c,r)`. A
  seeded walk from the START LAN over the shared-LAN graph places each
  LAN in a free cell adjacent to its parent; a `FAN` offset list makes
  child 0 continue the heading and child 1 go **opposite** (spine grows
  both ways), then perpendiculars; a small seeded wander turns the
  heading so the chain **snakes**. Spiral-search fallback if boxed in.
- **Loops** are NOT walked: their ring LANs (tagged in the generator
  with a perimeter `idx` + rectangle `ra×rb`) are laid on a **rectangle
  perimeter** of cells, so consecutive ring LANs AND the wrap
  ring[last]→ring[0] are in adjacent cells - the loop client straddles
  that closing edge. Anchored at the nearest free `ra×rb` block to its
  chain LAN's cell.
- Cells → rects: a square **fills its whole cell**; `colW[c]`/`rowH[r]`
  = max square needed in that column/row, so orthogonally adjacent
  cells share a **full edge** exactly. A multi-homed (bridge) client is
  placed by `touch(li1,li2)` at the **mid of the shared edge**
  (orthogonal cells) or the **shared corner** (diagonal cells), picking
  the adjacent-cell pair of its shared LANs. The walk **prefers
  orthogonal** steps (clean shared EDGE); diagonals (corner touch) are
  a last resort only. NEVER place a host on a square's outer corner
  (on the tight lattice that point is SHARED with the touching
  neighbour → stacking). **Every NON-bridge host renders INSIDE one
  square via the SINGLE authority - `draw()`'s `single`/`region`
  packer.** Grouping is by `insideHome(ho)`: a host's lone **shared**
  LAN (single-homed filler, incl. the host you're ON) or its only LAN
  (single-NIC dead-end) - NOT by seen-LAN count, so the start host
  packs in the SAME grid as the rest (no rival 2nd layout = the
  HB1-PN9QE4 Normal overlap). The square is sized by `insideOf(li)` =
  ALL non-bridge hosts (dead-ends + single-homed fillers) so it grows
  to fit them. (`inEmb`/`inOf` grid in the embedder is now overwritten
  by the packer for these hosts - keep the packer as the authority.)

Invariants kept: squares never overlap (distinct cells), a cell once
assigned never moves, the map only grows (column/row widen), fog-of-war
draws only the discovered subset at these fixed points, ducky travels,
no ellipsis in UI labels. Branches/loops add **no shortcut**
(`dist(start,golden)` stays `G`, hints exact). REJECTED (do not
reintroduce): ego-recentering, bbox/hull, force-directed, partition,
or any *reshuffling* grid.

## Status / open items

- **UI top-bar / footer relayout (user request, latest) SHIPPED.**
  A chrome-placement pass that SUPERSEDES the scattered older
  placement notes throughout this file (any footer-badge,
  `#mapyou` caption-bar, or lower-left-terminal description below
  is historical): (1) the mode badges **`#csbadge` / `#hhmbadge` /
  `#fwbadge` now live in `#topctl`**, to the LEFT of `#diffbadge`
  (order: CS · HHM · FW · diff · `#fambadge` · `#menubtn`), each
  still hidden when its mode is off; they were moved out of the
  `#about` footer. (2) **All `#topctl` badges AND `#notepad-btn`
  share the difficulty badge's size** (`font:inherit`,
  `padding:.3rem .6rem`, `line-height:1`, `letter-spacing:.02em`,
  the diff/fam `box-shadow`; the old `font-size:.78rem` /
  `.15rem .5rem` padding were dropped). (3) the **Notepad button
  moved to the FAR LEFT of `#scorebar`**, before "Host:".
  (4) the **Find-host box (`#find-box` + `#find-list`) moved into
  the `#about` footer, LEFT of `#netbtn`** (Network Options); its
  `margin-left:auto` was dropped. (5) the **map legend bar
  (`#maplegend`) was REMOVED** (HTML + CSS); square/switch/bridge
  meanings now live only in the guides. (6) the **"YOU ARE HERE"
  caption bar (`#mapyou` / `#mapyou-txt`) was REMOVED**;
  `setCaption()` now only updates `#termbar-host` (host/IP live in
  the scorebar; the on-map marker is the `(YOU)` label on the host
  icon). (7) the **terminal window `#termwrap` + its `#termrestore`
  tab now default to the UPPER-RIGHT** (`right:1rem; top:3.2rem`,
  below the scorebar/menu), not the old lower-left; the drag
  handler rebases to `left/top` on first drag so dragging is
  unaffected. (8) the footer attribution
  "· Ohio University · ECT Dept" was REMOVED (full attribution
  stays in the About modal); `#about` is
  `justify-content:space-between` so the controls slid flush-right.
  Pure layout/CSS + DOM relocation: no engine/generation/
  solvability touch, and every element kept its ID so all
  `getElementById` handlers are unaffected. Verified headless
  (1280x800, CS+FW+HHM forced on): badge DOM placement + order,
  all six top-bar items + Notepad at an identical 18px height,
  find-box left of Network Options, notepad left of Host,
  `#maplegend`/`#mapyou` gone, terminal flush upper-right, no
  JS/console errors. `docs/` + the in-app `#docsbox` were updated
  to match.
- **CS2 (Darkweb redesign - explicit entry + full-screen UI)
  SHIPPED.** Two changes on top of CS1's Darkweb:
  (1) **Broker auto-unlock removed.** Arriving at the broker host
  now prints a hint pointing at the new `dw enter` command instead
  of flipping `S.dwUnlocked` automatically. The player must
  explicitly opt in by running `dw enter` while standing on the
  broker - the only `dw` subcommand that BYPASSES `dwGate()` (it
  IS the gate); refuses off-broker, refuses when CS is off,
  no-ops if already inside. (2) **Full-screen `#dwfull` panel.**
  A new `position:fixed; inset:0; z-index:60` overlay replaces
  the old terminal-printed shop + footer popup. Styled after
  `w:\tools\ect-darkweb-market` (black bg, neon-green section
  headers with glow, yellow CREDITS chip, monospace, card
  layout) - all CSS embedded so the single-file / `file://`
  constraint is preserved; no external assets. Header has the
  ECT DARKWEB brand + a live `CREDITS: $N` chip + a CLOSE
  button. Body is three sections re-rendered every open AND
  after every buy/sell so totals stay live: **SHOP** (all
  `CS_PAIRS` as cards with name / desc / vuln / price /
  BUY-or-OWNED button - BUY disabled if owned or you can't
  afford), **INVENTORY** (every tool in `S.inv` as an owned
  card), **SELL INTEL** (current host's unsold intel as a card
  with SELL button, or an empty-state hint). Entry points: the
  footer `#dwbtn` (one-click open of the panel) and a NEW main-
  menu DIRECT item `#dwmenu-item` (no popout submenu). Both
  hidden until `S.dwUnlocked`. The old `#dwpop` / `#dwsub` /
  `#dwmenu-cell` plumbing is REMOVED. Close paths: × button,
  Esc, or `applyDwUI()` (force-closes when CS is toggled off).
  Click delegation inside `#dwfull-body` dispatches
  `data-dw="buy"` (reads `data-tool`) -> `dwBuy` and
  `data-dw="sell"` -> `dwSell(intelFile())`; both call the
  existing primitives so terminal users see the same prints,
  then `renderDwFull()` refreshes the body and the header
  credits chip. The terminal `dw list` / `dw buy` / `dw sell` /
  `sell` commands stay as a keyboard-driven fallback.
  `#dwfull` added to the focus-steal exclusion + Esc-close
  chain; `startGame` resets `S.dwUnlocked` + force-closes the
  panel. Verified headless across 14 checks: DOM layout (old
  popup removed, new panel + item added); ssh to broker without
  `dw enter` does NOT unlock; `dwFullOpen` no-ops before unlock;
  `dw enter` on broker unlocks + reveals UI; footer btn opens
  panel with all three sections rendered; Esc + × close it;
  main-menu item opens; BUY decrements credits + adds to inv;
  header credits chip updates after buy; SELL increments
  credits + clears the row; toggling CS off retracts UI +
  force-closes; `startGame` resets + closes; HB4 sandbox
  refuses `dw enter` ("no broker contact in this world"). No JS
  errors. Visual: panel shows `::: ECT DARKWEB :::` brand,
  neon-green `// SHOP //` / `// INVENTORY //` / `// SELL INTEL
  //` headers, yellow `CREDITS: $320,000` chip, monospace card
  rows with BUY / OWNED / SELL buttons.
  **Follow-up: economy bumped + terminal buy/sell removed.**
  Tool prices and intel sell values were multiplied by 1000 so the
  Darkweb feels like a real expense: priv-esc-kit $140,000,
  default-creds $80,000, hash-cracker $110,000, bof-exploit
  $170,000; intel values seeded in `[10000, 200000]`. A new
  `fmt$(n)` helper (uses `toLocaleString("en-US")`) formats every
  dollar surface with thousands separators - scorebar Credits
  chip, panel header chip, panel card prices, `cat <file>.intel`
  body, all terminal `dw`-family prints. Panel `.dw-card .price`
  column widened from 5rem to 7.5rem to fit `$200,000`. **Then
  the terminal `dw buy` / `dw sell` subcommands were removed** -
  the full-screen panel is the canonical buy/sell interface.
  `cmd_dw` now: `dw enter` (bypasses dwGate, unlocks), `dw` /
  `dw list` / `dw shop` (open the full-screen panel), anything
  else errors with "unknown subcommand". `dwBuy()` / `dwSell()`
  primitives KEPT - the panel's BUY / SELL buttons call them via
  the click delegation. `dwList()` (old terminal listing) deleted
  as dead code. HELP line shortened to `dw [enter|list]`; the
  `dw enter` welcome message updated to point at `dw list` /
  footer button / main-menu item (no longer mentions the removed
  subcommands). Standalone `sell <file>.intel` shortcut at the
  top level was left in place - user asked specifically to remove
  the `dw buy` / `dw sell` subforms, not the top-level alias.
- **NM2 (`nmap` port scan + services) SHIPPED.** Beyond NM1's
  ping-sweep: per-host `services[]` seeded at world-gen on its
  OWN RNG stream (`^0xB1A57001`, distinct constant so
  determinism + every earlier topology / address / name / hint
  / CS pass stays byte-stable). Every host always runs
  `22/tcp ssh`; 0-3 additional ports drawn from `SVC_CATALOG`
  (13 entries: ftp / smtp / domain / http / pop3 / imap /
  https / mysql / postgres / redis / http-alt / https-alt /
  git), each with a seeded version banner. The golden host
  gets an EXTRA signature port `1337/tcp ducky-vault
  GoldenVault 1.0` (kept distinct from the pool so no other
  host can roll it). A careful `nmap -sV` on golden confirms
  gold without a blind `claim`. `cmd_nmap` extended to branch
  on host vs subnet: a bare IP, `/32` (v4), `/128` (v6), or a
  hostname (in v6 worlds, parity with N6 ssh) all route to a
  new `nmapHost(ip)` that prints a real-nmap-ish table
  (`PORT STATE SERVICE VERSION`) and reveals the scanned host
  + its LAN. `-sV` accepted (and ignored, same way as `-sn`);
  CIDR scans continue to use the old subnet path. CS1
  vuln-class hint is preserved on the `22/ssh` line of host
  scans. HELP + `nmapHelp()` updated with the new form + a
  host-target example. **Greedy-descent solvability proof
  unchanged** - services are corroborating only; H3 hop/octet
  clue remains authoritative. Verified headless across 12
  checks (HB1 Normal+CS+FW and HB2 pure-v6): every host has
  `services` with 22/ssh; golden uniquely has port 1337;
  determinism per code; `nmap -sV <ip>` / `nmap <ip>` /
  `nmap <ip>/32` all render the table; far hosts rejected;
  subnet scan unchanged; golden scan shows GoldenVault; CS
  hint on vulnerable host; HELP mentions `-sV`; v6 hostname
  resolves. No JS errors.
- **Admin "Jump to host" SHIPPED.** New Admin ▸ menu item
  (`data-act="admin-jump"`) gated by the same passphrase modal
  as the other admin tools. Opens a new `#adminjump` modal
  (text input + datalist of every host in the world sorted by
  name + Jump / Cancel buttons). `adminResolveHost(tok)`
  accepts a hostname (exact case-insensitive match, then
  unique-prefix), or an IP literal via `W.ipOwner`. On
  successful resolve: `S.curId` jumps directly; host is
  revealed; terminal prints
  `*** ADMIN - jumped to NAME (IP) ***`. **Does NOT count as
  an ssh hop** (`S.hops` unchanged), **does NOT push to
  `S.trail`**, and **does NOT trigger player-side gameplay
  events** (loot pickup, broker discovery) - admin teleports
  shouldn't pollute the recorded path or fire mechanics
  reserved for actual ssh moves. Unknown name -> inline error
  on the modal, modal stays open; Enter submits; Esc / Cancel
  / backdrop close. Modal CSS shares the `#adminpw` selectors
  (same card styling for both). Added to the focus-steal
  exclusion + Esc-close chain + `startGame` reset. Verified
  headless across 8 checks: admin item present, modal opens
  with full host list, jump-by-name moves `S.curId` without
  changing hops/trail + reveals host, jump-by-IP works,
  unknown-name path keeps modal open with error, Esc closes,
  "already on" path, unique-prefix match works.
- **Minimap admin overlays SHIPPED.** Inside pass 7 of `draw()`
  (between the gold-machine dot and the YOU dot), two new
  overlays mirror the main-map flame / `$` badge:
  **orange dots** (`#ea580c`, matches FW badge) on every
  discovered host that is host-firewalled or a LAN-ACL victim
  (gated on `S.admin && S.adminShowFw`); **purple dot**
  (`#7c3aed`, matches CS badge) on the CS broker host (gated
  on `S.admin && S.adminShowDw && gCS && W.brokerId >= 0`).
  Both respect fog-of-war + HHM Hard. Drawn before the YOU
  dot so the player's own marker stays on top if a dot
  collides.
- **Admin "Show Darkweb" overlay SHIPPED.** Third Admin ▸ item
  (`data-act="admin-dw"` -> `adminUnlock(toggleAdminDw)`;
  right-side green check `.on` like Show Firewalls). When ON,
  `draw()` pass 6b-2 (right after the firewall flame pass)
  overlays a purple `$` badge (new `drawBrokerIcon`) at the
  TOP-LEFT of the CS broker host's icon so it can coexist with
  the firewall flame at the top-right. Also AUTO-REVEALS the
  broker host + its LAN on enable so the admin gets one-click
  discovery without needing "Reveal Network" first; prints the
  broker's name + primary IP in the terminal. No-op when CS is
  off or `W.brokerId < 0` (HB4 sandbox).
- **CS1 (Cybersecurity Mode) SHIPPED - Tier L, four-phase rollout
  in one pass.** Generator-gated advanced mode: traversal via
  `ssh` requires that the target host carry one of a small set of
  VULNERABILITIES AND that the player's inventory carry the
  matching TOOL. Players gather tools from host LOOT, sell INTEL
  files at THE DARKWEB for credits, and buy more tools. The mode
  is captured in the network code; toggling it regenerates the
  world (generator-gate, not runtime-gate, same reason as F1).
  HB4 sandbox ignores `gCS` exactly like `gFW`.
  **CS1a (code-encoding + toggle + badge).** HB1/HB2/HB3 mode
  widened from 1 to 2 base36 chars. M1 stays
  `diffIdx*6 + hhmIdx*2 + fwBit` (0..29). M2 is the CS bit (0..1,
  35 reserved). The body is now `<M1><M2><SEED>` = 9 chars; legacy
  8-char bodies parse with CS=off for backwards compat. HB4 keeps
  1 mode char, extended to `csBit*6 + hhmIdx*2 + fwBit` (0..11).
  `seedFromCode` strips the TRAILING 7 chars regardless of body
  length so the topology seed is byte-stable, and `parseModeChar2`
  reads the optional 2nd char. A new `gCS` global, a Cybersecurity
  item in Network Options submenu + footer popup, and a footer
  `#csbadge` (purple, hidden when off; mirrors `#fwbadge`) round
  out the toggle plumbing. `applyModeFromCode` / `applyCSBtn` /
  `toggleCS` / `rebuildCodeWithMode` mirror the F1 pattern.
  **CS1b (tools, vulns, ssh-gate, nmap hints + solvability
  proof).** A new `CS_PAIRS` catalog with 4 tool/vuln pairs:
  priv-esc-kit / sudo-misconfig, default-creds / default-password,
  hash-cracker / weak-password, bof-exploit / unpatched-service.
  Each pair has a `klass` short name for nmap annotation. A new
  CS pass in `genWorld` runs AFTER the F1 pass, on its own RNG
  stream (XOR constant `0xDEAD7001`, distinct from F1's
  `0xC2B2AE35` / addressing's `0x5BD1E995` / names' `0x27D4EB2F` /
  hints' `0x85EBCA6B`) so determinism + the topology draw order
  are byte-stable. Per-difficulty caps live in `DIFF_CFG` as
  numVuln/numLoot/startInv: Casual 0/0/0 (no-op like FW), Normal
  2-4 / 1-2 / 1, Hard 4-7 / 2-4 / 1, Brutal 7-11 / 3-6 / 0,
  Extreme 10-15 / 5-8 / 0. **Constructive validate-or-revert
  solvability proof**: loot is placed freely (only adds keys);
  vulns are placed one-by-one and KEPT only if a key-door BFS
  from start still has `dist(start->golden) == G` AND every host
  that was baseline-reachable in the F1 forward graph stays
  reachable with achievable inventory. The cumulative key-door
  BFS iterates to fixed point (picking up a key may unlock
  previously-blocked frontiers). The reachability check uses the
  F1-baseline-reach set, not all hosts, because F1 may
  legitimately leave some hosts forward-unreachable from start;
  CS must not require those to become reachable. `cmd_ssh`
  refuses entry: if `gCS && targetHost.vuln && !S.inv.includes(
  matching tool)` it prints "Permission denied (no usable exploit
  for VULN - try a TOOL)" and does NOT hop. `cmd_nmap` annotates
  each up host with its vulnerability class as a tag like
  "22/ssh (?priv-esc)" so the player can plan. New `cmd_inv`
  ("inv" / "tools" alias) lists the player's persistent tools
  with their descriptive names. `auto-pickup` is enabled: on
  ssh-arrival the host's loot tool drops into `S.inv` and the
  `h.loot` field is deleted (one pickup per host). HB4 sandbox
  skips the entire CS pass. Verified headless across 50 worlds
  (10 per difficulty) - 100% solvable, dist preserved,
  deterministic per code+level.
  **CS1c (loot, intel files, credits, broker discovery).**
  Per-host `loot` field is one of the catalog tools, picked up
  automatically on ssh-arrival. Per-host `.intel` file with a
  seeded credit value $10-200; visible in `ls` alongside the
  recon-clue `.txt`, readable via `cat`/`nano`. New `S.credits`
  / `S.soldIntel` / `S.dwUnlocked` per-run state (NOT persisted,
  NOT in the code - rediscovery each run is part of the puzzle).
  A new `W.brokerId` is chosen at gen time from the baseline-
  reachable set, never start, never golden - visiting it via
  `ssh` flips `S.dwUnlocked` true, prints a broker-contact
  banner, and calls `applyDwUI()` to reveal
  the Darkweb UI elements. New `cmd_creds` shows the player's
  balance + sold-intel count + Darkweb lock state. Scorebar
  gained a Tools/Credits column (`#sb-cs`) that's hidden when CS
  is off OR HB4 sandbox (mirrors how the badge is gated). The
  `homeFile`/`matchHint` split + new `intelFile`/`intelBody`/
  `matchIntel` helpers let `ls`/`cat`/`nano` cleanly handle both
  files; the intel file disappears from `ls` after being sold.
  **CS1d (Darkweb commands and UI - submenu + footer popup).**
  Terminal: a `dw` family dispatcher with
  subcommands list, buy TOOL, sell FILE; a standalone "sell FILE"
  shortcut at the host level. Shop prices: priv-esc-kit $140,
  default-creds $80, hash-cracker $110, bof-exploit $170.
  `dwGate()` blocks all commands until the broker is visited. A
  new "Darkweb ▸" main-menu submenu (popout, mirrors Network
  Options ▸) contains Shop / Inventory / Sell intel here /
  Credits, each wired to its `data-act` handler. A new footer
  `#dwbtn` (purple) sits to the LEFT of `#netbtn`, opens a
  `#dwpop` floating above the button - same mechanics as
  `#netpop`: `position:fixed`, anchored above via
  `getBoundingClientRect`, Esc + outside-click close, focus-steal
  exclusion. `#dwmenu-cell` (the submenu cell) and `#dwbtn` are
  hidden until `S.dwUnlocked`; `applyDwUI()` toggles them based
  on `gCS && S.dwUnlocked && !HB4` and is called from `startGame`
  (reset), broker-visit in `cmd_ssh`, and `toggleCS()` (retract
  on disable). The submenu's "Sell intel here" inspects the
  current host's intel file and runs `dwSell` if present; the
  others delegate to the same handlers as the terminal commands.
  Zero-day apply was scoped in the original TODO but deferred -
  regular tool purchases provide the same play-loop on the
  current 4-pair catalog and can be added back as a follow-up.
  Verified headless across 60+ checks (CS1a 8, CS1b 11 across 50
  worlds, CS1c 10, CS1d 15); no JS/console errors.
- **Network Options menu + footer popup + HHM badge relocation
  (user request) SHIPPED.** Four reorganisations in one pass:
  (1) New "Network Options ▸" item in the main menu (between
  Restart and Challenges) opening a side-flyout `#netsub` with
  four items: **Difficulty** (`data-act="diff"` → `cycleDiff`),
  **HHM** (`data-act="hhm"` → `toggleHHM`), **Firewalls**
  (`data-act="fw"` → `toggleFW`), **IP Mode** (`data-act="ipv6"`
  → cycles IPv4/IPv6/Dual). `netmenu`/`netsub` added to the
  shared subId map alongside admin/chall/theme. (2) **Firewalls
  removed from the Challenges submenu** - that submenu is now
  just daily/challenge/sandbox. The FW green-check CSS selector
  retargeted to `#netsub button[data-act="fw"]` +
  `#netpop button[data-act="fw"]`; `applyFWBtn` now syncs both
  with `querySelectorAll`. (3) **`#hhmbadge` moved from
  `#topctl` (top-right) to the footer `#about` span**, sitting
  just LEFT of `#fwbadge` and LEFT of `#claimbtn`. Restyled like
  `#fwbadge` (compact .78rem, no shadow, `margin-right:.45rem`)
  and now follows the SAME hidden-when-off pattern as Firewall:
  CSS `display:none` by default; only `.h-hhm` (teal) and
  `.h-hard` (indigo) override to `display:inline-block`. Label
  is the bare "HHM" (teal) or "HHM Hard" (indigo); "HHM Off" is
  invisible. The existing badge click handler still cycles
  through the 3 states. (4) **New footer "Network Options"
  button + popup** (`#netbtn` + `#netpop`) sits at the far LEFT
  of the footer span (left of the HHM badge). Click toggles
  `#netpop` open with the SAME four items as `#netsub` -
  identical `data-act` values, no logic duplication
  (`pop.addEventListener('click', e => handleAct(b.dataset.act))`).
  `#netpop` is `position:fixed` and dynamically anchored above
  the button (`place()` reads the button's
  `getBoundingClientRect` + pop's `offsetHeight`, sets top to
  `r.top - ph - 6`, left to `r.left`); a window-resize listener
  reflows it while open. Closes on outside-click (extended the
  global document click that already clears menu/.subpop), on
  Esc (extended the Esc chain right after the admin-pw branch),
  and after selecting an item. `#netpop` added to the
  focus-steal exclusion list so clicks between buttons inside
  it don't yank focus to the terminal. `applyHHMBtn()` label
  shortened ("HHM"/"HHM Hard" - "Off" never shows now); kept
  the `h-off`/`h-hhm`/`h-hard` class scheme so the existing
  HB1-`<modechar>` HHM-bit decoding/encoding is byte-unchanged.
  Pure UI: zero engine/generation/solvability touch; mode
  changes still flow through the existing
  `cycleDiff`/`toggleHHM`/`toggleFW`/`handleAct("ipv6")` paths
  (which rebuild the network code + regen as needed). Verified
  headless (HB1-0DEMOFW0 Casual baseline): `#hhmbadge` no
  longer a child of `#topctl` and IS a child of `#about`; HHM
  off → footer badge `display:none`; `#netsub` items exactly
  `[diff,hhm,fw,ipv6]`; `#challsub` no longer contains `fw`;
  clicking `#netbtn` opens `#netpop` with the same four items,
  `bottom <= #netbtn.top` (sits ABOVE the button); clicking
  HHM in the popup advances `gHHM` to `"hhm"`, badge shows
  teal "HHM", popup closes; another popup-cycle → `"hard"`,
  indigo "HHM Hard"; clicking the badge itself cycles to
  `"off"` and hides; main-menu Network Options → Firewalls
  enables `gFW` + the orange `#fwbadge` shows; Esc and
  outside-click both close `#netpop`; no JS/console errors.
- **U11 (locate / search a discovered host) SHIPPED.** A small
  `#find-box` text input sits on the right of the `#mapyou`
  caption bar (mapyou is already a flex row; `margin-left:auto`
  pushes the input to the far end), backed by a `<datalist
  id="find-list">` for typeahead. `refreshFindList()` rebuilds
  the option list from `S.seenHost` on the input's `focus`
  event (sorted by name). `findHost(q)` resolves a query
  (case-insensitive): exact name match wins, else the first
  prefix match, else null → input gets a transient `.bad`
  border for 800ms. Enter submits; an `input`-event handler
  also fires submit when the value EXACTLY matches a
  discovered name, so picking from the datalist dropdown
  locates without needing Enter too. Highlight pipeline -
  `flashFind(id)` sets `S.findHi = { id, t0, pan:true }` and
  starts a module-scope `_findRaf` requestAnimationFrame loop;
  the loop calls `draw()` each frame and clears `S.findHi` /
  `_findRaf` after 1500ms. Inside `draw()`, immediately AFTER
  the YOU anchor pin block (so it overrides its pan only the
  first frame), a new branch honors `S.findHi.pan` once:
  `S.panX = w/2 - HP[id].x*S.zoom;` (same for Y) then flips
  `pan` to false. A new pass 6c right after the firewall flame
  overlay (and BEFORE pass 7 minimap, which resets the
  transform) draws a two-ring `--accent` pulse around
  `HP[S.findHi.id]` - outer ring `sz*0.7 + 18*pulse` (radius
  modulated by `cos(dt/110)`) at `0.85·(1-k)` alpha, inner
  static ring `sz*0.55` at `(1-k)` alpha, where `k = dt/dur`
  fades to 0. The YOU anchor logic doesn't fight the pan
  because YOU's world point is unchanged frame-to-frame. The
  focus-steal click handler excludes `#find-box`; `startGame`
  cancels `_findRaf`, clears `S.findHi`, empties the input,
  drops `.bad`. Render-only; respects fixed embedder
  positions (no reshuffle); single-file, no external resource.
  HHM Hard subtlety: in Hard the non-YOU host icon is still
  suppressed, but the ring draws at its lattice position - the
  feature is opt-in QoL, the player is explicitly typing a
  remembered name; if you don't want that hint, don't locate
  in Hard. Verified headless (Playwright, HB1-ABC123 with
  admin-revealed 26-host world): datalist size matches
  seenHost on focus (26/26), Enter pans `S.pan*` by 366px (≫1)
  to recenter atlas.com, `S.findHi` set then null after
  1500ms, `_findRaf` cleared, invalid query adds `.bad` + 0
  pan, prefix `ATLAS` (uppercased) resolves to `atlas.com`,
  synthetic `input` event with the full name also locates,
  `startGame` resets value/findHi/raf, no JS/console errors;
  visual (HB1-0DEMOFW0 Casual, atlas.me 3-NIC bridge): green
  pulsing ring straddles the LAN 5 / LAN 4 / LAN 1 junction.
- **F1 (Firewall / ACL filtering) SHIPPED - Tier L, high-risk
  proven done.** Generator-gated hard-mode overlay: some hosts and
  off-spine subnets drop `ping`/`ssh` even when same-LAN; the
  player reads the symptom (host shows `up` in `nmap` but `ssh`
  refuses / `ping` times out) and routes via a different
  unfiltered same-LAN peer (NOT routing - N2 stays deferred).
  **Manual toggle "Firewalls"** inside the Challenges submenu
  (`#challsub button[data-act="fw"]` → `toggleFW()`; green check
  `.on` floated to the RIGHT side of the item via `::after`;
  per-state
  terminal line). `gFW` is in-memory only (NOT persisted, NOT in
  the hash - like `gDiff`/`gFamily`/`gHHM`); flipping it calls
  `startGame(W.code)` to REGENERATE the same world with/without
  the FW overlay - **generator-gate, NOT runtime-gate**:
  hints/`dist` are computed on the filtered graph, so toggling
  mid-run could strand the player at a position whose detour was
  only proven from start. Custom HB4 worlds ignore `gFW`.
  **Algorithm - constructive validate-or-skip** (what made the
  proof tractable): a new FW pass after the router pass walks a
  deterministic candidate list (spine bridges + off-spine hosts +
  off-spine LANs) on its own RNG stream
  (`seed ^ imul(di+1,0x9E3779B9) ^ 0xC2B2AE35`, distinct constant
  - determinism + topology draw order byte-stable like the
  embedder/address/name/hint streams) and TENTATIVELY applies
  each: a spine-bridge filter ALWAYS synthesises an unfiltered
  TWIN bridge on the same two chain LANs FIRST; the tentative FW
  is KEPT only if a filtered forward-reachability BFS still has
  `dist(start→golden)==G` AND every non-golden host keeps a finite
  forward distance (so every H3 hint stays truthful); otherwise
  REVERTED. Invariants therefore hold **by construction**.
  Per-host `h.fw={icmp,ssh}` + per-LAN `lan.acl=Set(hostIds)`
  (directional, inbound ssh). Off-spine host filters never target
  `startId`/`goldenId` or any spine bridge; ACLs never on spine
  chain LANs. `numFilter` per difficulty: Casual `[0,0]`, Normal
  `[1,2]`, Hard `[2,4]`, Brutal `[4,7]`, Extreme `[6,10]`.
  **Adjacency** in `genWorld` now branches: `fwOn` builds the
  FORWARD filtered graph via `canSsh(a,b)` (share a LAN AND not
  `b.fw.ssh` AND some shared LAN does not ACL-deny ssh into `b`);
  `!fwOn` keeps the original symmetric loop →
  **byte-identical baseline when off**. `W.dist`, `W.adj`,
  `W.diameter`, `goldenPath()`, par all reflect the filtered
  graph automatically. **Runtime touch points:** `cmd_ssh` - if
  `gFW && !W.adj[S.curId].includes(target)` ⇒ `"ssh: connect to
  host X port 22: Connection refused"`, NO hop. `cmd_ping` - if
  `gFW && owner.host.fw.icmp` ⇒ realistic `"Request timeout for
  icmp_seq 1"` + 100% packet loss, NO reveal (the host stays
  discoverable via nmap - that IS the lesson). `cmd_nmap`
  UNCHANGED. **Verified:** Python verifier (scratch in
  `w:\hbdev`, deleted) **1500/1500** worlds PASS across 300 seeds
  × 5 levels (determinism, gFW-off baseline equivalence, Casual
  `numFilter=0`, golden never filtered, no ACL on spine LANs,
  every filtered spine bridge has a clean twin, filtered
  `dist(start→golden)==G`, greedy-descent solvable, every
  non-golden host finite-distance). Runtime headless sweep:
  **60/60** worlds across 12 codes × 5 levels (HB1/HB2/HB3).
  End-to-end (HB1-HARD42@Hard, gFW on): `fwHosts=1`, `aclLans=3`,
  `diameter==4` (par holds); ssh filtered host →
  `Connection refused`; ping → `Request timeout`; `nmap -sn` still
  shows it `up`; toggle off → baseline restored; determinism
  across reload confirmed; no JS/console errors. The constructive
  validate-or-skip approach made the parallel-detour guarantee
  trivially provable.
  **Embedder follow-up (user-reported "two clients on top of each
  other"):** when a spine bridge is firewalled, its synthesised
  unfiltered TWIN sat at the SAME `touch(li1,li2)` midpoint and only
  the tiny ±3px last-resort offset separated them. Added
  `touchAt(li1,li2,frac)` (parameterised version of `touch()`;
  `touch = touchAt(...,0.5)` = byte-identical original midpoint), and
  the bridge-placement loop now PRE-GROUPS bridges by their exact
  shared-LAN set: a lone bridge still gets `frac=0.5` (so worlds with
  no FW twins are unchanged), but siblings get evenly spaced fractions
  `(k+1)/(N+1)` along the shared edge - they still straddle BOTH
  LANs (same `x` on a vertical edge, same `y` on a horizontal one),
  just at distinct points. Verified on `HB1-DEMOFW` @ Normal: twin
  bridges previously coincident now sit Δy=128px apart (≫ icon size
  ~46px) on the shared edge, 0 coincident bridge points overall.
  **Admin "Show Firewalls" overlay (user follow-up):** added a third
  Admin ▸ item (`data-act="admin-fw"` → `adminUnlock(toggleAdminFw)`;
  right-side green check `.on` like the Firewalls toggle) that
  toggles `S.adminShowFw`. When ON, a new `draw()` pass **6b**
  (placed AFTER the LAN label block, section 6, so a flame near a
  square's top edge can NEVER be hidden by the LAN title band)
  overlays a canvas-drawn **orange flame** (`drawFlame(ctx,x,y,size)`
  - teardrop + inner yellow tongue, dark outline so it reads on any
  pastel LAN bg) at the top-right of every discovered host that is
  host-firewalled (`h.fw.ssh` or `h.fw.icmp` set) OR a LAN-ACL victim
  (id appears in any `lan.acl` set). Respects fog-of-war and HHM
  Hard (hidden hosts get no flame so the YOU marker stays clear).
  Render-only; `startGame` resets `S.adminShowFw=false` and clears
  the menu `.on`. Note: an off-spine flamed host can land far from
  the start in the lattice (e.g. `wisp.tv` in `HB1-DEMOFW` @ Normal
  sits at world `(1358, 337)` while the start area is around
  `(471, 599)`); pan / zoom / minimap click to find it.
  **Footer FW badge (user follow-up):** an orange **`#fwbadge`**
  ("FW") sits inside the `#about` footer span immediately LEFT of
  `#claimbtn` (Gold Machine). `applyFWBtn()` toggles `.show` on it
  whenever the menu check syncs (any startGame / toggleFW path), but
  ONLY when `gFW && !/^HB4-/i.test(W.code)` - the HB4 sandbox
  generator ignores `gFW`, so the badge stays hidden there to avoid
  advertising a state with no gameplay effect. Click delegates to
  `toggleFW()` for one-click disable from anywhere on the map (same
  regen path as the menu item). Render-only; no engine/generation/
  solvability touch. Verified headless: FW off (HB1-0DEMOFW0) →
  hidden; FW on (HB1-7DEMOFW0, Normal+FW on) → shown, sits to the
  LEFT of `#claimbtn` (badge.right ≤ claimbtn.left), click flips
  `gFW=false` + hides + regenerates with mode char "6"; HB4 sandbox
  with the FW bit set (`encodeCustom({...,fw:1})`) → `gFW=true` but
  badge stays hidden as designed; no JS/console errors.
- **HHM (Hidden Host Mode) SHIPPED - 3-state (user follow-up).**
  In-memory mode `gHHM` is a STRING `"off"|"hhm"|"hard"` (default
  `"off"`; NOT persisted, NOT in the hash - like `gDiff`/`gFamily`).
  Clickable **`#hhmbadge`** in `#topctl`, LEFT of the difficulty
  badge (order: hhmbadge · diffbadge · fambadge · menubtn), cycles
  the three states (`toggleHHM()`; `applyHHMBtn()` sets the
  `h-off`/`h-hhm`/`h-hard` class + the badge label; tooltip
  "Host Hide Mode"; prints a per-mode terminal line). Its palette
  is deliberately a teal/indigo family the other badges never use
  (slate `#475569` → teal `#0d9488` → indigo `#4338ca`, white
  text). The old "Hidden Host Mode" menu item was removed.
  `draw()` derives `hardHide = gHHM==="hard" && !winOpen` and
  `hideTrail = gHHM!=="off" && !winOpen`. **The three states (and
  badge labels):**
  - **`"off"` - badge "HHM Off" (slate):** normal map - every
    discovered host/port/bridge AND the hop-trail breadcrumb draw
    (standard fog-of-war). The map grows as you recon.
  - **`"hhm"` - badge "HHM On" (teal):** discovered hosts/ports
    STILL draw and the map still grows as you recon (icons
    unchanged) - ONLY the hop-trail breadcrumb (pass 3) is hidden,
    so you cannot just retrace your own path.
  - **`"hard"` - badge "HHM Hard" (indigo):**
    `hidden(id)=hardHide && id!==S.curId` gates NIC ports (2),
    host icons/labels (5) and the "+N more" tags; HHM Hard ALSO
    hides the switch icons + "Switch" labels (pass 4) AND the
    current host's switch-link line (pass 2) - a visible switch
    reveals a LAN has >=2 hosts, the very topology Hard makes you
    deduce. So only LAN squares + LAN labels/CIDRs + the current
    host (icon + ducky + YOU + its own port dots) render; the
    hop-trail is hidden too. `swPos` is still COMPUTED (used for
    layout + the LAN-label collision slot) so host packing / label
    placement stay byte-identical across HHM states - only the
    DRAW is gated. Topology must be deduced from
    `ip a`/`nmap`/`describe`.
  LAN squares, LAN labels/CIDRs and the U12 minimap are UNTOUCHED
  in every state. Switches render in Off/HHM but are HIDDEN in
  Hard (the win-screen exception restores them).
  **Win-screen exception:** `#win.open` ⇒ `winOpen` true ⇒ full
  map + trail in BOTH hhm/hard (review the route). The win card is
  **draggable** (pointer-capture like the terminal/notepad; offset
  in the card `transform`, so `transform=""` is the single reset
  source). **`closeWin()`** (shared by ×/Keep-exploring/backdrop/
  Esc) removes `.open`, resets the drag, refocuses the terminal,
  and REDRAWS so HHM/Hard re-applies. `tutStart` forces
  `gHHM="off"` (the tutorial needs the visible map). **URL capture
  (user follow-up):** HHM mode is now pinned in the share URL via
  `?h=<off|hhm|hard>` (`shareUrl()` emits it for Copy Share Link
  only when non-default; Copy Challenge Link always emits both
  `?d=` and `?h=`; boot parses both ONCE before `startGame` so the
  recipient sees the EXACT same hide mode). Plain reload without
  the param still resets to "off" - same sanctioned exception as
  `?d=`. Pure display
  filter on fog-of-war: no engine/generation/solvability/embedder/
  persistence touch; deterministic. Verified headless (Playwright,
  full admin-revealed HB1-ABC123, fabricated 2-hop trail):
  Off=52 icons+trail; HHM=52 icons, NO trail; Hard=17 icons (YOU +
  switches), NO trail; Hard+win=52+trail (exception); Hard after
  close=17 (reverts); cycle wraps to Off=52+trail; right-side
  label Off/HHM/Hard correct; no JS errors.
  **Follow-up (user: "HHM hard should hide switches"):** Hard now
  ALSO hides the switch icons + "Switch" labels (draw pass 4) AND
  the current host's switch-link line (draw pass 2) - both gated
  on `hardHide` - so a visible switch no longer reveals that a LAN
  has >=2 hosts. `swPos` is unchanged (still computed for layout +
  the LAN-label collision slot, so positions stay byte-identical
  across HHM states; only the DRAW is gated); the current host's
  port DOTS still draw. Off/HHM unaffected; the win-screen
  exception still shows switches (keyed on `hardHide`). The old
  "Hard=17 icons (YOU + switches)" / "switches UNTOUCHED in every
  state" lines above are superseded. Re-verified headless
  (admin-revealed HB1-ABC123): Off/HHM 15 switches each, Hard 0
  (2 icon draws = the YOU host + its ducky), Hard+win 15
  (exception); a Hard screenshot shows no switches and no orphan
  link lines; no JS errors.
- **C1 (cipher ladder on the win screen) SHIPPED.** Each
  difficulty's win panel shows ONE encrypted note in a new
  `#win-cipher` block (filled by `renderCipher()`, called from
  `showWin()` AFTER `#win` gets `.open` so the small card can be
  measured). Decoding it BY HAND spells the next level's cipher
  type; Extreme decodes to the final reward. Ladder (cipher →
  message): Casual Caesar `SLJSHQ`→PIGPEN; Normal Pigpen→POLYBIUS
  SQUARE; Hard Polybius `42 11 24 31 ...`→RAIL FENCE ZIGZAG;
  Brutal Rail-Fence(3) `VNCEIEEEIHRGRP`→VIGENERE CIPHER; Extreme
  Vigenere(key DUCKY) `GOEUWIITDFHQKX`→DUCKY FOR THE WIN. (Enigma
  was dropped as non-viable to hand-decode; Vigenere is the
  hardest hand-solvable rung and closes the Caesar→Vigenere loop.)
  **User follow-up: the cipher is NOT named and NO hint shows** -
  the panel title is the fixed line "This machine has an encrypted
  note about the next difficulty level." and `#wc-hint` is
  emptied; the player identifies + cracks it unaided (DUCKY is the
  mascot / final answer, still guessable). The `C1` table is
  ciphertext-only (`{ct}`/`{pig}`); `type`/`hint` strings removed
  AND a follow-up scrubbed every scheme name + the `DUCKY` keyword
  from code COMMENTS too (user: source-peek concealment) - no
  scheme name / keyword / decoded plaintext is anywhere in source;
  the only residual token is the `drawPigpen` function name (2
  call sites; kept for maintainability, approved).
  **Security (obscurity-only, like the Admin answer-key):** only
  the precomputed ciphertext stored - Pigpen as a letter-INDEX
  array (`[15,14,...]`, never the word). Plaintext never stored,
  computed, or DOM/console-written; no decoder ships; final reward
  string verified absent from source. Encoders ran OFFLINE in a
  throwaway `w:\hbdev` script (deleted; round-trips proved every
  rung solvable). **Pigpen** is canvas line-segment glyphs
  (`drawPigGlyph`/`drawPigpen`: two tic-tac-toe pens via the
  interior-edge rule + two X-wedge grids, 2nd of each dotted) -
  NOT a font (single-file/no-CDN safe); it auto-sizes 28→14px and
  wraps to fit the ~320px (@11px root) win card. Custom/HB4 worlds
  show NO note (`decodeCustom` gate). Render-only, `gDiff`-keyed,
  no engine/generation/solvability/persistence touch. Verified
  headless (Playwright, HB1-ABC123 + HB4): all 5 levels show the
  fixed title (no cipher name), empty hint, ciphertext/Pigpen
  still rendered, Custom hides it, final reward not in source, no
  JS errors; Normal (glyphs) + Extreme (`GOEUWIITDFHQKX`)
  screenshots clean.
- **U13 (mask the Admin passphrase) SHIPPED.** The Admin ▸ unlock
  no longer uses the plaintext browser `prompt()`: `adminUnlock`
  is callback-based (`adminUnlock(onOk)`) and opens a masked
  `#adminpw` modal (see the Admin-menu note below for the full
  mechanics). Auth model unchanged - still client-side obscurity,
  NOT real auth; masking only stops a shoulder-surf / on-screen
  reveal.
- **P3 (sandbox / custom-difficulty codes) SHIPPED.** A 4th code
  prefix **`HB4-`** whose BODY encodes instructor-dialed topology:
  6-char base36 of a 27-bit packed int (`G` bits0-4, `leftArm` 5-7,
  `numBranch` 8-11, `numLoop` 12-14, `fam` 15-16, `v6t` 17-20,
  `dualt` 21-24, `size` 25-26) + a random base36 nonce. Same code
  rebuilds the SAME world (determinism); the nonce lets a re-roll at
  identical params vary. `encodeCustom`/`decodeCustom`/`customCfg`
  +`clampInt` near `seedFromCode`; every field CLAMPED to a solvable
  range before packing. `CODE_RE` widened to `/^HB[1234]-/i` (Load /
  boot / hashchange accept HB4); `familyOfCode` decodes HB4's family
  param; `seedFromCode` HB4 branch = FNV-1a of the whole body (the
  HB1-3 `parseInt(.,36)` path is byte-unchanged). `genWorld`: a
  custom code uses `customCfg` (pinned `G/leftArm/numBranch/numLoop`
  as `[v,v]`; `size`→`SANDBOX_SIZES` density preset for
  `numFill/numDead/numRouter/prefixes`; `v6pct/dualPct` from the
  param) and a FIXED `di=0` so the world depends on the code ALONE -
  difficulty does not apply (the diff badge shows **`Custom`**,
  `.d-Custom`; clicking it regenerates the identical world, so it is
  non-confusing). UI: a `#sandbox` modal (reuses the overlay/`.open`
  pattern + `data-act`/`handleAct`/backdrop/Esc) reached via the
  Challenges submenu "Custom network"; "Generate & play"
  (`startGame(readSandbox())`), "Copy custom link" (hash-only, no
  `?d=` since difficulty is irrelevant), "Close". `#sandbox` added
  to the focus-steal exclusion list (the modal has inputs/selects),
  the Esc list, and the disruptive-act `tutEnd` set. Loops need a
  chain of 3+ (`C=G+leftArm>=3`) - the generator already clamps to
  0 otherwise (no solvability impact; the modal hint says so).
  Render/solvability invariants unchanged: adjacency is
  family-agnostic and pinned `G` feeds the proven chain generator.
  Verified headless: **1944 param combos** (G/arm/branch/loop/size
  × all 3 families) - 100% encode/decode round-trip, family,
  determinism (incl. difficulty-independence), `dist(start,golden)
  ==G`, greedy-descent solvable; UI: modal opens, Generate&play →
  HB4 dual world diameter==dialed G, badge "Custom", reload via the
  code reproduces it, Copy link writes a `#HB4-` link, Esc/backdrop
  close, C<3 loop clamp safe, no JS errors.
- **U12 (minimap / overview) SHIPPED.** End of `draw()` (after a
  `setTransform(dpr,...)` reset to screen px) draws a bottom-right
  inset of the WHOLE discovered map: every discovered LAN square
  scaled into a fixed box from the SAME `region`/`EMB` positions
  (fog-of-war respected), an `--accent` YOU dot at `HP[curId]`, the
  current viewport rectangle (`(-S.pan)/S.zoom` .. `(canvas-S.pan)
  /S.zoom`), and - only when the golden location is KNOWN
  (`S.admin` or `W._won`) - a gold dot at `HP[W.goldenId]` plus,
  under `S.admin`, the gold `goldenPath()` route polyline (mirrors
  the main map's gold route; YOU is `--accent` so it stays distinct
  from the gold dot). Only when `lanIds.length >= 6` (small/tutorial maps
  skip it). The box geometry is stored in module-scope `_miniMap`;
  a canvas left-`pointerdown` inside it maps click to world coords,
  sets `S.panX/panY` to recenter, `stopPropagation` (keeps the
  focus-steal off), `draw()`. Render-only: no engine / generation /
  solvability / embedder touch, no reshuffle, no persistence.
  Verified headless: hidden on small maps, shown on a 109-LAN
  Extreme, click recenters `S.pan*`, screenshot clean, no JS
  errors.
- **STYLE RULE (user, hard): never use em dashes or en dashes
  anywhere** - code, UI/terminal strings, comments, docs, or chat.
  Use a hyphen, colon, semicolon, parens, or reword. Stored in
  agent memory (`no-dashes`). Applied as a one-time purge: 0 em/en
  dashes remain in `index.html` (97 removed), CLAUDE.md (140),
  `docs/TODO.md` (74); only comment/string/prose punctuation
  changed, no JS touched, runtime re-verified clean. (`·` middot,
  `→`/`⇒` arrows, `…` ellipsis are NOT dashes and out of scope;
  the separate no-ellipsis-in-UI-labels rule still stands for
  product labels.) Never reintroduce long dashes.
- **U10 (accessibility: map summary + keyboard nav) SHIPPED.**
  `cmd_describe()` (command `describe`, alias `map`) narrates the
  DISCOVERED map as terminal text (current host+ip; each
  connected+seen LAN's number/cidr, dual shows both; discovered
  peers + their ip + "bridges to LAN N"; a discovered-counts
  summary). Mirrors fog-of-war (`S.seen*` only); the terminal is
  real DOM so a screen reader can read it. No engine/solvability
  touch. `#map` canvas: `tabindex=0` + `role=img` + `aria-label`,
  `:focus-visible` outline, and a canvas-scoped `keydown` (arrows
  pan 60px, `+`/`-` zoom about canvas centre, same 0.3-3.5 clamp as
  wheel, `0` resets+recenters, `preventDefault` stops page scroll;
  scoped to the canvas so it never clashes with the terminal's
  arrow-key history). In HELP. Verified headless: describe/map
  correct, HELP lists it, keyboard pan/zoom mutates
  `S.pan*`/`S.zoom`, canvas focusable+labelled, screen dash-free,
  no JS errors.
- **Top-bar + menu UI pass (user requests) SHIPPED.** (1) The IP
  toggle moved out of the menu into a clickable **`#fambadge`** in
  the new top-right **`#topctl`** fixed flex cluster (order:
  `#diffbadge` · `#fambadge` · `#menubtn`; flex replaced the old
  per-element `right:` offsets). 3 colour-coded states
  `f-ipv4`/`f-ipv6`/`f-dual`; click → `handleAct("ipv6")` cycles
  IPv4→IPv6→Dual-stack (regenerates a new world in that mode).
  `updateFamLabel()` now drives the badge (was the removed
  `#famlbl`). (2) **Side-flyout submenus**: a shared
  `.subcell{position:relative}` wrapper + `.subpop` (`.open` →
  `position:absolute; right:100%; top:0`) so a submenu pops LEFT
  over the map and never lengthens the main menu. THREE use it:
  **Admin ▸** (`#adminsub`, passphrase-gated on open),
  **Challenges ▸** (`#challsub`: "Today's network" + "Copy
  challenge link"), and **Theme ▸** (`#themesub`: "Light Mode" /
  "Dark Mode" / "Auto" via `setTheme(m)`=`applyTheme`+persist
  `hostbound.theme`; an `<hr>`; then "High-Visibility"). The active
  mode gets a green check: every `#themesub button` has a
  fixed-width `::before` slot (keeps labels aligned) and `.on`
  fills it with `\2713` in `--accent`; `applyTheme` sets `.on` on
  the active `theme-*` button, `applyHiVis(on)` sets `.on` on the
  `hivis` button when hi-vis is enabled (so it shows the same green
  check). The old single cycling Theme item + `#themelbl` span +
  `cycleTheme()` were replaced by these. Only ONE flyout is open at a time (opening one closes the
  others; an outside click closes the menu + all `.subpop`). The
  menu click handler is generalized via a `{adminmenu:"adminsub",
  challmenu:"challsub", thememenu:"themesub"}` map. (3) Menu relabels/reorders:
  "Interactive tutorial"→"Tutorial", "Toggle high-visibility
  mode"→"High-Visibility", "Show command help" removed (the `help`
  terminal command + Docs modal remain; `handleAct"help"` branch is
  now dead-but-harmless), "Notepad" moved to the top of its
  section. Verified headless: badge order/cycle/colours, IP menu
  item gone, both flyouts `position:absolute`/left-of-menu/menu
  height unchanged/one-at-a-time, Challenges items work, menu
  order, terminal `help` still works, no JS errors.
- **U8 (seed-of-the-day / shareable challenge) SHIPPED.**
  `dailyCode()` = local YYYYMMDD through a mulberry32-style mix →
  `codePrefix()+base36`; same date+family ⇒ same code ⇒ same world
  (menu "Today's network", `data-act="daily"`). `copyChallenge()`
  (menu "Copy challenge link") copies
  `base?d=<gDiff>#<W.code>` - world+family in the hash, difficulty
  pinned in the `?d=` param. Boot parses `?d=<Level>` ONCE (before
  `startGame`, validated against `DIFFS`) so a recipient gets the
  EXACT same map at the same level; non-persisted (plain reload
  resets to Normal, invalid ignored - see the persistence note
  above). `clip()` extracted (shared by share/challenge copy).
  Tutorial guard + tutEnd cover `daily`. No persistence key, no
  engine/generation touch. Verified headless: daily deterministic
  & == `dailyCode()`, challenge link `?d=Hard#HB1-ABC123`, boot
  `?d=Brutal` pins once, reload-no-param → Normal, `?d=Bogus`
  ignored, no JS errors.
- **U9 (export run report / instructor answer key) SHIPPED.**
  `exportReport()` (by `copyShareLink`) builds a plain-text report:
  code, difficulty, address mode, WON/in-progress, hops (par),
  claims, time, rating (from `W._winStats` when won), and the full
  ssh trail (`S.trail`+`S.curId`, name + primary IP). The
  **answer key** (golden host+addr, optimal START→golden via a
  greedy walk on `W.dist`/`W.adj`) is **gated on `S.admin`** -
  non-admin gets a "(Answer key hidden - unlock Admin ▸)" line so a
  student can submit their own run without the solution. Emitted 3
  ways (no server / file:// safe): printed to the terminal
  (selectable), `navigator.clipboard` (best-effort), and a
  `data:text/plain` `<a download>` `hostbound-<code>.txt`. Wired
  `data-act="export"` → `handleAct`; menu item "Export run report"
  + a `#wincard` "Export report" button (same `#wincard
  button[data-act]`→`handleAct` path). No persistence, no
  engine/generation/solvability touch. Verified headless: non-admin
  (header/code/diff/trail, key hidden, printed + downloaded);
  admin (Instructor answer key/Golden machine/Optimal path); no JS
  errors.
- **T1 (interactive guided tutorial) SHIPPED.** A NON-blocking
  coach card `#tutcard` (fixed top-centre, NO backdrop - the player
  uses the real terminal/map underneath) opened only from the menu
  ("Interactive tutorial"). 8 steps on a fixed small Casual world
  `TUT_CODE = HB1-LEARN1`: Welcome → `ip a` → `nmap -sn` → `ssh` →
  `ls`/`cat` → descend → `claim` → recap. `execute()` calls
  `tutObserve(argv, ΔS.hops)` after the switch; the current step's
  `want(argv,dh)` predicate auto-advances (ssh = positive hops
  delta). Manual **Next** always advances (never stuck);
  **End tutorial** / Done close it. State is in-memory only
  (`gTut={on,step}`, `TUT_STEPS`) - never auto-shown, NO persisted
  "seen" flag (persistence constraint). `tutStart` sets
  `gFamily=ipv4`/`gDiff=Casual` then `startGame(TUT_CODE)`;
  new/load/ipv6 menu acts drop a stale card. Reuses the modal CSS +
  `data-act`/`handleAct` wiring; tutcard buttons wired like
  about/docs. Pure UI - zero engine/generation/solvability change.
  Verified headless end-to-end (each real command auto-advances,
  Next/End/Done work, Casual `HB1-LEARN1`, non-blocking, no JS
  errors).
- **N5 (IPv4/IPv6 dual-stack) SHIPPED - 3 address modes by code
  prefix.** `HB1-` = pure IPv4, `HB2-` = pure IPv6 (the
  byte-unchanged `fam==='ipv6'` early-return branch), `HB3-` =
  dual-stack. `assignAddresses`' v4/dual branch draws each LAN's
  family on the existing separate address RNG stream with
  `v6pct = (fam==='dual') ? cfg.v6pct : 0` (so HB1 stays all-v4,
  only HB3 mixes; START LAN forced v4; per-difficulty Casual 0 →
  Extreme 0.5), then assigns v4 (pool+non-overlap) or v6 (unique
  ULA /64) per LAN with independent bookkeeping. The menu toggle
  cycles **IPv4 → IPv6 → Dual-stack** (`gFamily`
  ipv4/ipv6/dual; `codePrefix` HB1/HB2/HB3; `familyOfCode` parses
  all 3; `CODE_RE` `/^HB[123]-/i`; famlbl IPv4/IPv6/Dual-stack);
  intro banner per `W.family`. New `W.hasV6`. The 5 former
  whole-world `W.family` spots are now per-object
  (`addrCmp`/`validTarget`/ping-loopback infer from the literal;
  `nmapHelp` example from the host's primary NIC; `ssh <hostname>`
  gate → `W.hasV6`); v6 LAN labels show "· IPv6". `sameLan` already
  gated on family so cross-family ping/ssh is blocked - the
  teaching: hop a dual-stack bridge. Solvability is family-agnostic
  (adjacency = shares-a-LAN; family assigned post-topology) so
  `dist(start,golden)` is unchanged. Verified: 315 N5 worlds (0
  fail) + 3-mode check - HB1 0 v6 LANs all levels, HB2 0 v4, HB3
  mixed (Casual 0 v6, harder mixes), determinism per code+level,
  toggle cycles HB1→HB2→HB3→HB1, greedy solvable, no JS errors.
  **Follow-up (user: "HB3 looks all-IPv4"):** root cause was sparse
  v6 + fog-of-war (start LAN forced v4) - not a bug, but the mode
  was effectively invisible. Fixed: bumped `v6pct` (Normal
  0.25→0.4 … Extreme 0.5→0.6) AND force `chain[leftArm+1]` (first
  hop past start, on the win path) to v6 so every HB3 non-Casual
  run crosses a v4↔v6 boundary one hop in. Re-verified 455 worlds
  (0 fail): HB3 non-Casual always has a v6 LAN on the greedy path +
  a dual-stack crossing bridge at the start, dist==`G`, solvable,
  deterministic; HB1/HB2/Casual-HB3 unchanged; HB3-CX5Z4M Normal
  3/33→15/33 v6.
- **N5 follow-up: TRUE dual-stack LANs + v6 label fix (user).**
  User: HB3 LAN 28's v6 host addresses overlapped; and wanted
  "a network where all hosts are dual-stack, the LAN square showing
  two network numbers." Fixes: (1) `shortIp(a)` compacts on-map v6
  host labels to `::iid` (under-icon + U2 bubble) - the full /64 is
  on the square label, so the long string overflowing packed cells
  is gone. (2) Chosen delivery (asked the user - "Augment HB3"):
  some HB3 LANs are now TRUE dual-stack subnets (`lan.cidr6`,
  `nic.ip6`, `dualPct` per level) - square shows both CIDRs +
  "· dual-stack", `ip a` shows inet+inet6, reachable via either
  family, `ipOwner`/`ipReachesFrom`/`lanFromArg`/`nmap` all
  dual-aware. Single-NIC so embedder untouched; family-agnostic
  graph so dist/solvability unchanged. Verified 380 worlds (0
  fail): HB1 pure-v4, HB2 pure-v6, Casual-HB3 all-v4, HB3 non-Casual
  has dual LANs (hosts with ip+ip6), ipOwner maps both, determinism,
  greedy solvable; visual: a dual LAN square shows two networks,
  compact v6 labels, no JS errors.
- **N5 follow-up 2: multi-homed label legibility (user).** User:
  multi-homed/dual-stack host IPs "impossible to read" - the U2
  per-port `ethN: ip` tags scattered fragments of a single address
  across positions. Removed the at-bubble text entirely (port dot +
  link only). Multi-homed hosts now show a **stacked under-icon
  list, ONE line PER NIC**: `ethN  <full v4>  <full v6>` - the
  whole address(es) on one line, never split or compacted; lines
  stack so they can't overlap. Single-NIC packed-in-square hosts
  keep the compact `::iid` (the original LAN-28 fix; full v6 there
  re-overlaps). Removed the dead `hasDown`/`gap`/`ipY` bubble-push
  logic. Verified on HB3-CX5Z4M (atlas.org: `eth0 fd8c:…::548c` /
  `eth1 50.76.14.44` / `eth2 26.253.229.63 fd56:…::4872`) - each
  address whole on its line, readable; multi-mode/level + Extreme
  dual admin-reveal redraw sweep: no JS/console errors.
- **N5 follow-up 3: bigger squares so bridge labels clear neighbours
  (user: "loom.co covers up marrow.us - spread the icons out").**
  A dual-stack bridge on a shared edge has a wide stacked v4+v6
  label that bled into the adjacent square's packed host. Fix
  (CLAUDE-sanctioned "BIG squares", NOT SPREAD): `sizeShared`
  enlarged (`w=max(320,cols*208+36)`, `h=max(196,84+rows*140+20)`)
  so packed single-NIC hosts sit further from edges; plus a
  width-fit safety on the multi-homed stack (measure widest line;
  if > 300px budget, scale font down to min 6.5px) so a 3-4-NIC
  dual host can't smother even so. Squares still touch, cells never
  move, no reshuffle. Verified HB3-CX5Z4M: loom.co's stack no longer
  covers marrow.us, addresses whole/readable; 16 worlds (4 codes ×
  4 levels) - 0 true-square overlaps, no JS errors. No N5 work
  remains.

- **G1 corner-seating refinement (user follow-up) SHIPPED.** A
  3+-shared router was sitting at the 2-LAN edge MIDPOINT with the
  3rd link crossing (the documented "logical, not a touch" minor) -
  user wanted it at the shared CORNER where all its squares meet
  (NE/NW/SW of the three cells). Added `junction(ids)` to the
  embedder: the lattice point that is a CORNER of the most of a
  host's shared-LAN cells; a `sh.length>=3` host seats there when
  `n>=3`. **Render-only - cells/walk/topology/solvability
  untouched.** Plus a hard distinctness guard: bridge points are
  taken from a priority candidate list (3-way corner → chosen
  2-LAN edge touch → every other adjacent shared pair's edge →
  tiny deterministic offset last resort) skipping any occupied
  point, so the "0 coincident embedded points" invariant holds even
  in dense Brutal/Extreme maps (this also fixed a *pre-existing
  latent* 2-bridge edge-midpoint collision G1's extra LANs made
  more likely - the old 75-case check never hit it). Verified
  headless across 40 worlds (8 codes × 5 levels incl. heavy
  Extreme): **0 coincident points**, 166 routers with **57% seated
  at a true ≥3-way corner** (was 0%; the rest fall back to a
  distinct 2-LAN edge touch and still draw all 3 ports/links), no
  JS errors; user's `HB1-1WJ25LR` Extreme: a 3-NIC router straddles
  its three squares' corner. Raising the 57% needs a walk bias
  (changes every world's cell layout → re-verify topology +
  rejected-models risk) - left as an optional follow-up, not done
  unprompted.
- **G1 (hosts with 3+ interfaces) SHIPPED.** Investigation found the
  2-NIC ceiling was a *rendering* choice, not a generator limit
  (`genWorld` already minted 3-4-NIC hosts; private-leaf NICs are
  hidden by U1, and the only 3-*shared* host - the loop `oc` -
  exists only at Hard+). Part A (render) needed no change:
  `draw()`'s NIC loop already draws a port+link per rendered NIC.
  Part B (user chose B): new per-difficulty `numRouter` + a
  deterministic ROUTER pass (last topology step, main RNG) that
  upgrades that many TRUE bridges (≥2 shared LANs + a private leaf)
  by adding one decoy to the private leaf → it becomes a shared
  dead-end stub and the bridge renders as a real 3-connected
  router. Stub = tree leaf → **no shortcut** (`dist(start,golden)`
  stays `G`). Verified: scratch topology harness, **1500/1500
  (300×5) pass** - determinism, connected, `dist==G`, `numRouter`
  in spec, every router ≥3 shared NICs, Casual still boring, greedy
  solvable; headless reveal-network eyeball (Normal) shows 2
  routers, 3 ports/links to switches, clean, no JS errors. Embedder
  untouched (the existing 2-LAN `touch()` + per-NIC draw loop
  handle the 3rd shared NIC exactly like the loop-`oc` case; the
  "3rd NIC is a logical, not-cell-adjacent tie" minor still applies
  and is acceptable - the stub usually lands adjacent via the
  shared-LAN walk).
- **U6 (closable win window) + U7 (notes scratchpad) SHIPPED.**
  U6: `#win` is dismissable - a `#win-x` × + "Keep exploring"
  button (`data-act="win-close"`), backdrop click, and a global
  **Esc** handler closing whichever overlay is open (win/about/docs;
  nano's earlier capture-phase handler still wins for nano).
  `showWin()` reworked: the FIRST win freezes a `W._winStats`
  snapshot and stops the clock (P1); re-opening only redraws that
  snapshot - never restarts the timer or rescores even though
  post-win ssh/claim still mutate live `S`. `cmd_claim()` guarded:
  once `W._won`, `claim`/Gold button just re-shows the locked
  result (no `S.claims++`). `startGame` clears `W._winStats`.
  U7: a draggable/minimizable floating `#notepad` (chrome palette,
  monospace `<textarea>`, `#noterestore` tab) mirroring the
  terminal-window pattern; opened from the hamburger
  (`data-act="notes"`) OR a `#notepad-btn` button on the `#mapyou`
  caption bar at the FAR LEFT (it's the first `#mapyou` flex child,
  before the `#mapyou-txt` "YOU ARE HERE" span - caption text moved
  into that span so `setCaption()` doesn't wipe the button).
  Default size = the terminal's (46rem×21rem); `toggleNotes` snaps
  it to the **map canvas's upper-left corner** on first open
  (`np.dataset.placed` guards re-snap until dragged; `startGame`
  clears it + inline pos to re-snap next game). **In-memory only** -
  `startGame` wipes + re-minimizes it, NO localStorage key (game
  state stays hash/in-memory per the persistence constraint). The
  focus-steal click handler now also excludes
  `#notepad`/`#noterestore`/`button` so typing notes isn't yanked
  to the command line. Verified headless: ×/backdrop/Esc close, Gold-button
  reopen keeps `Claims made: 0` (no rescore), notepad
  open/type-keeps-focus/minimize/restore/clear-on-new-game, no JS
  errors.
- **Terminal history-edit + copy/paste fixed (user-reported
  "odd responses").** Two bugs: (1) the up-arrow history index
  (`S.hi`) kept pointing into `S.history`, so editing a recalled
  command then pressing ↑/↓ silently jumped to a different old
  command and the un-submitted draft was lost (↓ to the bottom
  gave `""`). Reworked to readline-ish: a working copy
  `S.histBuf = [...S.history, draft]` (new in-memory `S` field,
  reset in `startGame`); ↑/↓ index it, never mutate `S.history`,
  per-slot edits persist (`onCmdInput` writes the live value back
  into the browsed slot), the draft is the bottom slot and is
  restored on ↓, no wrap at the top, Enter clears
  `S.hi`/`S.histBuf`. (2) Copy/paste: the off-screen
  `#cmdline` meant scrollback selection got eaten by the
  focus-steal click handler and right-click-Paste landed nowhere.
  Fix: the focus-steal handler now bails when a non-empty
  selection exists (so Ctrl+C works on the scrollback); a
  `#termwrap` `paste` listener inserts clipboard text at the caret
  (newlines → spaces, single-line box). Verified headless: ↑/↓
  newest/older, edit-persists-per-slot, draft restored on ↓,
  paste-at-caret, selection survives a click, no JS errors.
- **Hamburger label dropped + Docs/About modals added (user
  request).** `#menubtn` now shows only the `&#9776;` glyph (the
  word "Menu" removed; `aria-label`/`title="Menu"` kept for a11y).
  Bottom menu section (after the last `<hr>`) holds two items:
  `data-act="docs"` then `data-act="about"`. `#docsbox` is a
  scrollable in-app reference modal (What this is / same-LAN rule /
  Commands / finding golden / fog-of-war map / codes & difficulty;
  left-aligned, `max-height:88vh` body scroll, themed `code` spans);
  same overlay/`handleAct`/backdrop-click/Close (`docs-close`)
  wiring as About. `data-act="about"` opens `#aboutbox` - a centred
  modal mirroring
  the ENE about box (`w:\ene`, READ-ONLY ref): ducky logo, "About
  Hostbound", Authors **Dr. Douglas R. Bowie** /
  **Professor Brandon A. Saunders**, "J. Warren McClure School of
  Emerging Communication Technologies", the
  `ohio.edu/scripps-college/mcclure` link, and "© 2026 Douglas R.
  Bowie and Brandon A. Saunders. All rights reserved." - same
  authors credited the same way as ENE. Reuses the `#win`
  overlay/card CSS pattern (`.open`→flex, z-index 45). Closed via
  the Close button (`data-act="about-close"`) or a backdrop click;
  wired through `handleAct` like the win-card buttons. (The footer
  `#about` strip's "Ohio University · ECT Dept" line is unchanged.)
  Verified headless: menu→About opens, content/logo render, Close
  dismisses (`!open`), no JS errors. (Follow-up, user: the menu
  button label was shortened "About Hostbound" → **"About"**; the
  `#aboutbox` modal heading stays "About Hostbound" - `data-act`/
  wiring unchanged.)
- **H3 (hints live in a discoverable file) SHIPPED.** The recon/intel
  clue is no longer dumped on ssh-in - `banner()`'s clue line is
  gone (the weaker relative `Bearing: warmer/colder` cue stays; it
  is a separate existing mechanic, out of H3's scope). In `genWorld`
  the clue moved `h.hint` → `h.hintBody`; every host (incl. golden,
  body = clue-free "No recon data here." so file-presence is not a
  tell) also gets `h.hintFile = <word>.txt`, a 1-8 letter
  pronounceable word from its own derived RNG stream
  (`seed ^ imul(di+1,0x9E3779B9) ^ 0x85EBCA6B`, distinct constant →
  topology draw order untouched; `HB1-X`≡`HB2-X` share filenames,
  family not mixed). Clue text byte-identical to the old banner line
  → greedy-descent solvability proof still holds; reachability/
  scoring unchanged. New minimal read-only home FS: `ls` (the host's
  one `<word>.txt`), `cat <file>` (accepts `./`; wrong →
  `cat: <f>: No such file or directory`), `nano <file>` (GNU-nano-
  style full-screen READ-ONLY overlay `#nano`, inverse title/shortcut
  bars in the terminal palette; ANY key/click closes via
  capture-phase listeners that swallow the dismissing event so it
  never hits the command line). HELP + first-boot intro nudge
  updated. Verified headless v4 (HB1-ABC123) + v6 (HB2-ABC123):
  banner clue-free, `ls`→`m.txt`, `cat`/`cat ./`/missing/`nano` all
  correct, clue body byte-identical to the old hint, v4≡v6 filename
  (determinism), nano renders & closes, no JS errors.
- **`ip a` shows the interface network number (teaching aid).**
  Each eth NIC line is now
  `inet <addr>/<prefix> net <network>/<prefix> scope global ethN`
  (the `net` token = `cidrStr(n.ip)`, family-aware so v4 `/29` and
  v6 `/64` both compute correctly via the BigInt path). `lo` keeps
  the standard `scope host lo` line (no `net`). NOT real `ip a`
  output - deliberate, so students can see the network same-LAN
  reachability keys off without computing the mask by hand (pairs
  with NM1's help telling them to run `ip a`). Verified headless
  v4 (HB1-ABC123) + v6 (HB2-ABC123): correct `net` per family,
  `lo` unchanged, no JS errors. **`cmd_ipa` now REQUIRES the
  object** (user: bare `ip` should not work): no subcommand prints
  a real-iproute2-style `Usage: ip [ OPTIONS ] OBJECT ...` line +
  an `(try ip a)` hint line and does NOT dump addresses; only
  `a`/`addr`/`address` show interfaces (unknown object still
  `Object "X" is unknown`). The T1 tutorial step-1 `want`
  predicate tightened to `a[0]==="ip" && a[1] in {a,addr,address}`
  so a failed bare `ip` no longer auto-advances.
- **NM1 (realistic per-subnet nmap) SHIPPED.** `cmd_nmap` reworked:
  it scans **exactly ONE subnet** - the old no-arg "scan every
  connected LAN at once" path is GONE, so a multi-homed host must
  sweep each of its networks separately. The single-LAN
  `revealLan`/`seenHost` reveal path is byte-for-byte the old
  single-target branch, so per-subnet fog-of-war isolation is
  inherent and the greedy-descent/solvability proof is untouched
  (display/scan-scoping only). `nmap` / `nmap ?` / `nmap -h` /
  `nmap --help` print `nmapHelp()`: usage + the canonical ping-sweep
  form `nmap -sn NET/PREFIX` and a **generic** family-aware example
  (`192.168.1.0/24` for v4, `fd00:1234:5678::/64` for v6) -
  deliberately **NOT** the host's real subnets; the help instead
  tells the player to run `ip a` to find those (user direction:
  the help is an example, not a spoiler of the actual networks).
  `-sn` is **optional**: it is accepted (and
  ignored) anywhere, so `nmap <subnet>` behaves identically to
  `nmap -sn <subnet>` (either form scans the same). The first
  remaining non-flag token is the
  target, resolved via `lanFromArg` (now **STRICT** - the token
  must be the EXACT `<network-address>/<prefix>` of a connected
  LAN: a bare IP with no mask, a host address inside the subnet,
  or a wrong prefix are all rejected; v6 is spelling-agnostic on
  the network address; teaching aim - the student must read
  `ip a` and get the mask right) OR a **hidden** interface-name
  alias (`eth0`/`lo` → that NIC's subnet) that is intentionally
  NOT advertised in help and is the only maskless form accepted.
  A maskless address that looks valid gets an explicit "a target
  needs a network mask, e.g. TOKEN/24" (or `/64` for v6) hint in
  addition to the generic invalid-target line; `nmapHelp()` text
  is unchanged. A real but not-directly-connected LAN → "not on a directly
  connected network"; an unresolvable token → "invalid target" + a
  `nmap -h` hint. Main HELP line updated to the `-sn` form (alias
  unadvertised). Same-LAN reachability unchanged (no routing).
  Verified headless v4 (HB1-ABC123) + v6 (HB2-ABC123): `nmap`,
  `nmap -h`, `nmap ?` all render the help with the host's real
  subnet & canonical form; `nmap -sn <cidr>` and the hidden
  `nmap -sn eth0` both scan & reveal exactly that one LAN;
  not-connected/invalid rejected with the hint; no JS errors.
  Re-verified (HB1-ABC123): `nmap <subnet>` and `nmap -sn <subnet>`
  produce byte-identical scan output (help text unchanged).
- **P1 (timed / par mode) SHIPPED.** `W.diameter`
  (`dist[startId][goldenId]`, the optimal start→golden ssh-hop count)
  surfaced as **par**: scorebar shows `Hops traversed: N (par P)`
  (`#sb-par`, dim; green at/under par, red over). A per-run **timer**
  (`#sb-time`, `startTimer`/`tickTimer`/`stopTimer`, `S.t0`/`S.tEnd`,
  `fmtTime`) starts in `startGame`, ticks 1 Hz, **freezes on a winning
  claim** (`showWin` → `stopTimer`). Win panel adds rating
  (`Optimal run` when hops≤par and no wrong claims, else `+N over
  par` / wrong-claim count - the successful claim is discounted via
  `wrong = max(0, S.claims-1)`), `(par P)` on hops, and `Time`.
  In-memory only - NOT persisted (no leaderboard; that tier was the
  explicitly-optional part and would need a storage key). Verified
  headless: par renders, clock ticks then freezes, win panel
  par/time/rating correct incl. the optimal-run case, no JS errors.
- **U3 (map label legibility cleanup) SHIPPED.** Four parts, all in
  `draw()`, no generation change: (1) host-label overlap eased - the
  `hasDown` name-push is now gated on multi-homed (single-NIC labels
  hug the icon, no spurious downward shove); (2) bare single-NIC
  `eth*` port tag removed (kept the bubble dot + the U2 multi-homed
  `ethN: ip`); (3) **dynamic 8-slot LAN-label placement** (`LBpos`,
  natural-width block, least-overlap of `TC TL TR LM RM BC BL BR`,
  top-preferred, drawn last/haloed in its own pass) - fixes the old
  bridge-on-top-edge title-band collision; (4) "Network #" prefix
  dropped, bare CIDR shown. Verified headless v4+v6, single-LAN +
  multi-LAN/bridge + full Brutal map (70 LANs/64 hosts): no JS errors,
  U2 bubbles intact, labels clear. Earlier same-pass tweaks:
  LAN-label font capped at `15*F` (1.25× hostname); "Golden
  Machine"→"Gold Machine" button; admin pw moved to the "Admin ▸"
  popout; `.xyz` dropped from the HO1 TLD pool.
- v2 generator verified across 600+ seeds. Favicon + logo + Ohio
  University/ECT attribution in.
- **N3 (variable-size subnets) SHIPPED.** Per-difficulty `cfg.prefixes`
  pool; address-less LANs + post-topology `assignAddresses()` on its own
  RNG stream (`^0x5BD1E995`); prefix clamped to fit NIC count; non-
  overlapping aligned bases; `ipReachesFrom`/`lanFromArg` made prefix-
  aware (no more hardcoded /24 in ping/ssh/nmap). Verified headless
  across 400 combos (80 codes × 5 levels): deterministic per code+level,
  zero subnet overlap, zero capacity overflow, every NIC IP inside its
  subnet, globally unique, Casual all-/24, topology unchanged
  (`dist(start,golden)==diameter`). Eyeballed: `/29`/`/24` render with
  real CIDR and stay reachable. UI polish pass also landed (soft-light
  chrome, sans-serif, distinct dark terminal window).
- **N4 (full IPv4 range) SHIPPED.** `assignAddresses()` base picker
  replaced with a single full-range aligned draw (first octet 1..223)
  plus a `RESV` exclusion (0/8, 127/8, 169.254/16, 224/4, 240/4); the
  old 10/172.16/192.168 and 10/8-carve branches are gone. Public-
  looking nets now dominate (RFC1918 still occurs naturally). Verified
  headless across the same 400 combos: all N3 invariants hold, plus
  zero subnet or host IP in reserved space; address spread broadened
  (≈118 RFC1918 vs ≈22.9k public-range nets). Eyeballed: a public `/24`
  and `/29` render and stay reachable, no JS errors.
- **N1 (IPv6) SHIPPED.** `HB2-` code = IPv6 world (HB1- = IPv4, the
  unchanged default). Parallel BigInt v6 helpers; `assignAddresses`
  v6 branch = unique ULA /64 per LAN, `::1/128` lo; family-aware
  `netOf`/`sameLan`/`cidrStr`/hint/`ip a`/nmap/ping/ssh/`lanFromArg`;
  `gFamily` in-memory toggle (menu) only steers NEW codes; family
  derived from code prefix on load/boot/hashchange. Verified headless
  400 combos (40 codes × 5 levels × {v4,v6}): IPv4 N3/N4 invariants
  unchanged; IPv6 deterministic per code+level, unique /64s, every NIC
  in its /64, lo `::1/128`, addresses unique, parser round-trips,
  topology intact, v6 hint clue, same-/64 reachable & cross-/64 not.
  Eyeballed both: v6 ULA `/64` labels + nmap + `HB2-` code render
  clean, v4 unchanged, no JS errors. **KNOWN MINOR:** long v6 strings
  make the under-icon name/IP labels denser (cosmetic; same label-
  density issue noted elsewhere, not a regression - U2 since eased it
  for multi-homed hosts). N5 (dual-stack) is now **SHIPPED** (see
  the IPv6/N5 note above + Status).
- **N6 (`ssh <hostname>`) SHIPPED.** `resolveSshTarget(tok)` unifies
  ssh resolution: IP form works in any world; in **IPv6 worlds** a
  bare hostname also resolves (names are unique via `nameFor`). Name
  resolution does NOT bypass reachability - the host is reachable only
  if one of its eth NICs shares a directly-connected subnet, and you
  land on THAT NIC. v4 worlds still reject hostnames (IP-only,
  unchanged). Verified headless (5 codes × 3 levels): name→exact peer,
  `nmap`+`ssh <name>` hops, far host blocked, v4 `badfmt`, IP path
  unchanged; no JS errors.
- **U1 (only list connected-interface IPs) SHIPPED.** `connNics(h)` =
  eth NICs whose LAN has ≥2 hosts (an "active connection to another
  network object"); `primaryNic(h)` = first connected, else first eth
  (prompt/banner always have an address). Applied to scorebar
  "Address(es)", `mapyou`, `where-am-i`, prompt, banner, ping source,
  admin printouts. **`ip a` OMITS** unconnected NICs entirely (user
  decision - not NO-CARRIER); lo always shown; only shown NICs
  `revealLan`. Reachability/scoring unchanged (display-only). Verified
  24 hosts (v4+v6): unconnected addrs absent everywhere incl. `ip a`
  dev names, connected present, no JS errors.
- **U2 (per-NIC bubble IPs, multi-homed) SHIPPED.** In `draw()`, a host
  with ≥2 *rendered* NICs (`region[nn.lan]`) labels each port bubble
  ENE-style `eth0: <ip>` and drops the under-icon IP (hostname only);
  single-rendered-NIC hosts keep bare `ethN` + under-icon IP. Both the
  port loop and label loop gate on the same `region[nn.lan]` predicate
  so they never disagree. Verified by capturing `ctx2d.fillText` over
  766 hosts (300 multi + 466 single, v4+v6): every multi-homed host
  draws `ethN: ip` per bubble with no bare under-icon IP; singles keep
  bare `ethN` + IP; no JS errors. Eases the N1 long-v6 density. Full
  admin-reveal still shows neighbor-label overlap (pre-existing density
  minor, not a U2 regression).
- **Deterministic LATTICE embedder SHIPPED** (replaces the left→right
  spine embedder, which replaced the old `placeLan`/`S.lanCell` placer -
  all removed). Generator now: interior bidirectional start
  (`G`/`leftArm`/`C`), dead-end network **branches**, **loop** rings
  (rectangle-perimeter, return to originating client), all parameter
  ranges driven by the **5-level difficulty system** (badge cycles
  `Casual/Normal/Hard/Brutal/Extreme`; `gDiff` mixed into seed).
  Embedder: seeded meander walk + loop rectangles + cell→rect +
  `touch()` edge/corner bridge placement (see "Agreed map model").
  **Generator verified across 5×300 seeds (per difficulty)**:
  deterministic per code+level, connected, `dist(start,golden)==G` in
  the level's range (branches/loops add no shortcut), level
  branch/loop counts match spec, Casual boring, left arm a true
  dead-end direction, every branch & ring LAN renders (≥2 hosts), ring
  a real ≥3 cycle reachable only via its client, golden never in a
  ring, greedy descent solvable everywhere. Headless render (Normal):
  start area + difficulty badge clean, no JS errors.
  **NOT yet runtime-verified visually**: the meandering spine, branch,
  and loop *rendering* (needs traversal / Admin ▸ Reveal Network - the
  headless harness only types terminal commands). User to eyeball and
  iterate on layout polish (loop client also has a chain NIC whose LAN
  may not be cell-adjacent → that tie is logical, not a touch).
- **Stacked-host bug FIXED** (user: "two starting points on top of each
  other", HB1-PN9QE4 Casual). Cause: single-homed hosts were placed on
  the square's OUTER corner - shared with touching neighbours, and >4
  collapsed to one corner. Fix: orth-preferred walk + single-homed
  hosts placed in a distinct grid slot INSIDE their own square. Verified
  headless: 15 codes × 5 difficulties = 75 cases, **0 coincident
  embedded points**, no JS errors; HB1-PN9QE4 Casual visually clean.
- **Starter-LAN client overlap FIXED** (user: HB1-PN9QE4 Normal,
  "several clients in the starter lan square are overlapping"). Cause:
  TWO rival in-square layouts - the host you're ON (leaves seen →
  seen-LAN count >1) kept the embedder `inOf` grid while the rest went
  through `draw()`'s discovery-aware `single` packer; also the square
  was sized only for single-NIC dead-ends. Fix: `single` grouping by
  `insideHome` (lone shared LAN, seen-count-independent) so ALL
  non-bridge hosts share ONE packer grid; `sizeShared` uses
  `insideOf` (all non-bridge hosts) so the square grows to fit.
  Verified: 3 codes × 5 difficulties, up to 126 hosts, no JS errors;
  HB1-PN9QE4 Normal/Brutal visually clean. KNOWN MINOR (not the
  reported bug): a bridge whose shared edge is a square's TOP edge sits
  on the LAN title band - cosmetic, separate follow-up.
- Host label two-line gap is `ly + 14*F*lf + 7*F` (full name-line box +
  stroke halo; `textBaseline:"top"`). A flat gap collapsed under the 25%-
  smaller root font + bold current-host label and overlapped the IP - do
  not shrink it back. (Corner labels can still look tight in a downscaled
  full-viewport screenshot at default 0.75 zoom; they are crisp at real
  zoom - that is a screenshot artifact, not a layout bug.)
- Map view: node-link, fixed embedded positions (no ego re-layout).
- Dead code: the generator still computes per-host `pos`/`lanPos` the new map
  ignores; harmless, not yet stripped (offered to remove).
- IPv6 is **implemented** (N1, see Status): `HB2-` codes, ULA /64s,
  family-aware throughout. **N5 SHIPPED:** 3 modes by code prefix -
  `HB1-` pure IPv4, `HB2-` pure IPv6, `HB3-` dual-stack (per-LAN
  family, difficulty-weighted). The top-right `#fambadge` cycles
  all three. No N5 work remains.
- **Admin menu**: hamburger has an **"Admin ▸"** popout (`adminmenu`
  toggles `#adminsub`, `stopPropagation` so the menu stays open).
  `#adminsub.open` is a SIDE FLYOUT (shared `.subcell`/`.subpop`
  pattern, same as Challenges ▸) - `position:absolute; right:100%;
  top:0` anchored to its `.subcell` wrapper, so it pops out to the
  LEFT over the map (does NOT lengthen the main menu; only one
  flyout open at a time). It has
  **"Reveal Path"** (`admin-path`) and **"Reveal Network"**
  (`admin-net`). The passphrase is requested **when the "Admin ▸"
  popout is opened** (not per-item): clicking it calls
  `adminUnlock(onOk)` and the popout only expands from that callback,
  so the option names stay visible to everyone but it only opens once
  unlocked; collapsing it again never re-asks (and `adminUnlock`
  short-circuits on `S.admin`, running `onOk` immediately, so it asks
  exactly once per game). The item handlers still call `adminUnlock`
  defensively (a no-op once unlocked). **U13: `adminUnlock(onOk)` is
  now callback-based (async) and opens a masked-input modal `#adminpw`
  - NOT the old plaintext `prompt()`.** The modal reuses the
  about/sandbox overlay pattern (`.open`, `data-act`/`handleAct`,
  backdrop-click + Esc both via `adminPwClose`, in the focus-steal
  exclusion list); `adminPwSubmit` hashes the entry vs the salted
  FNV-1a `ADMIN_HASH` (`adminHash`, `ADMIN_SALT`, near `seedFromCode`)
  - a wrong passphrase shows an inline `#adminpw-err` and keeps the
  modal open, Cancel/Esc/backdrop drop the pending `_adminPending`
  action. `startGame` clears `_adminPending` + closes the modal.
  Client-side = obscurity only, NOT real auth (static file - masking
  just stops a shoulder-surf / on-screen reveal). Default passphrase
  `ducky`; change by recomputing the hash of `ADMIN_SALT+pass`
  (comment in code documents it). On unlock `S.admin=true` (resets
  per game, not persisted).
  - Reveal Path: `goldenPath()` = greedy descent on `W.dist`/`W.adj`
    (cur→golden); `adminRevealMap()` reveals the route's hosts+LANs
    (re-run each `draw` while `S.admin`); terminal prints the route; map
    draws a gold dashed highlight + rings/tags golden `(GOLDEN)`.
  - Reveal Network: `adminRevealAll()` adds EVERY host + LAN to
    `S.seenHost`/`S.seenLan` - the whole map (all squares + objects).
  - Show Darkweb: `toggleAdminDw()` overlays a purple `$` badge
    (`drawBrokerIcon`) on the CS broker host so the admin can see
    which host unlocks The Darkweb. Mirrors "Show Firewalls"
    (own `S.adminShowDw` flag, right-side green check via the
    same CSS pattern, draw pass 6b-2 right after the flame pass
    so it sits above LAN labels). Also auto-reveals the broker
    host + its LAN on enable so the admin gets one-click discovery
    without needing "Reveal Network" first; prints the broker's
    name + primary IP in the terminal. No-op when CS is off or
    `W.brokerId < 0` (HB4 sandbox).
