diff --git a/.claude/skills/run-firestorm/SKILL.md b/.claude/skills/run-firestorm/SKILL.md new file mode 100644 index 00000000..cceb317f --- /dev/null +++ b/.claude/skills/run-firestorm/SKILL.md @@ -0,0 +1,117 @@ +--- +name: run-firestorm +description: Run, launch, drive, screenshot, or smoke-test BattleTech FireStorm — the game (MW4.exe, fullscreen), the mission editor (MW4Ed2.exe, windowed), or a headless mission-load report. Use for "run the game", "start the editor", "screenshot the app", "verify a mission loads". +--- + +# Run FireStorm (game + mission editor) + +All paths relative to the repo root (`c:\VWE\firestorm`). Everything is driven through +**`.claude\skills\run-firestorm\driver.ps1`** — do not poke the exes directly; the driver +handles working directories, single-instance guards, window discovery, and screenshots. + +**This is the operator's real desktop, not a headless box.** The game takes the primary +display fullscreen (800×600 16-bit). A human may be using the machine — prefer the headless +`report` path or the windowed editor unless you actually need the game UI. + +## Prerequisites + +Already satisfied on this machine: the deployed game at `MW4\`, the editor installed in +place at `Gameleap\mw4\MW4Ed2.exe`, and the per-path `DWM8And16BitMitigation` AppCompat +shims. On a fresh machine, follow `RECOVERY.md` first, then `build-env\deploy-mw4.ps1` and +`build-env\deploy-editor.ps1` (they re-apply the shims — AppCompat is keyed on exe path). + +## Agent path — the driver + +``` +powershell -ExecutionPolicy Bypass -File .claude\skills\run-firestorm\driver.ps1 [args] +``` + +| Command | What it does | +|---|---| +| `status` / `windows` | List FireStorm processes and their top-level windows (how you detect modal dialogs) | +| `report ` | **Headless smoke test**: editor loads the mission hidden, writes `Gameleap\mw4\.report` (entity counts etc.), driver prints it. ~1–2 min. Mission = folder name under `Gameleap\mw4\Content\Missions` | +| `editor-start` | Launch the windowed mission editor; waits (≤180 s) for its UI | +| `game-start` | Launch the fullscreen game; waits for the `BattleTech Firestorm` window | +| `shot [name]` | Screenshot the **primary screen** → `.claude\skills\run-firestorm\shots\.png`, prints mean brightness (≈0 = dead capture) | +| `shotwin [name]` | Screenshot cropped to one app window (foregrounds it first) — use for the editor | +| `click ` | Left-click at screen coords; restores+foregrounds the app first (fullscreen game: screen coords = game coords at 800×600) | +| `stop` | Kill MW4Ed2 / MW4 / MW4pro | + +### Verified flows (each of these ran end-to-end) + +Headless mission check — the fastest "does the content pipeline still work": + +``` +powershell -ExecutionPolicy Bypass -File .claude\skills\run-firestorm\driver.ps1 report Coliseum +``` + +→ `editor exit code: 1` (normal — see Gotchas) and a report: `Total Entities=248`, per-class counts. + +Editor UI + screenshot: + +``` +powershell -ExecutionPolicy Bypass -File .claude\skills\run-firestorm\driver.ps1 editor-start +powershell -ExecutionPolicy Bypass -File .claude\skills\run-firestorm\driver.ps1 shotwin 'Mission Editor' editor-main +powershell -ExecutionPolicy Bypass -File .claude\skills\run-firestorm\driver.ps1 stop +``` + +Game UI + interaction + screenshot (grabs the display — coordinate with the operator): + +``` +powershell -ExecutionPolicy Bypass -File .claude\skills\run-firestorm\driver.ps1 game-start +powershell -ExecutionPolicy Bypass -File .claude\skills\run-firestorm\driver.ps1 shot game-shell +powershell -ExecutionPolicy Bypass -File .claude\skills\run-firestorm\driver.ps1 click 617 64 +powershell -ExecutionPolicy Bypass -File .claude\skills\run-firestorm\driver.ps1 shot game-map-tab +powershell -ExecutionPolicy Bypass -File .claude\skills\run-firestorm\driver.ps1 stop +``` + +The game boots into the **BattleTech Console V5.07** lobby (player roster + Mission/Map +tabs); `click 617 64` switches to the Map tab. + +## Human path + +- Game: run `MW4\MW4.exe` (fullscreen; Alt-Tab minimizes it). +- Editor: `Gameleap\mw4\run-editor.bat`. + +## Build / rebuild (separate concern) + +Code builds via VC6 (`Gameleap\code\mw4\Code\MechWarrior4.dsw`), resources via +`build-env\build-resources.ps1`, deploy via `build-env\deploy-mw4.ps1` — see `CLAUDE.md` +STEP 3/4 and `build-env\RESOURCE-BUILD.md`. Not needed just to run what's deployed. + +## Gotchas (all hit while building this skill) + +- **`-report` always `exit(1)`**, success or not — the editor's code literally ends with + `exit(1)`. Judge success by the `.report` file content (`UnLoadable` = failed load). The + report lands in `Gameleap\mw4\` (the exe's CWD); delete it after reading — don't commit it. +- **The game window title is `BattleTech Firestorm`** — not "MechWarrior", not "MW4". There + is also a second 640×480 helper window `DirectDrawDeviceWnd` (ignore it). +- **Fullscreen game minimizes the instant it loses focus** (fullscreen-exclusive DDraw), and + the desktop reverts to native resolution — a `shot` taken then captures the desktop, not + the game. `click` restores + refocuses and waits ~2.5 s for the mode switch back. +- **Editor window titles randomly carry a `[DDrawCompat] ` prefix** (the compat layer + retitles); enumeration shows both variants. Match titles loosely (`'Mission Editor'`). +- **Both exes are single-instance** (editor via mutex → modal "already running" message box + that hangs automation). The driver refuses to double-launch; `stop` first. +- **Editor startup is slow (~30–60 s)**: it loads/validates the whole content tree (a + "Building Resources..." window appears first) and LAB builds **STOP with a modal dialog on + the first broken content file** — if `editor-start` times out, check `windows` for a STOP + dialog before assuming a hang. +- **Running either app dirties tracked runtime logs** (`Gameleap\mw4\DebugLog.txt`, + `DDrawCompat-MW4Ed2.log` — this repo mirrors everything). `git restore` them afterwards. +- **`driver.ps1` must stay pure ASCII** — PowerShell 5.1 reads BOM-less files as ANSI; a + single em-dash produces "Missing closing '}'" parse errors. +- **Screenshots can capture the operator's desktop** (second monitor, minimized-game + moments). `shots\` is gitignored — never commit them, and `shot` deliberately captures + only the primary screen. + +## Troubleshooting + +- `Game window did not appear within 120 s` but the process exists → the wait pattern didn't + match a retitled window; run `status` to see the real title. +- `report`: "No report file produced" → the editor hit a content STOP before `MakeReport`; + run `editor-start` and `windows` to read the STOP dialog text. +- Driver parse errors mentioning `—` → someone re-saved it with non-ASCII characters; see + Gotchas. +- Game shows `[DDrawCompat] Fatal Error` → DDrawCompat's `ddraw.dll` leaked next to a game + exe; it belongs only in `Gameleap\mw4` for the editor (see `deploy-*.ps1` skip rules). diff --git a/.claude/skills/run-firestorm/driver.ps1 b/.claude/skills/run-firestorm/driver.ps1 new file mode 100644 index 00000000..b8e34695 --- /dev/null +++ b/.claude/skills/run-firestorm/driver.ps1 @@ -0,0 +1,212 @@ +# FireStorm run driver -- launch/drive/screenshot the game (MW4.exe) and mission editor (MW4Ed2.exe). +# Windows PowerShell 5.1. Usage: powershell -ExecutionPolicy Bypass -File driver.ps1 [arg] +# Commands: +# status list running FireStorm processes + their top-level windows +# report HEADLESS: editor loads the mission, writes .report, exits. +# Prints the report. (Editor always exits code 1 by design -- not an error.) +# editor-start launch the mission editor (windowed), wait for its UI to appear +# game-start launch the game (fullscreen 800x600 16-bit -- grabs the display) +# shot [name] screenshot -> shots\.png (whole virtual screen; +# fullscreen-exclusive DDraw has no window rect worth cropping) +# windows enumerate top-level windows (detects the editor's modal dialogs) +# stop kill MW4Ed2 / MW4 / MW4pro +param( + [Parameter(Position = 0)][string]$Command = 'status', + [Parameter(Position = 1)][string]$Arg, + [Parameter(Position = 2)][string]$Arg2 +) +$ErrorActionPreference = 'Stop' + +$RepoRoot = (Get-Item $PSScriptRoot).Parent.Parent.Parent.FullName # ...\.claude\skills\run-firestorm -> repo +$GameDir = Join-Path $RepoRoot 'MW4' +$EditorDir = Join-Path $RepoRoot 'Gameleap\mw4' +$ShotDir = Join-Path $PSScriptRoot 'shots' +$ProcNames = 'MW4Ed2', 'MW4', 'MW4pro' + +Add-Type -AssemblyName System.Drawing +Add-Type -AssemblyName System.Windows.Forms +Add-Type @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +public class FsWin { + [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc cb, IntPtr lp); + [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] static extern int GetWindowText(IntPtr h, StringBuilder s, int n); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr h, out RECT r); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); + [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd); // 9 = SW_RESTORE + [DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra); + public static void Click(int x, int y) { + SetCursorPos(x, y); + System.Threading.Thread.Sleep(150); + mouse_event(0x0002, 0, 0, 0, UIntPtr.Zero); // LEFTDOWN + System.Threading.Thread.Sleep(80); + mouse_event(0x0004, 0, 0, 0, UIntPtr.Zero); // LEFTUP + } + public struct RECT { public int L, T, R, B; } + delegate bool EnumProc(IntPtr h, IntPtr lp); + public class Info { public IntPtr HWnd; public uint Pid; public string Title; public int X, Y, W, H; } + public static List Windows(uint[] pids) { + var found = new List(); + EnumWindows((h, lp) => { + if (!IsWindowVisible(h)) return true; + uint pid; GetWindowThreadProcessId(h, out pid); + if (Array.IndexOf(pids, pid) < 0) return true; + var sb = new StringBuilder(512); GetWindowText(h, sb, 512); + RECT r; GetWindowRect(h, out r); + found.Add(new Info { HWnd = h, Pid = pid, Title = sb.ToString(), + X = r.L, Y = r.T, W = r.R - r.L, H = r.B - r.T }); + return true; + }, IntPtr.Zero); + return found; + } +} +'@ + +function Get-FsProcs { Get-Process -Name $ProcNames -ErrorAction SilentlyContinue } + +function Show-Windows { + $procs = Get-FsProcs + if (-not $procs) { Write-Output 'No FireStorm processes running.'; return } + $pids = [uint32[]]($procs | ForEach-Object Id) + foreach ($w in [FsWin]::Windows($pids)) { + $p = $procs | Where-Object Id -eq $w.Pid | Select-Object -First 1 + Write-Output ("{0,-8} pid={1,-6} {2}x{3} @({4},{5}) '{6}'" -f $p.Name, $w.Pid, $w.W, $w.H, $w.X, $w.Y, $w.Title) + } +} + +function Wait-ForWindow([string]$TitlePattern, [int]$TimeoutSec = 180) { + $sw = [Diagnostics.Stopwatch]::StartNew() + while ($sw.Elapsed.TotalSeconds -lt $TimeoutSec) { + $procs = Get-FsProcs + if ($procs) { + $pids = [uint32[]]($procs | ForEach-Object Id) + $hit = [FsWin]::Windows($pids) | Where-Object { $_.Title -match $TitlePattern } + if ($hit) { return $hit | Select-Object -First 1 } + } + Start-Sleep -Milliseconds 2000 + } + return $null +} + +switch ($Command) { + + 'status' { + $procs = Get-FsProcs + if ($procs) { $procs | Select-Object Name, Id, StartTime | Format-Table -AutoSize | Out-String | Write-Output } + Show-Windows + } + + 'windows' { Show-Windows } + + 'report' { + if (-not $Arg) { throw 'Usage: driver.ps1 report (a folder name under Gameleap\mw4\Content\Missions)' } + if (Get-Process -Name 'MW4Ed2' -ErrorAction SilentlyContinue) { + throw 'MW4Ed2 already running (single-instance mutex would pop a message box). Run: driver.ps1 stop' + } + $reportFile = Join-Path $EditorDir "$Arg.report" + if (Test-Path $reportFile) { Remove-Item $reportFile -Force } + # -report hides all windows, loads the mission, writes .report to the CWD, then exit(1). + $p = Start-Process -FilePath (Join-Path $EditorDir 'MW4Ed2.exe') ` + -ArgumentList "-armorlevel 3 -report $Arg" ` + -WorkingDirectory $EditorDir -PassThru + if (-not $p.WaitForExit(300000)) { $p.Kill(); throw 'report: editor did not exit within 5 min (STOP dialog on broken content?). Killed.' } + Write-Output "editor exit code: $($p.ExitCode) (1 is normal -- it always exit(1)s after -report)" + if (Test-Path $reportFile) { + $lines = Get-Content $reportFile + Write-Output "report: $reportFile ($($lines.Count) lines)" + Write-Output ($lines | Select-Object -First 12) + if ($lines.Count -gt 12) { Write-Output "... ($($lines.Count - 12) more lines)" } + if ($lines -contains 'UnLoadable') { Write-Output '!! Mission failed to load (UnLoadable).' } + } else { + Write-Output "!! No report file produced ($reportFile) -- editor died before MakeReport (content STOP?)." + } + } + + 'editor-start' { + if (Get-Process -Name 'MW4Ed2' -ErrorAction SilentlyContinue) { throw 'MW4Ed2 already running. Run: driver.ps1 stop' } + Start-Process -FilePath (Join-Path $EditorDir 'MW4Ed2.exe') -ArgumentList '-armorlevel 3' -WorkingDirectory $EditorDir | Out-Null + # Editor loads/validates the whole content tree, then presents a modal "Open Mission" dialog. + $w = Wait-ForWindow 'Open Mission|Mission Editor' 180 + if ($w) { Write-Output "Editor up: '$($w.Title)'"; Show-Windows } + else { throw 'Editor window did not appear within 180 s.' } + } + + 'game-start' { + if (Get-Process -Name 'MW4' -ErrorAction SilentlyContinue) { throw 'MW4 already running. Run: driver.ps1 stop' } + Start-Process -FilePath (Join-Path $GameDir 'MW4.exe') -WorkingDirectory $GameDir | Out-Null + # NB: the game window title is 'BattleTech Firestorm' (not MechWarrior/MW4) + $w = Wait-ForWindow 'Firestorm|FireStorm' 120 + if ($w) { Write-Output "Game up: '$($w.Title)' ($($w.W)x$($w.H))"; Show-Windows } + else { throw 'Game window did not appear within 120 s.' } + } + + { $_ -in 'shot', 'shotwin' } { + if (-not (Test-Path $ShotDir)) { New-Item -ItemType Directory $ShotDir | Out-Null } + if ($Command -eq 'shotwin') { + # shotwin [name] -- crop to the first matching app window + if (-not $Arg) { throw 'Usage: driver.ps1 shotwin [name]' } + $procs = Get-FsProcs + if (-not $procs) { throw 'No FireStorm process running.' } + $pids = [uint32[]]($procs | ForEach-Object Id) + $w = [FsWin]::Windows($pids) | Where-Object { $_.Title -match $Arg } | Select-Object -First 1 + if (-not $w) { throw "No window title matches '$Arg' (run: driver.ps1 windows)" } + [FsWin]::SetForegroundWindow($w.HWnd) | Out-Null; Start-Sleep -Milliseconds 400 + $bx = $w.X; $by = $w.Y; $bw = $w.W; $bh = $w.H + $name = if ($Arg2) { $Arg2 } else { Get-Date -Format 'yyyyMMdd-HHmmss' } + } else { + # shot [name] -- primary screen only (the game runs fullscreen on the primary; + # deliberately NOT the whole virtual desktop, to avoid capturing other monitors) + $b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + $bx = $b.X; $by = $b.Y; $bw = $b.Width; $bh = $b.Height + $name = if ($Arg) { $Arg } else { Get-Date -Format 'yyyyMMdd-HHmmss' } + } + $file = Join-Path $ShotDir "$name.png" + $bmp = New-Object System.Drawing.Bitmap($bw, $bh) + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.CopyFromScreen($bx, $by, 0, 0, $bmp.Size) + $g.Dispose() + $bmp.Save($file, [System.Drawing.Imaging.ImageFormat]::Png) + # crude non-blackness check so an agent can tell a dead capture without opening the file + $sum = 0.0; $n = 0 + for ($x = 50; $x -lt $bmp.Width; $x += [Math]::Max(1, [int]($bmp.Width / 16))) { + for ($y = 50; $y -lt $bmp.Height; $y += [Math]::Max(1, [int]($bmp.Height / 16))) { + $c = $bmp.GetPixel($x, $y); $sum += ($c.R + $c.G + $c.B) / 3.0; $n++ + } + } + $bmp.Dispose() + Write-Output ("saved: {0} ({1}x{2}, mean brightness {3:N0}/255)" -f $file, $bw, $bh, ($sum / $n)) + } + + 'click' { + # click -- screen-coordinate left click (game is fullscreen at 0,0, so + # screen coords == game coords at 800x600). Foregrounds the app window first. + if (-not $Arg -or -not $Arg2) { throw 'Usage: driver.ps1 click ' } + $procs = Get-FsProcs + if ($procs) { + $pids = [uint32[]]($procs | ForEach-Object Id) + $w = [FsWin]::Windows($pids) | Where-Object { $_.Title -notmatch 'DirectDrawDeviceWnd' } | Select-Object -First 1 + if ($w) { + # Fullscreen-exclusive DDraw MINIMIZES on focus loss -- must restore before clicking, + # then wait out the display-mode switch back to 800x600. + [FsWin]::ShowWindow($w.HWnd, 9) | Out-Null + [FsWin]::SetForegroundWindow($w.HWnd) | Out-Null + Start-Sleep -Milliseconds 2500 + } + } + [FsWin]::Click([int]$Arg, [int]$Arg2) + Write-Output "clicked ($Arg,$Arg2)" + } + + 'stop' { + $procs = Get-FsProcs + if (-not $procs) { Write-Output 'Nothing to stop.'; break } + $procs | ForEach-Object { Write-Output "killing $($_.Name) pid=$($_.Id)"; Stop-Process -Id $_.Id -Force } + } + + default { throw "Unknown command '$Command'. Commands: status report editor-start game-start shot windows stop" } +} diff --git a/.gitignore b/.gitignore index 3b04cacc..8ff3ec7c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ # # (Intentionally empty. Add entries here only for truly machine-local, non-portable # state you decide you never want mirrored.) + +# run-firestorm skill screenshots (may capture the operator's desktop -- never mirror) +.claude/skills/run-firestorm/shots/