OXYGENHUB
◆ main enhanced-x16-emulator / build.ps1
16.2 KiB · last changed chore(): initial commit 25 days ago source blame history raw
1
<#
2
.SYNOPSIS
3
    Configure, build, test and run the X16Emu ADD emulator on Windows.
4
5
.DESCRIPTION
6
    Wraps the MSVC/CMake/vcpkg build so the common jobs are one word instead of
7
    a remembered incantation. It works out which Visual Studio is installed,
8
    which CMake can actually drive that generator, and where vcpkg lives, then
9
    picks the matching preset from CMakePresets.json.
10
11
    The discovery matters more than the typing it saves. The system CMake on
12
    PATH is often older than the installed Visual Studio and does not know its
13
    generator, so `cmake --preset vs2026` fails with a wall of generator names
14
    that does not mention the real problem. Visual Studio ships its own CMake
15
    that always knows, so this prefers that one.
16
17
.EXAMPLE
18
    .\build.ps1
19
    Debug build of the emulator.
20
21
.EXAMPLE
22
    .\build.ps1 -Config Release
23
    Release build.
24
25
.EXAMPLE
26
    .\build.ps1 -Tests
27
    Build and run the unit test suite.
28
29
.EXAMPLE
30
    .\build.ps1 -DapTest -FetchRom
31
    Build, download a ROM, boot the emulator and run the DAP testbench against it.
32
33
.EXAMPLE
34
    .\build.ps1 -Dbg
35
    Build tools/x16dbg, the .NET command-line DAP client.
36
37
.EXAMPLE
38
    .\build.ps1 -Run -EmuArgs '-prg','MYPROG.PRG','-run'
39
    Build, then launch the emulator with extra arguments passed through to it.
