# 100 — Harness for skin work

Status: approved 2026-09-30. Built in F0, before any skin file exists.

## 1. What exists (010 §7)

`scripts/dialog_harness/kodi_live.py` + listener
`wcs/devtools/harness_service.py` (ops `ping`, `info`, `builtin`, `sleep`,
`wait`, `screenshot`, `dialog`). Captures are scaled to 1920x1080
(`_normalize_capture`, `TARGET_W/H`), which is lossy at 2560x1440.

## 2. Native captures (decided: Kodi stays at 2560x1440)

- New flag `--native` on `shot`, `open`, `keys`, `window`: keep the
  `TakeScreenshot` frame at its real size (2560x1440), no resize, no
  fullscreen toggling. `--region` then takes **skin coordinates**
  (1920x1080) and the tool converts with the measured factor
  (`gui width / 1920` = 4/3), so chapters keep speaking skin pixels.
- All skin comparisons (fork vs Estuary) use native frames of the same Kodi
  session and resolution → a correct fork gives **0 differing pixels** on
  static screens, no scaling noise.
- Geometry claims ("button at x=…") are converted back to skin pixels in
  reports.
- Update `AGENTS.md` ("GUI 1920x1080 fullscreen") and the `kodi-harness`
  skill to the real 2560x1440 state.

## 3. New commands

| Command | Does |
|---------|------|
| `kodi_live.py skin <estuary\|milionar>` | JSON-RPC `Settings.SetSettingValue lookandfeel.skin`, waits for the "keep skin" `DialogConfirm` and clicks Yes (`SendClick(11)` — Estuary/fork yes button id 11), waits for `Window.IsActive(Home)`, prints the active skin |
| `kodi_live.py reload` | `ReloadSkin()`, waits for Home, then scans new `kodi.log` lines for skin errors (`Skin has invalid include`, `Unable to load`, `Error loading`, `XML error`, `Missing include`) and fails if any |
| `kodi_live.py window <name\|id> [--path P] [--focus ID] [--native]` | `ActivateWindow(<name>[,P,return])`, optional `SetFocus`, settle, capture |
| `kodi_live.py jsonrpc '<json>'` | run a JSON-RPC call inside Kodi (`xbmc.executeJSONRPC`), print the result (new listener op `jsonrpc`) |
| `kodi_live.py log --since-mark` | print `kodi.log` lines since the last mark written by the harness (new op `mark` logs a unique token) |

The listener change needs `kodi_live.py restart`.

## 4. Skin baseline tool `scripts/skin_harness/baseline.py`

- `screens.json`: the screen set (120 §2) — each entry: name, steps (builtins
  / keys / window / plugin action), settle ms, focus, **masks** (regions in
  skin px to ignore: clock, date, weather, random rows) and whether it needs
  playback.
- `baseline.py capture --skin estuary --out out/skin/estuary/` and
  `--skin milionar --out out/skin/milionar/`: switch skin (§3), run every
  screen, capture natively, save `<name>.png` + `info.json` (window id,
  focused control, info labels).
- `baseline.py compare out/skin/estuary out/skin/milionar`: per screen
  `compare_images.py` metrics with masks applied, `side_by_side.png`,
  `diff.png`, one summary table (`report.md`): screen, identical %, differing
  px, bbox, status (`same` / `expected change` / `REGRESSION`). Expected
  changes are declared per screen in `screens.json` (e.g. Home logo bbox).
- `baseline.py difftree`: diffs `resources/skin.milionar/` against the
  `21.3-Omega` tag and fails on any changed file not allowed by 030 §4.

## 5. Determinism

- Clock/date/weather regions are masked. Rows with random content
  (`random_movies.xsp`, `sortby=random`, e.g. Home movies "Random movies" and
  "Movie sets") are masked over their whole row rectangle (header + items),
  measured with `--measure` on the row's list id; the mask is recorded in
  `screens.json`.
- Library content is fixed during a baseline run (no scans; the
  `/Users/chudy/kodi-media` set of 16 movies / 7 series).
- Fanart backgrounds: wait for `!Window.IsActive(busydialog)` and a fixed
  settle (≥ 1500 ms) after focus changes; Estuary crossfades 400 ms.
- For screens that need playback (OSD, seek bar, fullscreen info) use a local
  sample video generated once (`ffmpeg -f lavfi testsrc … 60 s`) in the
  scratch/fixtures folder, not a `.strm` (network).

## 6. Result (F0, 2026-09-30)

- **Native capture proven**: `--native` frames are 2560x1440, unscaled; regions
  are given in skin px and multiplied by 4/3.
- **Commands built**: `kodi_live.py skin | reload | window | jsonrpc | mark |
  log --since-mark` and `--native`; listener ops `jsonrpc`, `mark`, `skin`
  (confirms "keep skin" with `SendClick(11)`), `reload`, `stddialog`.
  `AGENTS.md` and skill `kodi-harness` state the 2560x1440 GUI.
- **Baseline tool**: `scripts/skin_harness/baseline.py capture | compare`
  with `screens.json` (71 screens: Home x7, Videos movies/TV shows x 9 views,
  seasons, episodes, sources, addon listing, info dialogs, standard dialogs,
  settings, playback incl. OSD/seek bar/fullscreen info/subtitles/OSD
  settings, skin settings sections, MyTV, MyCinema, Continue watching,
  premium dialogs, search dialog). Outputs in `scripts/skin_harness/out/`
  (git-ignored). Playback uses a generated ffmpeg fixture (`-g 1`, static
  SMPTE bars, paused at 25 %).
- **Exit check**: Estuary captured twice (full set, 552 s per run, 3-14 s per
  screen): compare -> **71 `same`, 0 differing px outside masks**
  (`out/report/report.md`).
- **What made it deterministic** (keep in mind for fork runs): each screen
  starts from `Dialog.Close(all)` + Home + `SetFocus(9000,0)`; Videos screens
  pin view (`Container.SetViewMode`) and position (`SetFocus(id,pos)`) because
  Kodi remembers both per path; info screens settle 6 s (async cast thumbs);
  notification screen cools down 7 s; progress dialog is closed on reset.
- **Masks in use** (skin px): clock/date top right `[1500,0,1920,110]` on all;
  Home-backdrop screens `[462,850,1920,1080]` (random-movies row, added
  automatically when a screen has no `window` step); info dialogs
  `[1596,158,1602,435]` (cast list edge); keyboard cursor; system info values
  `[460,170,1050,860]`; first row of OSD audio settings (pulsing focus glow).
- **Not covered yet**: Home with an empty library (needs an empty library,
  will be added when the fixture library option exists); the `skin`
  confirm-dialog path was only exercised as a no-op (Estuary -> Estuary),
  it gets its real test in F1 when `skin.milionar` exists.
- `baseline.py difftree` belongs to F1 (it needs the fork).
