Windows VM in Studio

Community@sunset1896/windows-vm-in-studio
Community skill — not reviewed. It was shared by 落嵛 and has not been checked by Neta. Read SKILL.md below before letting an agent use it.
Use it in Cohub

Paste this into your agent's chat — it fetches the skill and follows it.

Use the skill: windows-vm-in-studio. Run `npx -y @neta-art/neta-search-cli@latest skills use @sunset1896/windows-vm-in-studio --via site` and follow what it prints. Read its complete output, redirecting it to a temporary file first if necessary, and resolve relative paths from the skill directory it prints.
Install from a terminal
npx -y @neta-art/neta-search-cli@latest skills use @sunset1896/windows-vm-in-studio
Summary

Get a real, working Windows desktop living inside your world, operated right from the browser.

  • Builds an actual Windows machine inside your world, not a mockup: real desktop, real apps, real Windows software.
  • Delivers a clickable page where you watch and control the Windows screen live from your browser.
  • Takes hours to set up since it's all running in software, not real hardware, so install and typing are slow.
  • Best for running Windows-only programs, game launchers, or giving visitors a real desktop to poke at.
  • You'll be asked to share a login, type a code, or solve a captcha at a few points along the way.
SKILL.md
# Windows VM in Studio

Build a Windows machine that lives inside this Studio world and is operated from a browser.
Everything here was learned by actually doing it; the traps are real and each one cost hours.

## Read this first — the four laws

These four facts explain almost every failure you will hit. Internalize them before step 1.

1. **There is no GPU.** The VM renders in software. Anything that draws a lot of pixels
   (a heavy web page, a 3D scene, video) can wedge the guest's compositor. Once the compositor
   is wedged, screenshots hang forever and the screen freezes on its last frame — while the VM
   itself is still alive and responsive to network and keyboard. This is the single biggest
   source of "it just stopped working".
2. **There is no KVM.** Everything runs under TCG software emulation. Expect 10–20× slowdown.
   A Windows install is hours, a browser launch is a minute, a click visibly lands after ~10s.
3. **QEMU's input injection is not reliable.** Mouse clicks frequently do not register in the
   guest at all. Keyboard works, but only when sent as `send-key` with explicit qcodes, and
   only into windows that actually hold focus. Plan for keyboard-first automation.
4. **The relay and the VM are separate systems.** The VM can be perfectly alive while the
   screenshot relay is dead. Always check them independently before concluding anything.

## Architecture you are building

```
 creator's browser
        │  tap/drag = click into guest
        ▼
 Cloudflare quick tunnel  ──►  gateway.py :8080   (control page + screendump + input)
                                     │
                                     ▼ QMP socket
                                qemu-system-x86_64 ──► win10.qcow2  (the Windows disk)

 Windows guest:  poller.ps1  ── polls /pending, drives Edge over CDP, uploads screenshots ──► serve.py :8000
```

Two host services, one guest script:

| Piece | Port | Role |
|---|---|---|
| `gateway.py` | 8080 | Public face. Serves the control page, QEMU screendump, input injection. Tunnel points here. |
| `serve.py` | 8000 | Guest-facing. Serves `.ps1` files, queues clicks/text, receives screenshots. |
| `poller.ps1` | — | Runs inside Windows. Drains the queue, drives the browser, uploads JPEG screenshots. |

The guest reaches the host at **`10.0.2.2`** (QEMU user-mode networking).

## Stage 0 — Requirements and environment

Minimum resources. Ask for these before starting; if the world is smaller, warn the creator.

| Resource | Minimum | Notes |
|---|---|---|
| RAM (world) | 4 GB | VM gets 2–2.5 GB. 2 GB VM RAM causes install failures. |
| Disk | 25 GB free | 4 GB ISO + 20 GB disk. Use `/tmp` (local, fast); NFS/overlay disks are too slow for install. |
| CPU | 2 cores | More helps TCG speed linearly. |
| Network | outbound | To fetch the ISO and to run cloudflared. |

Verify, then set up the portable toolchain (no root in this container):

```bash
# check the ground you are standing on
nproc; free -g | head -2; df -h /tmp | tail -1
[ -e /dev/kvm ] && echo "KVM available (fast path)" || echo "no KVM -> TCG software emulation"

# portable toolchain: download .debs, unpack into a private root, no root needed
bash scripts/00-setup-tools.sh
```

