diff --git a/.github/workflows/build-modern.yml b/.github/workflows/build-modern.yml new file mode 100644 index 00000000..73fe2dbd --- /dev/null +++ b/.github/workflows/build-modern.yml @@ -0,0 +1,70 @@ +name: Build unofficial modern clients + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/build-modern.yml" + - "scripts/**" + - "v1.0-pre-modern/**" + - "v2.5-beta-1-modern/**" + - "artifacts/inc/**" + - "artifacts/lib/i386/zlib.lib" + +permissions: + contents: read + +jobs: + build: + name: Build, package, and smoke test + runs-on: windows-2022 + timeout-minutes: 30 + + steps: + - name: Check out source + uses: actions/checkout@v5 + + - name: Build both modernized clients + shell: cmd + run: | + for /f "usebackq delims=" %%I in (`"%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSINSTALL=%%I" + if not defined VSINSTALL ( + echo No Visual Studio C++ installation was found. + exit /b 1 + ) + call "%VSINSTALL%\VC\Auxiliary\Build\vcvars32.bat" + if errorlevel 1 exit /b 1 + if not exist "%VCToolsInstallDir%ATLMFC\lib\spectre\x86" ( + echo The x86 Spectre-mitigated MFC libraries are missing. + exit /b 1 + ) + + if exist "v1.0-pre-modern\Release" rmdir /s /q "v1.0-pre-modern\Release" + pushd "v1.0-pre-modern" + nmake /f chat.mak CFG="chat - Win32 Release" + if errorlevel 1 exit /b 1 + popd + + if exist "v2.5-beta-1-modern\Release" rmdir /s /q "v2.5-beta-1-modern\Release" + pushd "v2.5-beta-1-modern" + nmake /f chat.mak CFG="chat - Win32 Release" + if errorlevel 1 exit /b 1 + popd + + - name: Create archival ZIP files + shell: pwsh + run: ./scripts/package-modern-builds.ps1 -SourceRepositoryUrl "${{ github.server_url }}/${{ github.repository }}" + + - name: Smoke test from random folders + shell: pwsh + run: ./scripts/smoke-test-modern-builds.ps1 + + - name: Upload release-ready packages + uses: actions/upload-artifact@v4 + with: + name: comic-chat-unofficial-modern-builds-${{ github.sha }} + path: | + out/modern-builds/*.zip + out/modern-builds/SHA256SUMS.txt + if-no-files-found: error + retention-days: 90 diff --git a/README.md b/README.md index a5515ccd..f4d37cdc 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,16 @@ nmake /f chat.mak CFG="chat - Win32 Debug" REM asserts + TRACE for DebugVie It carries the mouse-wheel and panels-per-row work across and runs **DPI-unaware** so Windows scales the whole window uniformly (rather than scaling a few surfaces and leaving the rest tiny). The chief 2.5-specific fixes were dropping the MFC-4.0 common-control struct-tag remap, adding a Common Controls v6 manifest so the rebar toolbar creates, and the runtime fixes needed to connect/join/chat on a present-day IRC network. See [`v2.5-beta-1-modern/README.md`](v2.5-beta-1-modern/README.md). +### Cloud builds + +The manually triggered **Build unofficial modern clients** GitHub Actions +workflow builds both modernized clients on a pinned Windows runner, packages +each executable with its art and documentation, smoke-tests the extracted ZIPs +from random folders, and uploads release-ready artifacts with SHA-256 hashes. +Fork owners can enable Actions and run the workflow without any secrets. See +[`docs/UNOFFICIAL-RELEASE.md`](docs/UNOFFICIAL-RELEASE.md) for the packaging and +manual release process. + ### A note on the modernized folders This repository is published primarily as a **historical artifact** — the source is here for reference, study, and preservation, not as a maintained product. The `*-modern` folders are **not** a polished re-release; they're **worked examples** of the kinds of changes it takes to get a 1996–1998 MFC application building and running on a current machine, such as: diff --git a/docs/UNOFFICIAL-RELEASE.md b/docs/UNOFFICIAL-RELEASE.md new file mode 100644 index 00000000..e493ac64 --- /dev/null +++ b/docs/UNOFFICIAL-RELEASE.md @@ -0,0 +1,39 @@ +# Creating an unofficial modern build release + +The repository's modernized clients are historical worked examples, not official +product releases. GitHub Actions can produce two unsigned, portable ZIP files for +archivists, educators, and people experimenting with forks. + +## Build the packages + +1. Open the repository's **Actions** tab. +2. Select **Build unofficial modern clients**. +3. Choose **Run workflow** on the commit to package. +4. Download the `comic-chat-unofficial-modern-builds-*` artifact after the job + finishes. + +The artifact contains: + +- `ComicChat-1.0-pre-unofficial-modern.zip` +- `ComicChat-2.5-beta-1-unofficial-modern.zip` +- `SHA256SUMS.txt` + +Each ZIP contains a statically linked x86 executable, its bundled `ComicArt` +directory, the original help file, documentation, the repository license, and a +provenance notice. The workflow extracts each ZIP into a random path containing +spaces, launches it with an unrelated working directory, and verifies that it +remains running. + +## Publish the manual GitHub release + +1. Draft a new release with a date-based tag such as + `unofficial-modern-builds-YYYY-MM`. +2. Use a title such as **Unofficial modern builds - Month YYYY**. +3. Mark the release as a **pre-release**. +4. Attach both ZIP files and `SHA256SUMS.txt`. +5. Identify the source commit and state that the executables are unsigned, + unsupported archival builds rather than an official Microsoft product + release. + +No installer or code-signing material is required. Users should extract an +entire ZIP before running the executable so `ComicArt` remains beside it. diff --git a/scripts/package-modern-builds.ps1 b/scripts/package-modern-builds.ps1 new file mode 100644 index 00000000..7fcf4d1c --- /dev/null +++ b/scripts/package-modern-builds.ps1 @@ -0,0 +1,137 @@ +[CmdletBinding()] +param( + [string]$OutputDirectory, + [string]$SourceRevision, + [string]$SourceRepositoryUrl +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +if (-not $OutputDirectory) { + $OutputDirectory = Join-Path $repoRoot 'out\modern-builds' +} +elseif (-not [IO.Path]::IsPathRooted($OutputDirectory)) { + $OutputDirectory = Join-Path $repoRoot $OutputDirectory +} +$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) + +if (-not $SourceRevision) { + $SourceRevision = (& git -C $repoRoot rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0) { + throw 'Could not determine the source revision.' + } +} +if (-not $SourceRepositoryUrl) { + $SourceRepositoryUrl = (& git -C $repoRoot remote get-url origin).Trim() + if ($LASTEXITCODE -ne 0) { + throw 'Could not determine the source repository URL.' + } + if ($SourceRepositoryUrl -match '^git@github\.com:(.+?)(?:\.git)?$') { + $SourceRepositoryUrl = "https://github.com/$($Matches[1])" + } + elseif ($SourceRepositoryUrl.EndsWith('.git')) { + $SourceRepositoryUrl = $SourceRepositoryUrl.Substring(0, $SourceRepositoryUrl.Length - 4) + } +} +$SourceRepositoryUrl = $SourceRepositoryUrl.TrimEnd('/') + +$stagingDirectory = Join-Path $OutputDirectory 'staging' +if (Test-Path -LiteralPath $stagingDirectory) { + Remove-Item -LiteralPath $stagingDirectory -Recurse -Force +} +New-Item -ItemType Directory -Path $stagingDirectory -Force | Out-Null + +$packages = @( + [pscustomobject]@{ + Name = 'ComicChat-1.0-pre-unofficial-modern' + Version = 'Comic Chat 1.0 prerelease (Beta 2), modernized' + Executable = 'v1.0-pre-modern\chat.exe' + Art = 'v1.0-pre-modern\comicart' + Help = 'v1.0-pre-modern\cchat.hlp' + Readme = 'docs\MODERNIZATION.md' + RuntimeFiles = @( + 'v1.0-pre-modern\cchat.cnt' + 'v1.0-pre-modern\log.ept' + 'v1.0-pre-modern\profile.txt' + 'v1.0-pre-modern\readme.gif' + 'v1.0-pre-modern\readme.htm' + 'v1.0-pre-modern\readme.txt' + 'v1.0-pre-modern\titles.txt' + ) + }, + [pscustomobject]@{ + Name = 'ComicChat-2.5-beta-1-unofficial-modern' + Version = 'Comic Chat 2.5 beta 1, modernized' + Executable = 'v2.5-beta-1-modern\Release\CChat.exe' + Art = 'v2.5-beta-1-modern\comicart' + Help = 'v2.5-beta-1-modern\cchat.hlp' + Readme = 'v2.5-beta-1-modern\README.md' + RuntimeFiles = @( + 'v2.5-beta-1-modern\Release\CChat.exe.manifest' + ) + } +) + +foreach ($package in $packages) { + $packageDirectory = Join-Path $stagingDirectory $package.Name + $artDirectory = Join-Path $packageDirectory 'ComicArt' + New-Item -ItemType Directory -Path $artDirectory -Force | Out-Null + + $executable = Join-Path $repoRoot $package.Executable + $artSource = Join-Path $repoRoot $package.Art + $helpFile = Join-Path $repoRoot $package.Help + $readmeFile = Join-Path $repoRoot $package.Readme + foreach ($requiredPath in @($executable, $artSource, $helpFile, $readmeFile)) { + if (-not (Test-Path -LiteralPath $requiredPath)) { + throw "Required package input is missing: $requiredPath" + } + } + + Copy-Item -LiteralPath $executable -Destination $packageDirectory + Copy-Item -Path (Join-Path $artSource '*') -Destination $artDirectory -Recurse + Copy-Item -LiteralPath $helpFile -Destination $packageDirectory + Copy-Item -LiteralPath $readmeFile -Destination (Join-Path $packageDirectory 'README.md') + Copy-Item -LiteralPath (Join-Path $repoRoot 'LICENSE') -Destination (Join-Path $packageDirectory 'LICENSE.txt') + foreach ($runtimeFile in $package.RuntimeFiles) { + $runtimePath = Join-Path $repoRoot $runtimeFile + if (-not (Test-Path -LiteralPath $runtimePath)) { + throw "Required runtime file is missing: $runtimePath" + } + Copy-Item -LiteralPath $runtimePath -Destination $packageDirectory + } + + @( + 'UNOFFICIAL ARCHIVAL BUILD' + '' + $package.Version + '' + 'This is an unsigned, unsupported build of the modernized historical source.' + 'It is not an official Microsoft product release.' + '' + "Source: $SourceRepositoryUrl/tree/$SourceRevision" + "Revision: $SourceRevision" + "Built (UTC): $([DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ'))" + '' + 'There is no installer. Extract the entire directory and run the executable' + 'in place so that the bundled ComicArt directory remains beside it.' + ) | Set-Content -LiteralPath (Join-Path $packageDirectory 'UNOFFICIAL-BUILD.txt') -Encoding ascii + + $zipPath = Join-Path $OutputDirectory "$($package.Name).zip" + if (Test-Path -LiteralPath $zipPath) { + Remove-Item -LiteralPath $zipPath -Force + } + Compress-Archive -LiteralPath $packageDirectory -DestinationPath $zipPath -CompressionLevel Optimal +} + +$hashLines = Get-ChildItem -LiteralPath $OutputDirectory -Filter '*.zip' | + Sort-Object Name | + ForEach-Object { + $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $($_.Name)" + } +$hashLines | Set-Content -LiteralPath (Join-Path $OutputDirectory 'SHA256SUMS.txt') -Encoding ascii + +Remove-Item -LiteralPath $stagingDirectory -Recurse -Force +Write-Host "Created archival packages in $OutputDirectory" diff --git a/scripts/smoke-test-modern-builds.ps1 b/scripts/smoke-test-modern-builds.ps1 new file mode 100644 index 00000000..e8c3331b --- /dev/null +++ b/scripts/smoke-test-modern-builds.ps1 @@ -0,0 +1,99 @@ +[CmdletBinding()] +param( + [string]$PackageDirectory, + [ValidateRange(1, 60)] + [int]$RunSeconds = 8 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +if (-not $PackageDirectory) { + $PackageDirectory = Join-Path $repoRoot 'out\modern-builds' +} +elseif (-not [IO.Path]::IsPathRooted($PackageDirectory)) { + $PackageDirectory = Join-Path $repoRoot $PackageDirectory +} +$PackageDirectory = [IO.Path]::GetFullPath($PackageDirectory) + +$packages = @( + [pscustomobject]@{ + Name = 'ComicChat-1.0-pre-unofficial-modern' + Executable = 'chat.exe' + ArtPatterns = @('ComicArt\Avatars\*.avb', 'ComicArt\Backdrop\*') + }, + [pscustomobject]@{ + Name = 'ComicChat-2.5-beta-1-unofficial-modern' + Executable = 'CChat.exe' + ArtPatterns = @('ComicArt\*.avb', 'ComicArt\*.bgb') + } +) + +$testRoot = Join-Path ([IO.Path]::GetTempPath()) "Comic Chat smoke $([guid]::NewGuid())" +$workingDirectory = Join-Path ([IO.Path]::GetTempPath()) "Unrelated working directory $([guid]::NewGuid())" +New-Item -ItemType Directory -Path $testRoot, $workingDirectory -Force | Out-Null + +try { + foreach ($package in $packages) { + $zipPath = Join-Path $PackageDirectory "$($package.Name).zip" + if (-not (Test-Path -LiteralPath $zipPath)) { + throw "Package is missing: $zipPath" + } + + $extractDirectory = Join-Path $testRoot $package.Name + Expand-Archive -LiteralPath $zipPath -DestinationPath $extractDirectory + $packageRoot = Join-Path $extractDirectory $package.Name + $executable = Join-Path $packageRoot $package.Executable + if (-not (Test-Path -LiteralPath $executable)) { + throw "Packaged executable is missing: $executable" + } + + foreach ($pattern in $package.ArtPatterns) { + if (-not (Get-ChildItem -Path (Join-Path $packageRoot $pattern) -File -ErrorAction SilentlyContinue)) { + throw "Package contains no files matching $pattern" + } + } + + $bytes = [IO.File]::ReadAllBytes($executable) + $peOffset = [BitConverter]::ToInt32($bytes, 0x3c) + $machine = [BitConverter]::ToUInt16($bytes, $peOffset + 4) + if ($machine -ne 0x014c) { + throw ('Expected an x86 PE executable, but {0} has machine type 0x{1:x4}.' -f $executable, $machine) + } + + $process = $null + try { + $process = Start-Process -FilePath $executable -WorkingDirectory $workingDirectory -PassThru + $deadline = [DateTime]::UtcNow.AddSeconds($RunSeconds) + $mainWindow = [IntPtr]::Zero + while ([DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 500 + $process.Refresh() + if ($process.HasExited) { + throw "$($package.Executable) exited during the random-folder smoke test with code $($process.ExitCode)." + } + if ($process.MainWindowHandle -ne [IntPtr]::Zero) { + $mainWindow = $process.MainWindowHandle + } + } + if ($mainWindow -eq [IntPtr]::Zero) { + throw "$($package.Executable) did not create a top-level window during the random-folder smoke test." + } + Write-Host "$($package.Name) displayed a window and remained running for $RunSeconds seconds from $packageRoot" + } + finally { + if ($null -ne $process) { + $process.Refresh() + if (-not $process.HasExited) { + Stop-Process -Id $process.Id + $process.WaitForExit(5000) | Out-Null + } + $process.Dispose() + } + } + } +} +finally { + Remove-Item -LiteralPath $testRoot, $workingDirectory -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/v1.0-pre-modern/avatar.cpp b/v1.0-pre-modern/avatar.cpp index 1d2d2d05..64f122ad 100644 --- a/v1.0-pre-modern/avatar.cpp +++ b/v1.0-pre-modern/avatar.cpp @@ -142,11 +142,11 @@ CPose *GetPoseFromID(unsigned short poseID, BOOL loadMask) { // Nope, we need to construct a pose, and register it CPose *pose = new CPose; - char buff[100]; + CString path; AVFileRec *arec = (AVFileRec *) avRec[poseID]; - sprintf(buff, "%s\\%s.avb", theApp.GetAvatarDir(), arec->filename); - VERIFY(fp = fopen(buff, "rb")); + path.Format("%s\\%s.avb", theApp.GetAvatarDir(), arec->filename); + VERIFY(fp = fopen(path, "rb")); pose->m_drawing = new CDIB; fseek(fp, (long)arec->fgndOffset, SEEK_SET); VERIFY(pose->m_drawing->Load(fp)); @@ -973,4 +973,3 @@ const char *CAvatarX::OriginalName() { BOOL NullAvatar() { return (MyAvatar() == NULL); } - diff --git a/v1.0-pre-modern/bodycam.cpp b/v1.0-pre-modern/bodycam.cpp index db941ebf..19838cd4 100644 --- a/v1.0-pre-modern/bodycam.cpp +++ b/v1.0-pre-modern/bodycam.cpp @@ -93,10 +93,10 @@ CBodyCam::CBodyCam() EnableToolTips(TRUE); m_toolTip.Activate(TRUE); - char buff[80]; + CString path; for (int i = 0; i < NEMOTIONS; i++) { - sprintf(buff, "%s\\%s", theApp.GetAvatarDir(), lg_icons[i]); - VERIFY(m_icons[i].Load(buff)); + path.Format("%s\\%s", theApp.GetAvatarDir(), lg_icons[i]); + VERIFY(m_icons[i].Load(path)); } // Scale the (96-DPI) emotion-wheel pixel metrics to the display DPI once, so the diff --git a/v1.0-pre-modern/chat.cpp b/v1.0-pre-modern/chat.cpp index e5fc0cd3..b19b6114 100644 --- a/v1.0-pre-modern/chat.cpp +++ b/v1.0-pre-modern/chat.cpp @@ -663,20 +663,9 @@ void CDUpOne(const char *fullpath) { // sets the chatapp's base directory to be the base of fullpath void CChatApp::SetBaseDir(const char *fullpath) { - char buff[200]; - const char *start = fullpath, *ult = NULL; - while (1) { - const char *next = strchr(start, '\\'); - if (next) { - ult = next; - start = next+1; - } else break; - } - ASSERT(ult); - int nchars = ult - fullpath; - strncpy(buff, fullpath, nchars); - buff[nchars] = '\0'; - m_strBaseDir = buff; + CString path(fullpath); + int slash = path.ReverseFind('\\'); + m_strBaseDir = slash >= 0 ? path.Left(slash) : "."; } diff --git a/v1.0-pre-modern/chat.mak b/v1.0-pre-modern/chat.mak index eb76b185..c13f19e6 100644 --- a/v1.0-pre-modern/chat.mak +++ b/v1.0-pre-modern/chat.mak @@ -132,7 +132,7 @@ LINK32=link.exe # ADD BASE LINK32 /nologo /subsystem:windows /machine:I386 # ADD LINK32 uuid.lib winmm.lib /nologo /subsystem:windows /machine:I386 /nodefaultlib:"libc" LINK32_FLAGS=uuid.lib winmm.lib /nologo /subsystem:windows /incremental:no /FORCE:MULTIPLE\ - /LIBPATH:"C:\Program Files\Microsoft Visual Studio\18\Community\VC\Tools\MSVC\14.51.36231\ATLMFC\lib\spectre\x86"\ + /LIBPATH:"$(VCTOOLSINSTALLDIR)ATLMFC\lib\spectre\x86"\ /pdb:"$(OUTDIR)/chat.pdb" /machine:I386 /nodefaultlib:"libc"\ /out:"$(OUTDIR)/chat.exe" LINK32_OBJS= \ @@ -292,7 +292,7 @@ LINK32=link.exe # ADD BASE LINK32 /nologo /subsystem:windows /debug /machine:I386 # ADD LINK32 uuid.lib winmm.lib /nologo /subsystem:windows /incremental:no /debug /machine:I386 /nodefaultlib:"libc" LINK32_FLAGS=uuid.lib winmm.lib /nologo /subsystem:windows /FORCE:MULTIPLE\ - /LIBPATH:"C:\Program Files\Microsoft Visual Studio\18\Community\VC\Tools\MSVC\14.51.36231\ATLMFC\lib\spectre\x86"\ + /LIBPATH:"$(VCTOOLSINSTALLDIR)ATLMFC\lib\spectre\x86"\ /incremental:no /pdb:"$(OUTDIR)/chat.pdb" /debug /machine:I386\ /nodefaultlib:"libc" /out:"$(OUTDIR)/chat.exe" LINK32_OBJS= \ diff --git a/v2.5-beta-1-modern/chat.cpp b/v2.5-beta-1-modern/chat.cpp index 6592d45e..bee7506b 100644 --- a/v2.5-beta-1-modern/chat.cpp +++ b/v2.5-beta-1-modern/chat.cpp @@ -962,20 +962,9 @@ void CDUpOne(const char *fullpath) { // sets the chatapp's base directory to be the base of fullpath void CChatApp::SetBaseDir(const char *fullpath) { - char buff[200]; - const char *start = fullpath, *ult = NULL; - while (1) { - char *next = (char *)strchr(start, '\\'); - if (next) { - ult = next; - start = next+1; - } else break; - } - ASSERT(ult); - int nchars = ult - fullpath; - strncpy(buff, fullpath, nchars); - buff[nchars] = '\0'; - m_strBaseDir = buff; + CString path(fullpath); + int slash = path.ReverseFind('\\'); + m_strBaseDir = slash >= 0 ? path.Left(slash) : "."; } diff --git a/v2.5-beta-1-modern/chat.mak b/v2.5-beta-1-modern/chat.mak index 1e308b79..58162a89 100644 --- a/v2.5-beta-1-modern/chat.mak +++ b/v2.5-beta-1-modern/chat.mak @@ -35,7 +35,6 @@ LINK32=link.exe ARTINC=..\artifacts\inc ARTLIB=..\artifacts\lib\i386 -SPECTRE=C:\Program Files\Microsoft Visual Studio\18\Community\VC\Tools\MSVC\14.51.36231\ATLMFC\lib\spectre\x86 # /Zi is kept in both configs so Release is still debuggable (it emits a PDB but # does not disable optimization). The MFC/CRT static libraries are selected @@ -47,7 +46,7 @@ RSC_PROJ=/l 0x409 /fo"$(INTDIR)\chat.res" /i "." /i "$(ARTINC)" $(RSC_CFG) LINK32_FLAGS=/nologo /subsystem:windows /FORCE:MULTIPLE /incremental:no /debug \ /machine:I386 /nodefaultlib:"libc" \ - /LIBPATH:"$(SPECTRE)" /LIBPATH:"$(ARTLIB)" \ + /LIBPATH:"$(VCTOOLSINSTALLDIR)ATLMFC\lib\spectre\x86" /LIBPATH:"$(ARTLIB)" \ uuid.lib secur32.lib comctl32.lib ole32.lib oleaut32.lib oldnames.lib wsock32.lib \ shell32.lib winmm.lib imm32.lib winspool.lib comdlg32.lib oledlg.lib wininet.lib zlib.lib \ /out:"$(OUTDIR)\CChat.exe"