40
#>
41
42
[CmdletBinding()]
43
param(
44
    # Build configuration.
45
    [ValidateSet('Debug', 'Release')]
46
    [string]$Config = 'Debug',
47
48
    # A specific CMake target instead of the emulator, e.g. joystick_test.
49
    [string]$Target,
50
51
    # Build and run the unit test suite (implies -DBUILD_TESTS=ON).
52
    [switch]$Tests,
53
54
    # Launch the emulator when the build succeeds.
55
    [switch]$Run,
56
57
    # Boot the emulator and run testbench/test_dap.py against it.
58
    [switch]$DapTest,
59
60
    # Path to rom.bin. Otherwise the usual places are searched.
61
    [string]$Rom,
62
63
    # Download rom.bin from X16Community/x16-rom with gh, as CI does.
64
    [switch]$FetchRom,
65
66
    # Re-run the configure step even when the build tree is already set up.
67
    [switch]$Reconfigure,
68
69
    # Delete the build tree first.
70
    [switch]$Clean,
71
72
    # Stop a running emulator that is holding x16emu.exe open.
73
    [switch]$Force,
74
75
    # Override the CMakePresets.json preset (vs2022, vs2026, ninja).
76
    [string]$Preset,
77
78
    # DAP port for -Run and -DapTest. 0 picks a free one.
79
    [int]$Port = 0,
80
81
    # Seconds to let the machine reach BASIC before -DapTest starts asserting.
82
    [int]$BootWait = 25,
83
84
    # Build tools/x16dbg, the .NET DAP client, instead of the emulator.
85
    [switch]$Dbg,
86
87
    # Extra arguments passed straight to x16emu, e.g. -EmuArgs '-prg','A.PRG','-run'.
88
    # Named rather than trailing, because a bare -run would collide with this
89
    # script's own -Run switch and PowerShell would bind it here instead.
90
    [Parameter(ValueFromRemainingArguments = $true)]
91
    [string[]]$EmuArgs
92
)
93
94
$ErrorActionPreference = 'Stop'
95
Set-StrictMode -Version Latest
96
97
$RepoRoot = $PSScriptRoot
98
$RomDir = Join-Path $RepoRoot 'latest_rom'
99
100
function Write-Step { param([string]$m) Write-Host "==> $m" -ForegroundColor Cyan }
101
function Write-Note { param([string]$m) Write-Host "    $m" -ForegroundColor DarkGray }
102
function Fail { param([string]$m) Write-Host "!!! $m" -ForegroundColor Red; exit 1 }
103
104
# ─── Toolchain discovery ────────────────────────────────────────────────────
105
106
function Find-VisualStudio {
107
    $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
108
    if (-not (Test-Path $vswhere)) {
109
        Fail "vswhere.exe not found. Install Visual Studio with the C++ workload."
110
    }
111
112
    # -latest alone can pick an install without the C++ tools, which then fails
113
    # much later with a compiler error rather than an obvious one here.
114
    $path = & $vswhere -latest -products * `
115
        -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 `
116
        -property installationPath 2>$null | Select-Object -First 1
117
    if (-not $path) {
118
        $path = & $vswhere -latest -products * -property installationPath 2>$null | Select-Object -First 1
119
    }
120
    if (-not $path) { Fail "No Visual Studio installation found." }
121
122
    $line = & $vswhere -latest -products * -property catalog_productLineVersion 2>$null | Select-Object -First 1
123
    $version = & $vswhere -latest -products * -property installationVersion 2>$null | Select-Object -First 1
124
    $major = if ("$version" -match '^(\d+)') { $Matches[1] } else { '' }
125
126
    [pscustomobject]@{ Path = $path; ProductLine = "$line"; Major = "$major" }
127
}
128
129
function Find-CMake {
130
    param([string]$VsPath)
131
    # Visual Studio's own CMake always understands its own generator; the one on
132
    # PATH frequently does not, which is the failure this script exists to avoid.
133
    $bundled = Join-Path $VsPath 'Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe'
134
    if (Test-Path $bundled) { return $bundled }
135
    $onPath = Get-Command cmake -ErrorAction SilentlyContinue
136
    if ($onPath) { return $onPath.Source }
137
    Fail "No cmake.exe found, in Visual Studio or on PATH."
138
}
139
140
function Find-CTest {
141
    param([string]$CMakePath)
142
    $ctest = Join-Path (Split-Path $CMakePath) 'ctest.exe'
143
    if (Test-Path $ctest) { return $ctest }
144
    $onPath = Get-Command ctest -ErrorAction SilentlyContinue
145
    if ($onPath) { return $onPath.Source }
146
    Fail "No ctest.exe found beside cmake or on PATH."
147
}
148
149
function Find-VcpkgRoot {
150
    param([string]$VsPath)
151
    # The presets reference $env:VCPKG_ROOT in their toolchain file, so this has
152
    # to end up in the environment whether or not the user has set it.
153
    $candidates = @(
154
        $env:VCPKG_ROOT,
155
        $env:VCPKG_INSTALLATION_ROOT,
156
        (Join-Path $VsPath 'VC\vcpkg')
157
    ) | Where-Object { $_ }
158
159
    foreach ($c in $candidates) {
160
        if (Test-Path (Join-Path $c 'scripts\buildsystems\vcpkg.cmake')) { return $c }
161
    }
162
    Fail "No vcpkg found. Set VCPKG_ROOT, or install the vcpkg component with Visual Studio."
163
}
164
165
function Resolve-Preset {
166
    param([string]$Major)
167
    if ($Preset) { return $Preset }
168
169
    # Match the installed Visual Studio against the preset generators rather than
170
    # hardcoding a version table. vswhere reports the internal major version (17
171
    # for 2022, 18 for 2026) and the generator names carry the same number, so a
172
    # preset added for a future Visual Studio is picked up without touching this.
173
    $presetFile = Join-Path $RepoRoot 'CMakePresets.json'
174
    if (Test-Path $presetFile) {
175
        $presets = (Get-Content $presetFile -Raw | ConvertFrom-Json).configurePresets
176
        $match = $presets | Where-Object {
177
            $_.PSObject.Properties.Name -contains 'generator' -and
178
            $_.generator -match "^Visual Studio $Major "
179
        } | Select-Object -First 1
180
        if ($match) { return $match.name }
181
    }
182
183
    Fail ("No CMakePresets.json preset matches Visual Studio major version $Major. " +
184
          "Add one, or pass -Preset <name>.")
185
}
186
187
# ─── ROM handling ───────────────────────────────────────────────────────────
188
189
function Resolve-Rom {
190
    if ($Rom) {
191
        if (-not (Test-Path $Rom)) { Fail "ROM not found: $Rom" }
192
        return (Resolve-Path $Rom).Path
193
    }
194
195
    # Built one at a time rather than as a list expression: Join-Path throws on a
196
    # null base, so an unset X16_ROM_DIR would take the whole lookup down before
197
    # the emptiness filter ever ran.
198
    $places = New-Object System.Collections.Generic.List[string]
199
    if ($env:X16_ROM) { $places.Add($env:X16_ROM) }
200
    $places.Add((Join-Path $RomDir 'rom.bin'))
201
    $places.Add((Join-Path $RepoRoot 'rom.bin'))
202
    if ($env:X16_ROM_DIR) { $places.Add((Join-Path $env:X16_ROM_DIR 'rom.bin')) }
203
204
    foreach ($p in $places) {
205
        if (Test-Path $p) { return (Resolve-Path $p).Path }
206
    }
207
    return $null
208
}
209
210
function Get-Rom {
211
    # Mirrors .github/actions/fetch-rom: prefer the release asset, which never
212
    # expires, over a workflow artifact that does.
213
    if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
214
        Fail "-FetchRom needs the GitHub CLI (gh) on PATH."
215
    }