`00-setup-tools.sh` pulls the QEMU stack plus helpers and unpacks them into
`tools/root/`, giving you `qemu-system-x86_64`, `qemu-img`, `genisoimage`, `wimlib`, `cabextract`,
`aria2c`, `chntpw` — all runnable with an `LD_LIBRARY_PATH` prefix. Every script in `scripts/`
exports that prefix for you.

It is safe to run repeatedly: if a working toolchain already exists (in this skill, or in a
previous build elsewhere in the world) it reuses it instead of downloading again. Set
`FORCE=1` to rebuild. `scripts/lib/env.sh` performs the same discovery, so the scripts find a
toolchain from an earlier session automatically.

## Stage 1 — Get a Windows image

Two roads. Prefer the first.

**A. Official UUP dump (clean, reproducible, ~4 GB).** Query the public UUP API, download the
package set, and assemble an ISO with wimlib. Full detail in `reference/iso-build.md`.
This is what the working install used: Windows 10 Pro 22H2, x64.

**B. A ready-made ISO** if the creator supplies one. Verify it is bootable and not a stub:

```bash
file win10.iso                      # expect: ISO 9660 CD-ROM filesystem data (DOS/MBR boot sector)
ls -lh win10.iso                    # a real Win10 ISO is 3.5–6 GB
```

## Stage 2 — Build the unattended answer file

`autounattend.xml` makes Setup run with no human input: it partitions the disk, selects the
edition, creates the user, and skips the questions. Generate it from `reference/autounattend.xml`.

```bash
bash scripts/01-make-answer-iso.sh          # produces autounattend.iso
```

**The Joliet trap.** Windows Setup only reads the answer file if it is visible under a
long-filename filesystem. A plain `genisoimage -o` yields `AUTOUNAT.XML` (8.3) and Setup
silently ignores it — you get a normal interactive install and never know why.

```bash
# always build the ISO with Joliet + Rock Ridge
genisoimage -J -R -o autounattend.iso ./answer/
```

## Stage 3 — Install Windows

```bash
bash scripts/02-install.sh      # boots the ISO, runs Setup unattended
```

Expect **2–5 hours** under TCG. Watch progress without a screen using QEMU's own screendump:

```bash
bash scripts/03-snap.sh /tmp/win10/now.png    # QMP screendump -> PNG, see reference/qmp.md
```

The guest's desktop appears in `now.png` when Setup finishes. If the guest reboots into
Setup again, go to Stage 4.

## Stage 4 — Unstick OOBE (the trap that eats days)

A freshly installed Windows can loop forever in the Out-of-Box Experience, showing
"Why did my PC restart?" or bouncing between region/network screens. There are **two**
independent causes; fix both.

**Cause 1 — `ImageState` stuck at `UNDEPLOYABLE`.** Setup marks the image deployable only after
its final pass. If the pass was interrupted, this persists and every boot re-enters OOBE.
Fix offline in WinPE:

```
HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\State\ImageState = IMAGE_STATE_COMPLETE
HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\State\OOBEInProgress = 0
HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\State\SystemSetupInProgress = 0
```

**Cause 2 — the network-account requirement.** Pre-1903 builds without a network connection
block OOBE waiting for a Microsoft account. Set the bypass, then click Next on the restart
screen:

```
HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE\BypassNRO = 1 (DWORD)
```

**Two facts that surprise everyone:**

- `msoobe.exe` **does not exist in `System32`** on current builds. Hunting for it wastes time.
- Once the image is complete, **`msoobe` re-writes `ImageState` back to `UNDEPLOYABLE` when it
  runs**. So if OOBE ever executes again, your offline fix is undone. Fix the cause, do not
  just re-apply the patch.

`scripts/04-winpe-fix.cmd` (run inside WinPE) applies both registry fixes at once.
Full offline-registry technique, including how to mount the real system hive, is in
`reference/winpe-offline-registry.md`.

## Stage 5 — Get usable input

You need to drive the guest before you can build anything else in it.

**Keyboard — this works.** Use `send-key` with explicit qcodes, one key at a time:

