diff --git a/.github/workflows/compile-observer-smoke.yml b/.github/workflows/compile-observer-smoke.yml new file mode 100644 index 0000000000..cfce17132e --- /dev/null +++ b/.github/workflows/compile-observer-smoke.yml @@ -0,0 +1,454 @@ +# Public CI and rolling experimental distribution surface for the reviewed compile-observer candidate. +name: Compile observer candidate + +on: + push: + branches: + - semper/compile-observer + pull_request: + branches: + - semper/oxce-8.6.5-base + workflow_dispatch: + +permissions: + contents: read + +env: + UPSTREAM_BASE_VERSION: 8.6.5 + UPSTREAM_BASE_SHA: 668880bb756852f0734cfe92e438e400080a2f4d + EXPERIMENTAL_TAG: semper-exp-current + +jobs: + observer-contract: + name: Observer contract + runs-on: ubuntu-latest + steps: + - name: Checkout candidate head + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Compile smoke test + run: | + g++ -std=c++17 -Wall -Wextra -Werror \ + tests/CompileObserverSmoke.cpp \ + tests/CompileObserverCrossTu.cpp \ + -o /tmp/compile-observer-smoke + + - name: Run smoke test + run: /tmp/compile-observer-smoke + + linux-engine-build: + name: Full Linux engine build + runs-on: ubuntu-22.04 + steps: + - name: Checkout candidate head + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Prepare SemperSupra build identity + shell: bash + run: | + CANDIDATE_SHA="${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}" + SHORT_SHA="${CANDIDATE_SHA:0:8}" + echo "CANDIDATE_SHA=$CANDIDATE_SHA" >> "$GITHUB_ENV" + echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV" + python3 - "$SHORT_SHA" <<'PY' + from pathlib import Path + import re + import sys + + short_sha = sys.argv[1] + path = Path("src/version.h") + text = path.read_text(encoding="utf-8") + text, count = re.subn( + r'#define OPENXCOM_VERSION_GIT ".*"', + f'#define OPENXCOM_VERSION_GIT " (SemperSupra experimental g{short_sha})"', + text, + count=1, + ) + if count != 1: + raise SystemExit("could not stamp OPENXCOM_VERSION_GIT") + path.write_text(text, encoding="utf-8") + PY + + - name: Install OXCE build dependencies + run: | + sudo apt-get update + sudo apt-get -yq --no-install-suggests --no-install-recommends install \ + appstream \ + libfuse2 \ + libsdl1.2-dev \ + libsdl-mixer1.2-dev \ + libsdl-image1.2-dev \ + libsdl-gfx1.2-dev + + - name: Install LinuxDeploy + uses: miurahr/install-linuxdeploy-action@v1 + with: + plugins: appimage + + - name: Configure OXCE + run: cmake . -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr + + - name: Compile full OXCE engine + run: make -j$(nproc) + + - name: Stage Linux AppImage + shell: bash + run: | + make install DESTDIR=AppDir + rm -rf AppDir/usr/share/openxcom/UFO + rm -rf AppDir/usr/share/openxcom/TFTD + mkdir -p AppDir/usr/share/doc/openxcom + cat > AppDir/usr/share/doc/openxcom/BUILD-INFO.txt <- + (github.event_name == 'push' && github.ref == 'refs/heads/semper/compile-observer') || + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'semper/compile-observer' && github.base_ref == 'semper/oxce-8.6.5-base') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download platform packages + uses: actions/download-artifact@v4 + with: + pattern: public-*-package + path: dist + merge-multiple: true + + - name: Generate machine-readable manifest and checksums + shell: bash + run: | + CANDIDATE_SHA="${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}" + SHORT_SHA="${CANDIDATE_SHA:0:8}" + export CANDIDATE_SHA SHORT_SHA + python3 <<'PY' + from __future__ import annotations + + from datetime import datetime, timezone + from hashlib import sha256 + import json + import os + from pathlib import Path + + dist = Path("dist") + repo = os.environ["GITHUB_REPOSITORY"] + tag = os.environ["EXPERIMENTAL_TAG"] + candidate = os.environ["CANDIDATE_SHA"] + base_version = os.environ["UPSTREAM_BASE_VERSION"] + base_sha = os.environ["UPSTREAM_BASE_SHA"] + + def digest(path: Path) -> str: + h = sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + artifacts = [] + for path in sorted(dist.iterdir()): + if not path.is_file(): + continue + name = path.name + if name.endswith("-windows-x86.zip"): + artifacts.append({ + "os": "windows", + "arch": "x86", + "compatible_arches": ["x86", "x86_64"], + "kind": "zip", + "entrypoint": "OpenXcom.exe", + "name": name, + "sha256": digest(path), + "url": f"https://github.com/{repo}/releases/download/{tag}/{name}", + }) + elif name.endswith("-linux-x86_64.AppImage"): + artifacts.append({ + "os": "linux", + "arch": "x86_64", + "compatible_arches": ["x86_64"], + "kind": "appimage", + "entrypoint": name, + "name": name, + "sha256": digest(path), + "url": f"https://github.com/{repo}/releases/download/{tag}/{name}", + }) + + required = {("windows", "x86"), ("linux", "x86_64")} + found = {(a["os"], a["arch"]) for a in artifacts} + if found != required: + raise SystemExit(f"unexpected platform artifact set: {found!r}") + + manifest = { + "schema": 1, + "channel": tag, + "status": "experimental", + "official_oxce": False, + "published_at": datetime.now(timezone.utc).isoformat(), + "upstream": { + "repository": "MeridianOXC/OpenXcom", + "version": base_version, + "commit": base_sha, + }, + "source": { + "repository": repo, + "branch": "semper/compile-observer", + "commit": candidate, + }, + "release": { + "tag": tag, + "url": f"https://github.com/{repo}/releases/tag/{tag}", + "manifest_url": f"https://github.com/{repo}/releases/download/{tag}/experimental-build-manifest.json", + }, + "artifacts": artifacts, + } + manifest_path = dist / "experimental-build-manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + + checksum_lines = [] + for path in sorted(p for p in dist.iterdir() if p.is_file()): + checksum_lines.append(f"{digest(path)} {path.name}") + (dist / "SHA256SUMS.txt").write_text("\n".join(checksum_lines) + "\n", encoding="utf-8") + PY + + - name: Prepare release notes + shell: bash + run: | + CANDIDATE_SHA="${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}" + SHORT_SHA="${CANDIDATE_SHA:0:8}" + cat > release-notes.md </dev/null 2>&1; then + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/git/refs/tags/${TAG}" \ + -f sha="${CANDIDATE_SHA}" -F force=true >/dev/null + else + gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f ref="refs/tags/${TAG}" -f sha="${CANDIDATE_SHA}" >/dev/null + fi + + if RELEASE_ID=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" --jq '.id' 2>/dev/null); then + for ASSET_ID in $(gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100" --jq '.[].id'); do + gh api --method DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${ASSET_ID}" >/dev/null + done + gh release edit "${TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --title "UNOFFICIAL — SemperSupra OXCE Experimental Build (${UPSTREAM_BASE_VERSION})" \ + --notes-file release-notes.md \ + --prerelease + else + gh release create "${TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --target "${CANDIDATE_SHA}" \ + --title "UNOFFICIAL — SemperSupra OXCE Experimental Build (${UPSTREAM_BASE_VERSION})" \ + --notes-file release-notes.md \ + --prerelease + fi + + gh release upload "${TAG}" dist/* \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber + + verify-public-channel: + name: Verify unauthenticated public channel + needs: + - publish-experimental + if: >- + (github.event_name == 'push' && github.ref == 'refs/heads/semper/compile-observer') || + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'semper/compile-observer' && github.base_ref == 'semper/oxce-8.6.5-base') + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Resolve manifest and verify all public assets without credentials + shell: bash + run: | + CANDIDATE_SHA="${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}" + export CANDIDATE_SHA + env -u GH_TOKEN -u GITHUB_TOKEN python3 <<'PY' + from hashlib import sha256 + import json + import os + from urllib.request import Request, urlopen + + repo = os.environ["GITHUB_REPOSITORY"] + tag = os.environ["EXPERIMENTAL_TAG"] + expected_candidate = os.environ["CANDIDATE_SHA"] + headers = {"User-Agent": "SemperSupra-public-channel-verifier/1"} + + def get(url: str) -> bytes: + with urlopen(Request(url, headers=headers), timeout=120) as response: + return response.read() + + release_url = f"https://api.github.com/repos/{repo}/releases/tags/{tag}" + release = json.loads(get(release_url).decode("utf-8")) + if release.get("tag_name") != tag or release.get("prerelease") is not True: + raise SystemExit("rolling release identity/prerelease state is invalid") + + manifest_asset = next( + (a for a in release.get("assets", []) if a.get("name") == "experimental-build-manifest.json"), + None, + ) + if manifest_asset is None: + raise SystemExit("public manifest asset is missing") + + manifest = json.loads(get(manifest_asset["browser_download_url"]).decode("utf-8")) + if manifest.get("schema") != 1 or manifest.get("channel") != tag: + raise SystemExit("public manifest schema/channel is invalid") + if manifest.get("official_oxce") is not False: + raise SystemExit("public manifest does not explicitly identify build as unofficial") + if manifest.get("source", {}).get("commit") != expected_candidate: + raise SystemExit("rolling release does not point to the candidate that just passed CI") + + observed = set() + for artifact in manifest.get("artifacts", []): + data = get(artifact["url"]) + actual = sha256(data).hexdigest() + if actual != artifact.get("sha256"): + raise SystemExit(f"SHA-256 mismatch for public asset {artifact.get('name')}") + observed.add((artifact.get("os"), artifact.get("arch"))) + + required = {("windows", "x86"), ("linux", "x86_64")} + if observed != required: + raise SystemExit(f"unexpected published platform set: {observed!r}") + + print(f"verified public rolling channel {tag} at {expected_candidate}") + PY diff --git a/.github/workflows/experimental-source-snapshot.yml b/.github/workflows/experimental-source-snapshot.yml new file mode 100644 index 0000000000..a665910826 --- /dev/null +++ b/.github/workflows/experimental-source-snapshot.yml @@ -0,0 +1,235 @@ +name: Harden experimental source release + +on: + push: + branches: + - semper/compile-observer + workflow_dispatch: + +permissions: + actions: read + contents: write + +concurrency: + group: semper-exp-source-snapshot + cancel-in-progress: true + +env: + EXPERIMENTAL_TAG: semper-exp-current + UPSTREAM_BASE_VERSION: 8.6.5 + +jobs: + source-snapshot: + name: Attach exact public source snapshot + runs-on: ubuntu-22.04 + timeout-minutes: 35 + steps: + - name: Checkout exact public candidate + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.sha }} + + - name: Wait for public engine build/release gate + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + candidate="${GITHUB_SHA}" + for attempt in $(seq 1 70); do + payload="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${candidate}&per_page=100")" + row="$(jq -c '[.workflow_runs[] | select(.name == "Compile observer candidate" and .event == "push")] | sort_by(.created_at) | last // empty' <<<"$payload")" + if [[ -z "$row" ]]; then + echo "compile workflow not visible yet (${attempt}/70)" + sleep 20 + continue + fi + + status="$(jq -r '.status' <<<"$row")" + conclusion="$(jq -r '.conclusion // ""' <<<"$row")" + url="$(jq -r '.html_url' <<<"$row")" + echo "compile gate: status=${status} conclusion=${conclusion} ${url}" + + if [[ "$status" == "completed" && "$conclusion" == "success" ]]; then + exit 0 + fi + + if [[ "$status" == "completed" && "$conclusion" != "success" ]]; then + # A failed post-publish verifier may be retried in-place and turn + # the same workflow run green. Keep the source firewall closed, + # but wait for the bounded observation window rather than making + # the first transient conclusion permanent for this candidate. + echo "compile/release gate currently ${conclusion}; waiting for any in-place failed-job rerun (${attempt}/70)" + fi + sleep 20 + done + echo "timed out waiting for a successful public engine build/release gate" >&2 + exit 1 + + - name: Download and validate current release evidence + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p dist + for attempt in $(seq 1 12); do + rm -f dist/experimental-build-manifest.json dist/SHA256SUMS.txt + if gh release download "$EXPERIMENTAL_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern experimental-build-manifest.json \ + --pattern SHA256SUMS.txt \ + --dir dist; then + break + fi + if [[ "$attempt" == "12" ]]; then + echo "release evidence did not become downloadable" >&2 + exit 1 + fi + echo "release evidence not ready yet (${attempt}/12)" + sleep 10 + done + + python3 <<'PY' + import json, os, pathlib + manifest = json.loads(pathlib.Path('dist/experimental-build-manifest.json').read_text(encoding='utf-8')) + expected = os.environ['GITHUB_SHA'] + observed = manifest.get('source', {}).get('commit') + if observed != expected: + raise SystemExit(f"rolling release points at {observed!r}, expected {expected!r}; fail closed") + if manifest.get('channel') != os.environ['EXPERIMENTAL_TAG']: + raise SystemExit('rolling release channel mismatch') + PY + + - name: Generate source snapshot from exact public commit + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + short="${GITHUB_SHA:0:8}" + source_name="semper-oxce-experimental-${UPSTREAM_BASE_VERSION}-g${short}-source.zip" + git archive \ + --format=zip \ + --prefix="OpenXcom-SemperSupra-g${short}/" \ + --output="dist/${source_name}" \ + "$GITHUB_SHA" + echo "SOURCE_NAME=${source_name}" >> "$GITHUB_ENV" + + - name: Add source provenance and refresh checksums + shell: bash + run: | + set -euo pipefail + python3 <<'PY' + from hashlib import sha256 + import json, os, pathlib + + root = pathlib.Path('dist') + manifest_path = root / 'experimental-build-manifest.json' + checksum_path = root / 'SHA256SUMS.txt' + source_path = root / os.environ['SOURCE_NAME'] + candidate = os.environ['GITHUB_SHA'] + tag = os.environ['EXPERIMENTAL_TAG'] + repo = os.environ['GITHUB_REPOSITORY'] + + def digest(path: pathlib.Path) -> str: + h = sha256() + with path.open('rb') as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b''): + h.update(chunk) + return h.hexdigest() + + manifest = json.loads(manifest_path.read_text(encoding='utf-8')) + manifest['source_archive'] = { + 'kind': 'complete-public-source-snapshot', + 'name': source_path.name, + 'sha256': digest(source_path), + 'commit': candidate, + 'origin': 'exact-public-candidate-commit', + 'url': f'https://github.com/{repo}/releases/download/{tag}/{source_path.name}', + 'private_validator_assets_included': False, + } + manifest_path.write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8') + + existing = {} + for raw in checksum_path.read_text(encoding='utf-8').splitlines(): + raw = raw.strip() + if not raw: + continue + digest_text, name = raw.split(None, 1) + existing[name.strip()] = digest_text + + # The manifest changed and this source archive is new. Preserve the + # already-verified platform hashes from the primary publisher. + existing[manifest_path.name] = digest(manifest_path) + existing[source_path.name] = digest(source_path) + checksum_path.write_text( + ''.join(f'{existing[name]} {name}\n' for name in sorted(existing)), + encoding='utf-8', + ) + PY + + - name: Attach source snapshot and updated evidence + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh release upload "$EXPERIMENTAL_TAG" \ + "dist/$SOURCE_NAME" \ + dist/experimental-build-manifest.json \ + dist/SHA256SUMS.txt \ + --repo "$GITHUB_REPOSITORY" \ + --clobber + + - name: Verify published source without credentials + shell: bash + run: | + set -euo pipefail + env -u GH_TOKEN -u GITHUB_TOKEN python3 <<'PY' + from hashlib import sha256 + from time import sleep + import json, os + from urllib.error import HTTPError, URLError + from urllib.request import Request, urlopen + + repo = os.environ['GITHUB_REPOSITORY'] + tag = os.environ['EXPERIMENTAL_TAG'] + candidate = os.environ['GITHUB_SHA'] + headers = {'User-Agent': 'SemperSupra-source-snapshot-verifier/1'} + + def get(url: str) -> bytes: + last = None + for attempt in range(1, 13): + try: + with urlopen(Request(url, headers=headers), timeout=120) as response: + return response.read() + except (HTTPError, URLError) as exc: + last = exc + if isinstance(exc, HTTPError) and exc.code not in (404, 429, 500, 502, 503, 504): + raise + if attempt == 12: + raise + print(f'public asset not ready ({attempt}/12): {exc}') + sleep(10) + raise last + + release = json.loads(get(f'https://api.github.com/repos/{repo}/releases/tags/{tag}').decode('utf-8')) + assets = {a['name']: a for a in release.get('assets', [])} + manifest_asset = assets.get('experimental-build-manifest.json') + if manifest_asset is None: + raise SystemExit('published manifest missing') + manifest = json.loads(get(manifest_asset['browser_download_url']).decode('utf-8')) + if manifest.get('source', {}).get('commit') != candidate: + raise SystemExit('published manifest candidate mismatch') + source = manifest.get('source_archive') or {} + if source.get('commit') != candidate: + raise SystemExit('published source archive candidate mismatch') + source_asset = assets.get(source.get('name')) + if source_asset is None: + raise SystemExit('published source archive missing') + data = get(source_asset['browser_download_url']) + if sha256(data).hexdigest() != source.get('sha256'): + raise SystemExit('published source archive SHA-256 mismatch') + print(f'verified public source snapshot {source.get("name")} at {candidate}') + PY diff --git a/docs/SEMPERSUPRA-EXPERIMENTAL-BUILDS.md b/docs/SEMPERSUPRA-EXPERIMENTAL-BUILDS.md new file mode 100644 index 0000000000..a001f37609 --- /dev/null +++ b/docs/SEMPERSUPRA-EXPERIMENTAL-BUILDS.md @@ -0,0 +1,235 @@ +# SemperSupra experimental build and distribution policy + +Status: draft policy for the SemperSupra OXCE fork and compile-observer work. + +## Purpose + +`SemperSupra/OpenXcom` is a public fork used as a reviewed build, deployment, and possible upstream-contribution surface. It is not the official OXCE repository. + +Official upstream remains: + +- repository: `MeridianOXC/OpenXcom` +- project: OpenXcom Extended (OXCE) + +SemperSupra builds must be unmistakably distinguishable from official OXCE builds, even if an archive is renamed or copied away from GitHub. + +## Build classes + +Use three distinct levels. + +1. **CI build** — compile/test evidence only. No claim that the output is suitable for users. +2. **Rolling experimental prerelease** — public downloadable binaries used by SemperSupra development and local-agent environments. Unsupported and explicitly unofficial. +3. **Immutable preview prerelease** — intentionally published snapshot for broader testing after the relevant parity/runtime gates have passed. + +Do not create a normal GitHub Release for experimental compile-observer work. Experimental distribution uses GitHub **Pre-release** semantics only. + +## Rolling experimental channel + +The canonical development channel is the mutable prerelease tag: + +```text +semper-exp-current +``` + +Release page: + +```text +https://github.com/SemperSupra/OpenXcom/releases/tag/semper-exp-current +``` + +Machine API: + +```text +https://api.github.com/repos/SemperSupra/OpenXcom/releases/tags/semper-exp-current +``` + +The tag is intentionally mutable. It advances to a new `semper/compile-observer` candidate only after all public gates required by the workflow succeed. For the current work those gates are: + +- observer contract test; +- full Linux engine build; +- full Windows engine build. + +The rolling release is an **acquisition channel**, not an immutable historical record. Every platform package includes the candidate SHA in its filename and internal provenance, and the release publishes a machine-readable `experimental-build-manifest.json` plus `SHA256SUMS.txt`. + +Automated consumers should resolve the release by the exact `semper-exp-current` tag, read the manifest, select a compatible artifact, verify SHA-256, and only then execute or unpack it. Consumers should not scrape Actions run IDs or depend on Actions artifact retention. + +## Artifact naming + +Experimental downloadable artifacts use this form: + +```text +semper-oxce-experimental--g--. +``` + +Examples: + +```text +semper-oxce-experimental-8.6.5-g189795f-linux-x86_64.AppImage +semper-oxce-experimental-8.6.5-g189795f-windows-x86.zip +``` + +Avoid names that could be mistaken for upstream, including: + +```text +OXCE-8.6.5.zip +OpenXcom-8.6.5.zip +OXCE-nightly.zip +v8.6.6 +``` + +The `semper-` and `experimental` qualifiers are intentional provenance, not decoration. + +## Executable provenance + +Public experimental CI stamps the executable version metadata at build time, for example: + +```text +Extended 8.6.5 (SemperSupra experimental g189795f) +``` + +The source baseline is not permanently rewritten solely to carry this build label. The stamp is produced by the public build workflow so a renamed archive or copied executable still identifies itself as a SemperSupra experimental build. + +## BUILD-INFO.txt + +Every downloadable package contains generated provenance equivalent to: + +```text +UNOFFICIAL / EXPERIMENTAL BUILD + +This build is produced by the SemperSupra OpenXcom fork. +It is not an official OXCE release and is not published by MeridianOXC. + +Upstream project: + MeridianOXC/OpenXcom + +Upstream base: + OXCE + + +SemperSupra source: + SemperSupra/OpenXcom + +Candidate commit: + + +Channel: + semper-exp-current + +Support: + Do not report failures from this build to upstream OXCE unless the + problem is independently reproduced on an official upstream build. +``` + +This information is generated from immutable build inputs rather than maintained manually inside an archive. + +## Machine-readable manifest + +The rolling prerelease publishes: + +```text +experimental-build-manifest.json +``` + +Schema version 1 contains at least: + +- channel and experimental/offical status; +- upstream repository, version, and exact upstream commit; +- SemperSupra source repository, branch, and exact candidate commit; +- release/tag URLs; +- one entry per downloadable platform artifact; +- OS and architecture compatibility; +- package kind and entrypoint; +- immutable SHA-256 for each artifact; +- direct public download URL. + +The current supported automated-consumer targets are: + +- `windows` / `x86`, marked compatible with `x86_64` Windows hosts; +- `linux` / `x86_64`, delivered as AppImage. + +Unsupported host combinations must fail closed rather than selecting an unrelated binary. + +## Immutable preview naming + +If broader distribution or historical retention becomes useful, create a separate immutable prerelease rather than treating the rolling tag as archival history. + +Recommended tag: + +```text +semper-preview---g +``` + +Recommended title: + +```text +UNOFFICIAL — SemperSupra OXCE Preview Build +Base: OXCE · g +``` + +Normal OXCE semantic-version tags remain reserved for upstream. + +## Public CI boundary + +Public GitHub-hosted CI may contain only material safe for public distribution, including: + +- GPL engine source and SemperSupra patches; +- compiler/unit/smoke tests; +- synthetic/reference mods and test assets that are redistributable; +- generated build metadata and checksums. + +Public CI must not contain or upload proprietary X-COM data, private development evidence, credentials, secrets, or licensed assets that cannot be redistributed. + +Authoritative tests that require legitimate X-COM data belong on a controlled local/self-hosted execution surface. + +## Pinned-base review gate + +Experimental public PRs should compare against an explicit pinned upstream-base branch, not a moving upstream branch. For the current work: + +```text +semper/oxce-8.6.5-base + -> 668880bb756852f0734cfe92e438e400080a2f4d +``` + +The current compile-observer PR is a build/review gate only and must not be merged merely because CI passes. Runtime A/B/C parity and side-effect validation remain separate promotion gates. + +## Windows CI policy + +Do not use `windows-latest` for this legacy Visual Studio solution when reproducibility depends on a specific Visual Studio generation. In August 2026, `windows-latest` resolved to Windows Server 2025 with Visual Studio 2026, which broke the old OXCE nightly step that attempted to modify a Visual Studio 2022 installation. + +For the compile gate, pin: + +```yaml +runs-on: windows-2022 +``` + +and compile the solution's ordinary supported `Release|Win32` configuration. `OpenXcom.2010.sln` and `OpenXcom.2010.vcxproj` define both `Release` and the legacy `Release_XP` configurations; `Release` uses the runner's default supported platform toolset, while `Release_XP` explicitly requests `v141_xp`. + +The public compile gate therefore validates modern Windows compilation without dynamically installing the obsolete XP toolset. If an actual Windows-XP-compatible distribution is ever required, treat that as a separate compatibility/release job with its own maintained toolchain rather than making it a prerequisite for ordinary CI. + +## Release source and generator-validator boundary + +Every public binary release must remain tied to public source that is sufficient to satisfy the applicable license and rebuild the distributed binary. For this GPL fork, the public release contract is **complete corresponding source**, not a deliberately reduced implementation. + +The public release tag already identifies an exact public source commit and GitHub exposes source archives for that tag. The stronger target state is to also publish an explicitly named source snapshot whose SHA-256 is recorded in `experimental-build-manifest.json` alongside the platform binaries. + +The source snapshot must be generated from the exact reviewed **public** candidate commit. It must never be created by archiving the private development repository and deleting known-private paths afterward. + +The public/private split is based on semantic role rather than file format. Private validation/evaluation assets may remain private when they are not required corresponding source, including comprehensive conformance corpora, adversarial/regression knowledge, proprietary-data test environments, private compatibility matrices, fuzz/evaluation knowledge, and other validator/control-plane evidence. Ordinary public build/smoke tests remain public where they are part of the public build and trust contract. + +This preserves a generator-validator asymmetry: + +```text +private validation/control plane + judges reviewed implementation + | + v +public source candidate + -> public CI/build + -> platform binaries + -> corresponding source snapshot + -> hashes/provenance +``` + +A public implementation can therefore be reproducible and GPL-compliant without publishing every private asset used to decide whether candidate behavior is acceptable. + +For downstream Windows distribution, Windows Package Foundry should index immutable preview/stable Windows releases and their provenance rather than becoming a source-code mirror. The mutable `semper-exp-current` channel remains primarily a development acquisition channel unless Foundry later defines an explicit experimental-feed policy. diff --git a/src/Engine/CompileObserver.h b/src/Engine/CompileObserver.h new file mode 100644 index 0000000000..805af25915 --- /dev/null +++ b/src/Engine/CompileObserver.h @@ -0,0 +1,374 @@ +#pragma once +/* + * Copyright 2010-2026 OpenXcom Developers. + * + * This file is part of OpenXcom. + * + * OpenXcom is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * OpenXcom is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with OpenXcom. If not, see . + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace OpenXcom +{ + +/** + * Schema version for structured compile/load observation events. + * + * This version describes the observer contract, not OXCE ruleset syntax. + */ +static constexpr std::uint32_t COMPILE_OBSERVER_SCHEMA_VERSION = 1; + +/** + * Stable, high-level event classes emitted by the compile/load observer. + * + * Event producers remain the existing OXCE loader/linker/resource paths; + * this enum does not define or duplicate their semantics. + */ +enum class CompileEventKind : std::uint8_t +{ + PhaseBegin, + PhaseEnd, + RuleOperation, + LinkResult, + ResourceResolution, + FilesystemWriteIntent, + Snapshot +}; + +/** + * Structured view of one compile/load observation. + * + * Text members are non-owning and are only guaranteed to remain valid for + * the duration of CompileObserver::onCompileEvent(). A sink that retains an + * event must copy the text it needs. + * + * Empty fields mean "not applicable/not supplied" for that event kind. + */ +struct CompileEvent +{ + std::uint32_t schemaVersion = COMPILE_OBSERVER_SCHEMA_VERSION; + CompileEventKind kind = CompileEventKind::PhaseBegin; + std::string_view phase; + std::string_view category; + std::string_view operation; + std::string_view identity; + std::string_view source; + std::string_view outcome; +}; + +/** + * Passive observer for authoritative OXCE compile/load behavior. + * + * The observer is optional. With no observer installed, emitCompileEvent() + * is a no-op. Observer failures are contained so instrumentation cannot alter + * OXCE loader pass/fail behavior; callers may query compileObserverFailed() + * and fail evidence generation separately. + */ +class CompileObserver +{ +public: + virtual ~CompileObserver() = default; + virtual void onCompileEvent(const CompileEvent &event) = 0; +}; + +namespace CompileObserverDetail +{ +inline const char *eventKindName(CompileEventKind kind) noexcept +{ + switch (kind) + { + case CompileEventKind::PhaseBegin: return "phase-begin"; + case CompileEventKind::PhaseEnd: return "phase-end"; + case CompileEventKind::RuleOperation: return "rule-operation"; + case CompileEventKind::LinkResult: return "link-result"; + case CompileEventKind::ResourceResolution: return "resource-resolution"; + case CompileEventKind::FilesystemWriteIntent: return "filesystem-write-intent"; + case CompileEventKind::Snapshot: return "snapshot"; + } + return "unknown"; +} + +inline std::string iso8601UtcNow() +{ + using namespace std::chrono; + const system_clock::time_point now = system_clock::now(); + const milliseconds millis = duration_cast(now.time_since_epoch()) % 1000; + const std::time_t raw = system_clock::to_time_t(now); + std::tm utc{}; +#ifdef _WIN32 + gmtime_s(&utc, &raw); +#else + gmtime_r(&raw, &utc); +#endif + std::ostringstream out; + out << std::put_time(&utc, "%Y-%m-%dT%H:%M:%S") + << '.' << std::setw(3) << std::setfill('0') << millis.count() << 'Z'; + return out.str(); +} + +inline std::string environmentCorrelationId() +{ + const char *value = std::getenv("OXCE_CORRELATION_ID"); + if (value && *value) + { + return value; + } + + using namespace std::chrono; + const auto micros = duration_cast(system_clock::now().time_since_epoch()).count(); + std::ostringstream out; + out << "oxce-local-" << micros; + return out.str(); +} + +class EnvironmentJsonlObserver final : public CompileObserver +{ +public: + explicit EnvironmentJsonlObserver(const std::string &path) : + _out(path, std::ios::out | std::ios::trunc), + _correlationId(environmentCorrelationId()) + { + if (!_out) + { + throw std::runtime_error("could not open compile observer trace: " + path); + } + } + + void onCompileEvent(const CompileEvent &event) override + { + _out << "{\"schema\":" << event.schemaVersion << ",\"timestamp\":"; + writeJsonString(iso8601UtcNow()); + _out << ",\"correlation_id\":"; + writeJsonString(_correlationId); + _out << ",\"sequence\":" << ++_sequence << ",\"kind\":"; + writeJsonString(eventKindName(event.kind)); + _out << ",\"phase\":"; + writeJsonString(event.phase); + _out << ",\"category\":"; + writeJsonString(event.category); + _out << ",\"operation\":"; + writeJsonString(event.operation); + _out << ",\"identity\":"; + writeJsonString(event.identity); + _out << ",\"source\":"; + writeJsonString(event.source); + _out << ",\"outcome\":"; + writeJsonString(event.outcome); + _out << "}\n"; + _out.flush(); + if (!_out) + { + throw std::runtime_error("failed writing compile observer trace"); + } + } + +private: + void writeJsonString(std::string_view value) + { + _out.put('"'); + for (const char raw : value) + { + const unsigned char ch = static_cast(raw); + switch (ch) + { + case '"': _out << "\\\""; break; + case '\\': _out << "\\\\"; break; + case '\b': _out << "\\b"; break; + case '\f': _out << "\\f"; break; + case '\n': _out << "\\n"; break; + case '\r': _out << "\\r"; break; + case '\t': _out << "\\t"; break; + default: + if (ch < 0x20) + { + static const char hex[] = "0123456789abcdef"; + _out << "\\u00" << hex[(ch >> 4) & 0x0f] << hex[ch & 0x0f]; + } + else + { + _out.put(static_cast(ch)); + } + } + } + _out.put('"'); + } + + std::ofstream _out; + std::string _correlationId; + std::uint64_t _sequence = 0; +}; + +inline CompileObserver *&observerSlot() noexcept +{ + static CompileObserver *observer = nullptr; + return observer; +} + +inline bool &failureSlot() noexcept +{ + static bool failed = false; + return failed; +} + +inline bool &environmentCheckedSlot() noexcept +{ + static bool checked = false; + return checked; +} + +inline std::unique_ptr &environmentObserverSlot() noexcept +{ + static std::unique_ptr observer; + return observer; +} + +inline void tryInstallEnvironmentObserver() noexcept +{ + if (environmentCheckedSlot() || observerSlot()) + { + return; + } + environmentCheckedSlot() = true; + + const char *path = std::getenv("OXCE_COMPILE_TRACE"); + if (!path || !*path) + { + return; + } + + try + { + environmentObserverSlot() = std::make_unique(path); + observerSlot() = environmentObserverSlot().get(); + } + catch (...) + { + failureSlot() = true; + } +} +} + +/** + * Installs an observer owned by the caller. + * @param observer Observer to install, or nullptr to disable observation. + * @return Previously installed observer. + */ +inline CompileObserver *setCompileObserver(CompileObserver *observer) noexcept +{ + CompileObserver *previous = CompileObserverDetail::observerSlot(); + CompileObserverDetail::environmentCheckedSlot() = true; + CompileObserverDetail::observerSlot() = observer; + CompileObserverDetail::failureSlot() = false; + return previous; +} + +/** + * Returns the currently installed observer, or nullptr when disabled. + * + * When no observer was installed explicitly, the first call checks the + * OXCE_COMPILE_TRACE environment variable once. If it names a file, a minimal + * JSON Lines sink is installed for the lifetime of the process. + */ +inline CompileObserver *getCompileObserver() noexcept +{ + CompileObserverDetail::tryInstallEnvironmentObserver(); + return CompileObserverDetail::observerSlot(); +} + +/** + * Reports whether the installed observer threw while handling an event or the + * environment-requested trace sink could not be created. + * + * The failure is deliberately contained to preserve authoritative engine + * behavior. Evidence-producing modes can treat this flag as a hard failure. + */ +inline bool compileObserverFailed() noexcept +{ + return CompileObserverDetail::failureSlot(); +} + +/** + * Sends one event to the installed observer without changing engine behavior. + */ +inline void emitCompileEvent(const CompileEvent &event) noexcept +{ + CompileObserver *observer = getCompileObserver(); + if (!observer) + { + return; + } + + try + { + observer->onCompileEvent(event); + } + catch (...) + { + CompileObserverDetail::failureSlot() = true; + } +} + +/** + * No-throw scope helper for observing an existing engine phase. + * + * Construction emits PhaseBegin. Destruction emits PhaseEnd with outcome + * "success" during normal scope exit or "exception" during stack unwinding. + * It does not catch, translate, or suppress engine exceptions. + * + * The phase string is non-owning and must outlive this scope. Intended call + * sites use string literals so observation introduces no allocation or other + * failure path into authoritative engine behavior. + */ +class CompilePhaseScope +{ +public: + explicit CompilePhaseScope(std::string_view phase) noexcept : + _phase(phase), _uncaughtOnEntry(std::uncaught_exceptions()) + { + CompileEvent event; + event.kind = CompileEventKind::PhaseBegin; + event.phase = _phase; + emitCompileEvent(event); + } + + ~CompilePhaseScope() noexcept + { + CompileEvent event; + event.kind = CompileEventKind::PhaseEnd; + event.phase = _phase; + event.outcome = std::uncaught_exceptions() > _uncaughtOnEntry ? "exception" : "success"; + emitCompileEvent(event); + } + + CompilePhaseScope(const CompilePhaseScope &) = delete; + CompilePhaseScope &operator=(const CompilePhaseScope &) = delete; + +private: + std::string_view _phase; + int _uncaughtOnEntry; +}; + +} diff --git a/src/Engine/Game.cpp b/src/Engine/Game.cpp index f3533971d4..2f6870db1e 100644 --- a/src/Engine/Game.cpp +++ b/src/Engine/Game.cpp @@ -38,6 +38,7 @@ #include "Options.h" #include "CrossPlatform.h" #include "FileMap.h" +#include "CompileObserver.h" #include "Unicode.h" #include "../Ufopaedia/UfopaediaStartState.h" #include "../Menu/NotesState.h" @@ -500,6 +501,7 @@ void Game::setSavedGame(SavedGame *save) */ void Game::loadMods() { + CompilePhaseScope compilePhase("Game::loadMods"); Mod::resetGlobalStatics(); delete _mod; _mod = new Mod(); diff --git a/src/main.cpp b/src/main.cpp index 4fd8a1d410..d8b12c074c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -19,6 +19,10 @@ #include #include #include +#include +#include +#include +#include #include "version.h" #include "Engine/Exception.h" #include "Engine/Logger.h" @@ -26,7 +30,9 @@ #include "Engine/Game.h" #include "Engine/Options.h" #include "Engine/FileMap.h" +#include "Engine/CompileObserver.h" #include "Menu/StartState.h" +#include "Mod/Mod.h" /** @mainpage * @author OpenXcom Developers @@ -93,6 +99,151 @@ void exceptionLogger() Game *game = 0; +static bool validateRulesetsRequested() noexcept +{ + const char *value = std::getenv("OXCE_VALIDATE_RULESETS"); + if (!value) + { + return false; + } + + const std::string_view enabled(value); + return enabled == "1" || enabled == "true" || enabled == "TRUE" || enabled == "yes" || enabled == "YES"; +} + +/** + * Emits a deliberately bounded final-state view for the validate-only path. + * + * This does not reconstruct loader semantics. The real OXCE loader has already + * completed successfully; these events only expose identities that survived + * into the final item/research maps plus OXCE's existing creation/update + * tracking. Observation failures are contained so they cannot change the + * loader result. + */ +template +static void emitEffectiveRuleSnapshot(const Mod &mod, std::string_view category, const std::string &identity, const T *rule) noexcept +{ + if (!getCompileObserver()) + { + return; + } + + CompileEvent created; + created.kind = CompileEventKind::Snapshot; + created.phase = "validate-rulesets"; + created.category = category; + created.operation = "created-by"; + created.identity = identity; + created.outcome = "present"; + try + { + const ModData *source = mod.getModCreatingRule(rule); + if (source) + { + created.source = source->name; + } + } + catch (...) + { + created.outcome = "provenance-unavailable"; + } + emitCompileEvent(created); + + CompileEvent effective; + effective.kind = CompileEventKind::Snapshot; + effective.phase = "validate-rulesets"; + effective.category = category; + effective.operation = "effective-rule"; + effective.identity = identity; + effective.outcome = "present"; + try + { + const ModData *source = mod.getModLastUpdatingRule(rule); + if (source) + { + effective.source = source->name; + } + } + catch (...) + { + effective.outcome = "provenance-unavailable"; + } + emitCompileEvent(effective); +} + +static void emitValidateOnlySnapshots(const Mod &mod) noexcept +{ + if (!getCompileObserver()) + { + return; + } + + for (const std::string &identity : mod.getItemsList()) + { + const RuleItem *rule = mod.getItem(identity, false); + if (rule) + { + emitEffectiveRuleSnapshot(mod, "items", identity, rule); + } + } + for (const std::string &identity : mod.getResearchList()) + { + const RuleResearch *rule = mod.getResearch(identity, false); + if (rule) + { + emitEffectiveRuleSnapshot(mod, "research", identity, rule); + } + } +} + +/** + * Runs the authoritative mod/VFS/ruleset load without constructing Game. + * + * This is intentionally environment-gated for the private spike so the + * existing command-line grammar and normal startup path remain untouched. + * Callers should provide isolated -user and -cfg paths when using this for + * automated evidence generation because normal Options/updateMods behavior + * can write configuration and create user folders. + */ +static int validateRulesetsAndExit() +{ + std::unique_ptr mod; + try + { + CompilePhaseScope phase("validate-rulesets"); + + // Match the non-persisted initial state established by Game::Game(). + Options::reload = false; + Options::mute = false; + + // This is the same active-mod/VFS setup used by StartState::load(). + Options::updateMods(); + Mod::resetGlobalStatics(); + mod.reset(new Mod()); + mod->loadAll(); + emitValidateOnlySnapshots(*mod); + + // The normal Game owns an initialized mixer. This headless path does not; + // silence destructor-time playback cleanup after validation has completed. + Options::mute = true; + mod.reset(); + FileMap::clear(true, false); + + std::cout << "OXCE ruleset validation succeeded." << std::endl; + return EXIT_SUCCESS; + } + catch (const std::exception &e) + { + // If loadAll() failed, keep the Mod alive until mixer-using cleanup is muted. + Options::mute = true; + mod.reset(); + FileMap::clear(true, false); + Log(LOG_ERROR) << "OXCE ruleset validation failed: " << e.what(); + std::cerr << "OXCE ruleset validation failed: " << e.what() << std::endl; + return EXIT_FAILURE; + } +} + // If you can't tell what the main() is for you should have your // programming license revoked... int main(int argc, char *argv[]) @@ -124,6 +275,11 @@ int main(int argc, char *argv[]) Options::baseXResolution = Options::displayWidth; Options::baseYResolution = Options::displayHeight; + if (validateRulesetsRequested()) + { + return validateRulesetsAndExit(); + } + game = new Game(title.str()); State::setGamePtr(game); game->setState(new StartState); diff --git a/tests/CompileObserverCrossTu.cpp b/tests/CompileObserverCrossTu.cpp new file mode 100644 index 0000000000..1a238bcbf5 --- /dev/null +++ b/tests/CompileObserverCrossTu.cpp @@ -0,0 +1,10 @@ +/* + * Cross-translation-unit probe for the header-only compile observer slot. + */ + +#include "../src/Engine/CompileObserver.h" + +bool compileObserverVisibleFromOtherTranslationUnit() noexcept +{ + return OpenXcom::getCompileObserver() != nullptr; +} diff --git a/tests/CompileObserverSmoke.cpp b/tests/CompileObserverSmoke.cpp new file mode 100644 index 0000000000..fd0a7b512e --- /dev/null +++ b/tests/CompileObserverSmoke.cpp @@ -0,0 +1,143 @@ +/* + * Private development smoke test for the OXCE compile observer contract. + * No game data or engine runtime is required. + */ + +#include "../src/Engine/CompileObserver.h" + +#include +#include +#include +#include +#include +#include + +bool compileObserverVisibleFromOtherTranslationUnit() noexcept; + +namespace +{ + +class CountingObserver final : public OpenXcom::CompileObserver +{ +public: + int count = 0; + OpenXcom::CompileEventKind lastKind = OpenXcom::CompileEventKind::PhaseBegin; + std::string lastPhase; + std::string lastOutcome; + + void onCompileEvent(const OpenXcom::CompileEvent &event) override + { + ++count; + lastKind = event.kind; + lastPhase.assign(event.phase.data(), event.phase.size()); + lastOutcome.assign(event.outcome.data(), event.outcome.size()); + } +}; + +class ThrowingObserver final : public OpenXcom::CompileObserver +{ +public: + void onCompileEvent(const OpenXcom::CompileEvent &) override + { + throw std::runtime_error("intentional observer failure"); + } +}; + +} + +int main() +{ + using namespace OpenXcom; + + assert(COMPILE_OBSERVER_SCHEMA_VERSION == 1); + assert(getCompileObserver() == nullptr); + assert(!compileObserverVisibleFromOtherTranslationUnit()); + assert(!compileObserverFailed()); + + CompileEvent event; + event.kind = CompileEventKind::PhaseBegin; + event.phase = "smoke"; + + // Disabled observation is a no-op. + emitCompileEvent(event); + assert(!compileObserverFailed()); + + CountingObserver counting; + assert(setCompileObserver(&counting) == nullptr); + assert(compileObserverVisibleFromOtherTranslationUnit()); + event.kind = CompileEventKind::RuleOperation; + emitCompileEvent(event); + assert(counting.count == 1); + assert(counting.lastKind == CompileEventKind::RuleOperation); + assert(!compileObserverFailed()); + + // A phase scope emits begin/end without changing normal control flow. + const int beforeSuccess = counting.count; + { + CompilePhaseScope scope("smoke.success"); + } + assert(counting.count == beforeSuccess + 2); + assert(counting.lastKind == CompileEventKind::PhaseEnd); + assert(counting.lastPhase == "smoke.success"); + assert(counting.lastOutcome == "success"); + + // Stack unwinding is observed, not caught or translated by the phase scope. + const int beforeException = counting.count; + bool sawExpectedException = false; + try + { + CompilePhaseScope scope("smoke.exception"); + throw std::runtime_error("authoritative failure"); + } + catch (const std::runtime_error &e) + { + sawExpectedException = std::string(e.what()) == "authoritative failure"; + } + assert(sawExpectedException); + assert(counting.count == beforeException + 2); + assert(counting.lastKind == CompileEventKind::PhaseEnd); + assert(counting.lastPhase == "smoke.exception"); + assert(counting.lastOutcome == "exception"); + assert(!compileObserverFailed()); + + // A broken evidence sink must not escape into authoritative engine behavior. + ThrowingObserver throwing; + assert(setCompileObserver(&throwing) == &counting); + assert(compileObserverVisibleFromOtherTranslationUnit()); + emitCompileEvent(event); + assert(compileObserverFailed()); + + // Replacing/disabling the observer starts a fresh evidence session. + assert(setCompileObserver(nullptr) == &throwing); + assert(getCompileObserver() == nullptr); + assert(!compileObserverVisibleFromOtherTranslationUnit()); + assert(!compileObserverFailed()); + + // The JSONL sink must envelope every semantic event with automation-safe + // timestamp/correlation/sequence metadata without changing schema 1 fields. + const char *tracePath = "/tmp/compile-observer-envelope-smoke.jsonl"; + setenv("OXCE_CORRELATION_ID", "observer-smoke-correlation", 1); + { + CompileObserverDetail::EnvironmentJsonlObserver jsonl(tracePath); + CompileEvent jsonEvent; + jsonEvent.kind = CompileEventKind::Snapshot; + jsonEvent.phase = "validate-rulesets"; + jsonEvent.category = "items"; + jsonEvent.operation = "effective-rule"; + jsonEvent.identity = "STR_SMOKE"; + jsonEvent.source = "SmokeMod"; + jsonEvent.outcome = "present"; + jsonl.onCompileEvent(jsonEvent); + } + std::ifstream trace(tracePath); + std::string line; + std::getline(trace, line); + assert(line.find("\"timestamp\":\"") != std::string::npos); + assert(line.find("Z\"") != std::string::npos); + assert(line.find("\"correlation_id\":\"observer-smoke-correlation\"") != std::string::npos); + assert(line.find("\"sequence\":1") != std::string::npos); + assert(line.find("\"identity\":\"STR_SMOKE\"") != std::string::npos); + std::remove(tracePath); + + return 0; +}