216
    Write-Step "Fetching rom.bin from X16Community/x16-rom"
217
    $staging = Join-Path $RomDir 'zip'
218
    New-Item -ItemType Directory -Force $RomDir | Out-Null
219
    New-Item -ItemType Directory -Force $staging | Out-Null
220
    & gh release download -R X16Community/x16-rom --pattern '*.zip' --dir $staging --clobber
221
    if ($LASTEXITCODE -ne 0) { Fail "gh release download failed." }
222
    $zip = Get-ChildItem (Join-Path $staging '*.zip') | Select-Object -First 1
223
    if (-not $zip) { Fail "No zip downloaded from x16-rom." }
224
    Expand-Archive -Path $zip.FullName -DestinationPath $RomDir -Force
225
    $rom = Join-Path $RomDir 'rom.bin'
226
    if (-not (Test-Path $rom)) { Fail "rom.bin missing from the downloaded archive." }
227
    Write-Note "ROM at $rom"
228
    return $rom
229
}
230
231
# ─── Process and port helpers ───────────────────────────────────────────────
232
233
function Stop-RunningEmulator {
234
    param([string]$ExePath)
235
    # A running emulator holds its own .exe open and the link step fails with a
236
    # bare LNK1104 that says nothing about why.
237
    $running = Get-Process -ErrorAction SilentlyContinue |
238
        Where-Object { $_.Path -and $_.Path -ieq $ExePath }
239
    if (-not $running) { return }
240
241
    if (-not $Force) {
242
        Write-Host "!!! x16emu.exe is running (PID $($running.Id -join ', ')) and will block the link step." -ForegroundColor Yellow
243
        Write-Host "    Close it, or re-run with -Force." -ForegroundColor Yellow
244
        exit 1
245
    }
246
    foreach ($p in $running) {
247
        Write-Note "Stopping running emulator (PID $($p.Id))"
248
        Stop-Process -Id $p.Id -Force
249
    }
250
    Start-Sleep -Milliseconds 500
251
}
252
253
function Get-FreePort {
254
    if ($Port -ne 0) { return $Port }
255
    $l = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
256
    $l.Start()
257
    $p = $l.LocalEndpoint.Port
258
    $l.Stop()
259
    return $p
260
}
261
262
function Wait-ForPort {
263
    param([int]$P, [int]$TimeoutSec = 60)
264
    $deadline = (Get-Date).AddSeconds($TimeoutSec)
265
    while ((Get-Date) -lt $deadline) {
266
        try {
267
            $c = [System.Net.Sockets.TcpClient]::new()
268
            $c.Connect('127.0.0.1', $P)
269
            $c.Close()
270
            return $true
271
        } catch {
272
            Start-Sleep -Milliseconds 250
273
        }
274
    }
275
    return $false
276
}
277
278
# ─── Main ───────────────────────────────────────────────────────────────────
279
280
$vs = Find-VisualStudio
281
$cmake = Find-CMake -VsPath $vs.Path
282
$ctest = Find-CTest -CMakePath $cmake
283
$vcpkg = Find-VcpkgRoot -VsPath $vs.Path
284
$presetName = Resolve-Preset -Major $vs.Major
285
$buildDir = Join-Path $RepoRoot "out\build\$presetName"
286
287
$env:VCPKG_ROOT = $vcpkg
288
289
Write-Step "Toolchain"
290
Write-Note "Visual Studio : $($vs.Path)"
291
Write-Note "CMake         : $cmake"
292
Write-Note "vcpkg         : $vcpkg"
293
Write-Note "Preset        : $presetName ($Config)"
294
295
if ($Clean -and (Test-Path $buildDir)) {
296
    Write-Step "Removing $buildDir"
297
    Remove-Item -Recurse -Force $buildDir
298
}
299
300
# x16dbg is a .NET project, so it sits outside the CMake build entirely.
301
if ($Dbg) {
302
    if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
303
        Fail "-Dbg needs the .NET SDK (dotnet) on PATH."
304
    }