```bash
# via QMP, wrapped by the gateway:
curl -s -X POST -H 'Content-Type: application/json' \
  -d '{"type":"sendkey","keys":["meta_l","r"]}' http://127.0.0.1:8080/input
```

Rules that took hours to learn:

- **`sendkey` (a list of keys) is reliable. `input-send-event` (per-key events) is not.**
  After a guest reboot, `input-send-event` may stop reaching the guest entirely; `send-key`
  keeps working. Prefer `send-key` everywhere.
- **Uppercase, and these characters, must be composed explicitly.** `A` is
  `["shift","a"]`; so are `_ : " & * ( ) + ! @ # $ % ^ < > ? ~ { } |`.
  Gateways that pass a bare character silently drop it. `scripts/lib/sendkey-typing.py`
  handles the whole map.
- **Give the window time.** Open a dialog, then wait 5–7 seconds before typing. Typing into a
  dialog that has not painted yet loses the first characters — the classic symptom is
  `Windows cannot find '/c'` when you sent `cmd /c ...`.
- **Verify head *and* tail of a long command.** The Run box scrolls. Screenshot, press `Home`,
  screenshot again, and confirm both ends before pressing Enter. A truncated command that
  looks fine at the tail is the most common silent failure.

**Mouse — assume it is broken.** Absolute positioning via the `usb-tablet` device works for
*moving* the pointer, but `left` button presses are frequently swallowed by the guest.
Do not build a workflow that depends on clicking. When you must click, prefer, in order:

1. A keyboard path (Tab/Enter, or a command typed into the Run dialog).
2. CDP `Input.dispatchMouseEvent` **inside the guest's browser** — reliable, and the right tool
   for anything on a web page.
3. QEMU `input-send-event` with `abs` + `btn` — last resort, verify every click.

## Stage 6 — Build the browser relay

The point of the whole exercise: let the creator see and operate the guest from a web page.

```bash
python3 scripts/gateway.py &          # :8080, the public face
python3 scripts/serve.py &            # :8000, the guest-facing side
```

Then, inside Windows, start the poller (it drives Edge over CDP and uploads JPEG frames):

```powershell
powershell -ExecutionPolicy Bypass -File C:\Users\<user>\poller.ps1
```

Start it by typing into the Run dialog — the guest has no other remote entry.

**The screenshot contract.** `poller.ps1` calls `Page.captureScreenshot` on the guest's Edge
and POSTs the JPEG to `/uploadshot`. Two hard rules:

- **Every CDP call needs a timeout.** A wedged compositor never answers. Without a deadline the
  script hangs forever on its first frame and the whole relay looks dead while the VM is fine.
  `reference/poller.ps1` wraps every call with a deadline and reports `timeout` instead of
  blocking.
- **Every PowerShell file must be pure ASCII.** PowerShell 5 reads scripts as ANSI, so a
  Chinese character written literally becomes mojibake and breaks parsing. Use `\uXXXX`
  escapes inside JS strings, never literal non-ASCII in the file.

**Choosing the browser mode.** Headful Edge renders into the QEMU framebuffer and is what the
creator sees, but heavy pages can wedge the compositor. `--headless=new` gives a much more
robust screenshot path (no compositor involvement) at the cost of nothing being visible on the
VM's own screen. For automation-driven work prefer headless; for "the creator watches a real
desktop" prefer headful and keep pages light.

```bash
# headful (visible, fragile on heavy pages)
"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" \
  --remote-debugging-port=9222 --user-data-dir=C:\edgecdp https://example.com

# headless (reliable captures, nothing on the VM screen)
"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" \
  --headless=new --remote-debugging-port=9222 --user-data-dir=C:\edgecdp2 https://example.com
```

## Stage 7 — Expose it over Cloudflare

```bash
bash scripts/07-tunnel.sh     # starts cloudflared quick tunnel, prints the public URL
```

Quick-tunnel URLs are ephemeral: **the hostname changes every time cloudflared restarts.**
Whenever it does, re-read the URL from the log and hand the new one to the creator — the old
link is dead the moment the process dies. `scripts/07-tunnel.sh` prints the current URL and
writes it to `tunnel.url`.

Hand the URL over through the **interaction-components** skill (`kind: link`,
`target.type: external-url`) so the creator gets a tappable card.

## Stage 8 — Keep it alive, and know its limits

The VM lives in `/tmp` and will vanish when the world restarts. Persist the disk:

```bash
bash scripts/08-persist.sh     # copy qcow2 into the world's persistent folder
```

Restore after a restart with `scripts/09-restore.sh`.

**Checklist before you claim success:**

- [ ] `qemu-system-x86_64` running, QMP socket answering
- [ ] gateway `:8080/status` returns ok
- [ ] `shot.jpg` mtime advancing (this is the real liveness signal — a static mtime means a
      wedged compositor, not a dead VM)
- [ ] tunnel URL returns HTTP 200 from outside
- [ ] the control page shows the desktop, and a tap moves the guest pointer

Check them in that order; the first failure is your actual problem.

## Failure catalog

Every entry is a real failure from a real build. Match the symptom, apply the fix.

| Symptom | Cause | Fix |
|---|---|---|
| Install reboots into Setup forever | `ImageState=UNDEPLOYABLE` | Stage 4, cause 1 |
| OOBE loops on "Why did my PC restart?" | network requirement, pre-1903 | Stage 4, cause 2 (`BypassNRO` + click Next) |
| Answer file ignored, install is interactive | ISO lacks Joliet | rebuild with `-J -R` |
| `msoobe.exe` not found | it is not in System32 | do not look for it; fix `ImageState` |
| OOBE fix reverts after a while | `msoobe` rewrites `ImageState` | fix the cause, not the symptom |
| Disk not found by Setup | disk on a slow/NFS mount | move the qcow2 to `/tmp` |
| Install fails midway | VM RAM too low | give the VM ≥ 2.5 GB |
| `e1000e` device hangs at PnP | e1000e under TCG | use `rtl8139` with `romfile=` |
| Guest has no network | wrong NIC model | `-device rtl8139,netdev=n0,romfile=` |
| Typed command loses its first characters | typed before the dialog painted | wait 5–7 s, verify head and tail |
| `Windows cannot find '/c'` | the `cmd ` prefix was dropped | same as above; retype and verify the head |
| Download lands as 0 or 1 byte | guest `curl` raced the server flush | re-download and `dir` it; assert the byte count |
| Uppercase or `:` `_` `"` `,` missing from typed text | gateway did not compose the shift | use the shift map in `sendkey-typing.py` |
| Clicks never register | QEMU mouse injection into guest | use CDP for web pages, keyboard elsewhere |
| Screenshot relay stops, VM still pingable | wedged guest compositor | Ctrl+Alt+Del via `send-key` to repaint; or restart the VM |
| First screenshot never returns, poller silent | CDP call without a timeout | give every CDP call a deadline |
| PowerShell script does nothing, no error | file downloaded as 0 bytes | re-download, verify size |
| PowerShell script errors on a Chinese string | PS5 reads files as ANSI | keep the file pure ASCII, use `\uXXXX` |
| Tunnel URL suddenly dead | cloudflared restarted | re-read the URL, hand over the new one |
| VM gone after a world restart | `/tmp` cleared | Stage 8 restore |
| Guest clock jumps days | host/guest time drift under TCG | re-sync; do not trust guest timestamps |
| Whole world feels sluggish | TCG pegging both cores | expect it; reduce VM vCPUs if the host needs headroom |

## Working with the creator while this runs

This build is measured in hours and has long silent stretches. Keep the creator oriented:

- Say what stage you are in and what "done" looks like, not which command you ran.
- When you need them (a CAPTCHA, an SMS code, a login tap), say exactly what to do and stop.
- Never claim success from a plan; claim it only after the checklist in Stage 8 passes.
- If the compositor wedges twice, say so plainly and offer the alternatives in
  `reference/alternatives.md` instead of grinding.

## Reference material

- `reference/iso-build.md` — UUP dump → ISO, end to end
- `reference/winpe-offline-registry.md` — mount a hive, edit it, put it back
- `reference/qmp.md` — screendump, keys, input events, snapshots
- `reference/poller.ps1` — annotated working poller (pure ASCII, CDP with timeouts)
- `reference/alternatives.md` — when this is the wrong tool (real KVM host, real Windows box,
  native RDP/VNC services)