305
    $dbgDir = Join-Path $RepoRoot 'tools\x16dbg'
306
    if (-not (Test-Path $dbgDir)) { Fail "tools\x16dbg not found." }
307
308
    Write-Step "Building tools/x16dbg ($Config)"
309
    & dotnet build $dbgDir -c $Config --nologo
310
    if ($LASTEXITCODE -ne 0) { Fail "x16dbg build failed." }
311
    Write-Step "Done"
312
    Write-Note "Run it with: dotnet run --project tools/x16dbg -- <command>"
313
    exit 0
314
}
315
316
# Reconfigure when the cache is missing, when asked, or when the BUILD_TESTS
317
# setting no longer matches what was asked for -- otherwise -Tests on a tree
318
# configured without them silently builds nothing and ctest reports an empty run.
319
# A named *_test target needs the same option, or the build fails claiming the
320
# target does not exist when the truth is that it was never declared.
321
$wantsTests = $Tests -or ($Target -and $Target -match '_test$')
322
323
$cache = Join-Path $buildDir 'CMakeCache.txt'
324
$needConfigure = $Reconfigure -or -not (Test-Path $cache)
325
if (-not $needConfigure -and $wantsTests) {
326
    $cached = Select-String -Path $cache -Pattern '^BUILD_TESTS:BOOL=(.*)$' -ErrorAction SilentlyContinue
327
    if (-not $cached -or $cached.Matches[0].Groups[1].Value -ne 'ON') { $needConfigure = $true }
328
}
329
330
if ($needConfigure) {
331
    Write-Step "Configuring"
332
    $cfgArgs = @('--preset', $presetName)
333
    if ($wantsTests) { $cfgArgs += '-DBUILD_TESTS=ON' }
334
    & $cmake @cfgArgs
335
    if ($LASTEXITCODE -ne 0) { Fail "Configure failed." }
336
}
337
338
$exePath = Join-Path $buildDir "$Config\x16emu.exe"
339
340
$targets = @()
341
if ($Target) { $targets += $Target }
342
elseif ($Tests) { $targets += 'unit_tests' }
343
else { $targets += 'x16emu' }
344
345
if ($targets -contains 'x16emu' -or $Run -or $DapTest) {
346
    if ($targets -notcontains 'x16emu') { $targets += 'x16emu' }
347
    Stop-RunningEmulator -ExePath $exePath
348
}
349
350
Write-Step "Building $($targets -join ', ') ($Config)"
351
& $cmake --build $buildDir --config $Config --target @targets
352
$buildStatus = $LASTEXITCODE
353
354
if ($buildStatus -ne 0) {
355
    # -Tests is expected to be partial on MSVC: several pre-existing test files
356
    # define int main(void) while SDL's headers have already redefined main to
357
    # SDL_main, which is C4026 and fatal under /WX. Report it rather than
358
    # pretending the whole build failed, so the tests that do build still run.
359
    if ($Tests) {
360
        Write-Host "!!! Some test targets failed to build; running the ones that did." -ForegroundColor Yellow
361
    } else {
362
        Fail "Build failed."
363
    }
364
}
365
366
if (-not $Tests -and -not $Run -and -not $DapTest) {
367
    Write-Step "Done"
368
    if (Test-Path $exePath) { Write-Note $exePath }
369
    exit 0
370
}
371
372
if ($Tests) {
373
    Write-Step "Running unit tests"
374
    Push-Location $buildDir
375
    try {
376
        & $ctest -C $Config --output-on-failure
377
        $testStatus = $LASTEXITCODE
378
    } finally {
379
        Pop-Location
380
    }
381
    if ($testStatus -ne 0) {
382
        Write-Host "!!! ctest reported failures (see above)." -ForegroundColor Yellow
383
        exit $testStatus
384
    }
385
    Write-Step "Unit tests passed"
386
    exit 0
387
}
388
389
# Everything below needs a working emulator binary and a ROM.
390
if (-not (Test-Path $exePath)) { Fail "Emulator not found at $exePath" }
391
392
$romPath = if ($FetchRom) { Get-Rom } else { Resolve-Rom }
393
if (-not $romPath) {
394
    Fail "No rom.bin found. Pass -Rom <path>, set X16_ROM, or use -FetchRom."
395
}
396
397
if ($Run) {
398
    $runArgs = @('-rom', $romPath)
399
    if ($Port -ne 0) { $runArgs += @('-debugport', "$Port") }
400
    if ($EmuArgs) { $runArgs += $EmuArgs }
401
    Write-Step "Running x16emu"
402
    Write-Note "$exePath $($runArgs -join ' ')"
403
    & $exePath @runArgs
404
    exit $LASTEXITCODE
405
}
406
407
if ($DapTest) {
408
    if (-not (Get-Command python -ErrorAction SilentlyContinue)) {
409
        Fail "-DapTest needs python on PATH."
410
    }
411
    $dapPort = Get-FreePort
412
    $runArgs = @('-rom', $romPath, '-debugport', "$dapPort", '-warp')
413
    if ($EmuArgs) { $runArgs += $EmuArgs }
414
415
    Write-Step "Booting the emulator on DAP port $dapPort"
416
    $proc = Start-Process -FilePath $exePath -ArgumentList $runArgs -PassThru
417
418
    try {
419
        if (-not (Wait-ForPort -P $dapPort)) { Fail "DAP server never came up on port $dapPort." }
420
        # The port opens long before the machine reaches BASIC, and the testbench
421
        # types into the KERNAL buffer, so it has to wait for the boot as well as
422
        # the socket or the early assertions race the ROM.
423
        Write-Note "Waiting ${BootWait}s for the machine to boot"
424
        Start-Sleep -Seconds $BootWait
425
426
        Write-Step "Running testbench/test_dap.py"
427
        & python (Join-Path $RepoRoot 'testbench\test_dap.py') $dapPort
428
        $dapStatus = $LASTEXITCODE
429
    } finally {
430
        if ($proc -and -not $proc.HasExited) {
431
            Write-Note "Stopping the emulator (PID $($proc.Id))"
432
            Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
433
        }
434
    }
435
436
    if ($dapStatus -ne 0) { Fail "DAP testbench reported failures." }
437
    Write-Step "DAP testbench passed"
438
    exit 0
439
}