From f632b50c8152d76519a1d972dbe3d00d6b6ce61e Mon Sep 17 00:00:00 2001 From: "Mark E. DeYoung" Date: Sat, 22 Aug 2026 10:51:52 +0200 Subject: [PATCH 01/15] ci: public compile-observer candidate for OXCE 8.6.5 --- .github/workflows/compile-observer-smoke.yml | 60 ++++++ src/Engine/CompileObserver.h | 196 +++++++++++++++++++ src/Engine/Game.cpp | 2 + src/main.cpp | 70 +++++++ tests/CompileObserverCrossTu.cpp | 10 + tests/CompileObserverSmoke.cpp | 114 +++++++++++ 6 files changed, 452 insertions(+) create mode 100644 .github/workflows/compile-observer-smoke.yml create mode 100644 src/Engine/CompileObserver.h create mode 100644 tests/CompileObserverCrossTu.cpp create mode 100644 tests/CompileObserverSmoke.cpp diff --git a/.github/workflows/compile-observer-smoke.yml b/.github/workflows/compile-observer-smoke.yml new file mode 100644 index 0000000000..a81c006f10 --- /dev/null +++ b/.github/workflows/compile-observer-smoke.yml @@ -0,0 +1,60 @@ +name: Compile observer candidate + +on: + push: + branches: + - semper/compile-observer + pull_request: + branches: + - oxce-plus + workflow_dispatch: + +jobs: + observer-contract: + name: Observer contract + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - 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 + + windows-engine-build: + name: Windows engine build + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install XP toolset used by upstream nightly + shell: powershell + run: | + Set-Location "C:\Program Files (x86)\Microsoft Visual Studio\Installer\" + $InstallPath = "C:\Program Files\Microsoft Visual Studio\2022\Enterprise" + $WorkLoads = '--add Microsoft.VisualStudio.Component.WinXP' + $Arguments = ('/c', "vs_installer.exe", 'modify', '--installPath', "`"$InstallPath`"", $WorkLoads, '--quiet', '--norestart', '--nocache') + $process = Start-Process -FilePath cmd.exe -ArgumentList $Arguments -Wait -PassThru -WindowStyle Hidden + if ($process.ExitCode -ne 0) { + throw "XP toolset installation failed with exit code $($process.ExitCode)" + } + + - name: Add MSBuild to PATH + uses: microsoft/setup-msbuild@v2 + + - name: Compile OXCE using upstream nightly configuration + env: + VC_FLAGS: /v:minimal /m /p:BuildInParallel=true /p:PreferredToolArchitecture=x64 /p:Configuration=Release_XP + shell: powershell + run: | + Set-Location src + msbuild OpenXcom.2010.sln $env:VC_FLAGS /p:Platform=Win32 diff --git a/src/Engine/CompileObserver.h b/src/Engine/CompileObserver.h new file mode 100644 index 0000000000..d4ed73a834 --- /dev/null +++ b/src/Engine/CompileObserver.h @@ -0,0 +1,196 @@ +#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 + +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 CompileObserver *&observerSlot() noexcept +{ + static CompileObserver *observer = nullptr; + return observer; +} + +inline bool &failureSlot() noexcept +{ + static bool failed = false; + return failed; +} +} + +/** + * 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::observerSlot() = observer; + CompileObserverDetail::failureSlot() = false; + return previous; +} + +/** + * Returns the currently installed observer, or nullptr when disabled. + */ +inline CompileObserver *getCompileObserver() noexcept +{ + return CompileObserverDetail::observerSlot(); +} + +/** + * Reports whether the installed observer threw while handling an event. + * + * The exception 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..be672dcaf3 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,65 @@ 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"; +} + +/** + * 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(); + + // 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 +189,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..0dce892a00 --- /dev/null +++ b/tests/CompileObserverSmoke.cpp @@ -0,0 +1,114 @@ +/* + * 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 + +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()); + + return 0; +} From 18978a62a04658f504a4c5b872fecccdfdeff811 Mon Sep 17 00:00:00 2001 From: "Mark E. DeYoung" Date: Sat, 22 Aug 2026 10:56:56 +0200 Subject: [PATCH 02/15] ci: retrigger public compile-observer candidate --- .github/workflows/compile-observer-smoke.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/compile-observer-smoke.yml b/.github/workflows/compile-observer-smoke.yml index a81c006f10..2fbff9abd9 100644 --- a/.github/workflows/compile-observer-smoke.yml +++ b/.github/workflows/compile-observer-smoke.yml @@ -1,3 +1,4 @@ +# Public CI surface for the reviewed compile-observer candidate. name: Compile observer candidate on: From 189795f139955e0327e1483b7c0a27a8b39dffd2 Mon Sep 17 00:00:00 2001 From: "Mark E. DeYoung" Date: Sat, 22 Aug 2026 11:01:07 +0200 Subject: [PATCH 03/15] ci: use pinned 8.6.5 Linux engine build gate --- .github/workflows/compile-observer-smoke.yml | 42 ++++++++------------ 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/.github/workflows/compile-observer-smoke.yml b/.github/workflows/compile-observer-smoke.yml index 2fbff9abd9..271ce44d4d 100644 --- a/.github/workflows/compile-observer-smoke.yml +++ b/.github/workflows/compile-observer-smoke.yml @@ -7,7 +7,7 @@ on: - semper/compile-observer pull_request: branches: - - oxce-plus + - semper/oxce-8.6.5-base workflow_dispatch: jobs: @@ -28,34 +28,26 @@ jobs: - name: Run smoke test run: /tmp/compile-observer-smoke - windows-engine-build: - name: Windows engine build - runs-on: windows-latest + linux-engine-build: + name: Full Linux engine build + runs-on: ubuntu-22.04 steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Install XP toolset used by upstream nightly - shell: powershell + - name: Install OXCE build dependencies run: | - Set-Location "C:\Program Files (x86)\Microsoft Visual Studio\Installer\" - $InstallPath = "C:\Program Files\Microsoft Visual Studio\2022\Enterprise" - $WorkLoads = '--add Microsoft.VisualStudio.Component.WinXP' - $Arguments = ('/c', "vs_installer.exe", 'modify', '--installPath', "`"$InstallPath`"", $WorkLoads, '--quiet', '--norestart', '--nocache') - $process = Start-Process -FilePath cmd.exe -ArgumentList $Arguments -Wait -PassThru -WindowStyle Hidden - if ($process.ExitCode -ne 0) { - throw "XP toolset installation failed with exit code $($process.ExitCode)" - } - - - name: Add MSBuild to PATH - uses: microsoft/setup-msbuild@v2 - - - name: Compile OXCE using upstream nightly configuration - env: - VC_FLAGS: /v:minimal /m /p:BuildInParallel=true /p:PreferredToolArchitecture=x64 /p:Configuration=Release_XP - shell: powershell - run: | - Set-Location src - msbuild OpenXcom.2010.sln $env:VC_FLAGS /p:Platform=Win32 + sudo apt-get update + sudo apt-get -yq --no-install-suggests --no-install-recommends install \ + libsdl1.2-dev \ + libsdl-mixer1.2-dev \ + libsdl-image1.2-dev \ + libsdl-gfx1.2-dev + + - name: Configure OXCE + run: cmake . -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr + + - name: Compile full OXCE engine + run: make -j$(nproc) From 8b46622747cfc085baf14faa6b5e87801163c683 Mon Sep 17 00:00:00 2001 From: "Mark E. DeYoung" Date: Sat, 22 Aug 2026 11:16:06 +0200 Subject: [PATCH 04/15] ci: restore Windows build on supported VS2022 runner --- .github/workflows/compile-observer-smoke.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/compile-observer-smoke.yml b/.github/workflows/compile-observer-smoke.yml index 271ce44d4d..0205de1388 100644 --- a/.github/workflows/compile-observer-smoke.yml +++ b/.github/workflows/compile-observer-smoke.yml @@ -51,3 +51,21 @@ jobs: - name: Compile full OXCE engine run: make -j$(nproc) + + windows-engine-build: + name: Full Windows engine build + runs-on: windows-2022 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Add MSBuild to PATH + uses: microsoft/setup-msbuild@v2 + + - name: Compile OXCE Win32 Release + shell: powershell + run: | + Set-Location src + msbuild OpenXcom.2010.sln /v:minimal /m /p:BuildInParallel=true /p:PreferredToolArchitecture=x64 /p:Configuration=Release /p:Platform=Win32 From e1df68939100d1c9ae49e9c91a99c7442f402391 Mon Sep 17 00:00:00 2001 From: "Mark E. DeYoung" Date: Sat, 22 Aug 2026 11:16:28 +0200 Subject: [PATCH 05/15] docs: define SemperSupra experimental build policy --- docs/SEMPERSUPRA-EXPERIMENTAL-BUILDS.md | 154 ++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 docs/SEMPERSUPRA-EXPERIMENTAL-BUILDS.md diff --git a/docs/SEMPERSUPRA-EXPERIMENTAL-BUILDS.md b/docs/SEMPERSUPRA-EXPERIMENTAL-BUILDS.md new file mode 100644 index 0000000000..41cfe0bd1a --- /dev/null +++ b/docs/SEMPERSUPRA-EXPERIMENTAL-BUILDS.md @@ -0,0 +1,154 @@ +# 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. **CI artifact** — downloadable binary intended for bounded testing. Still experimental and unsupported. +3. **GitHub pre-release** — intentionally published preview for broader testing after the relevant parity and runtime gates have passed. + +Do not create a normal GitHub Release for experimental compile-observer work. If a release is needed before the work is upstream-quality, publish it only as a GitHub **Pre-release**. + +At the time this policy was written, the `Compile observer candidate` workflow performs compile/test gates only and does **not** upload downloadable artifacts or create releases. + +## 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.tar.gz +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 + +When downloadable binaries are introduced, the build should embed visible provenance in the executable/version string, for example: + +```text +Extended 8.6.5 (SemperSupra experimental g189795f) +``` + +A renamed archive must not be enough to make a SemperSupra build appear official. + +## BUILD-INFO.txt + +Every downloadable artifact should contain `BUILD-INFO.txt` generated by CI with at least: + +```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: + + +Purpose: + + +Support: + Do not report failures from this build to upstream OXCE unless the + problem is independently reproduced on an official upstream build. +``` + +The file should be generated from immutable build inputs rather than maintained manually inside an archive. + +## Pre-release naming + +If broader distribution becomes useful, use SemperSupra-specific tags rather than upstream-looking semantic-version tags. + +Recommended tag: + +```text +semper-exp---g +``` + +Recommended title: + +```text +UNOFFICIAL — SemperSupra OXCE Experimental Build +Base: OXCE · g +``` + +Mark it as a GitHub **Pre-release**. + +A later, more stable but still non-upstream line can use `semper-preview-...`; reserve normal OXCE version numbers 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. From ac26465ee83396cf388bd1dd2e43ddc81dc3a8a5 Mon Sep 17 00:00:00 2001 From: "Mark E. DeYoung" Date: Sat, 22 Aug 2026 11:30:06 +0200 Subject: [PATCH 06/15] ci: publish rolling experimental build channel --- .github/workflows/compile-observer-smoke.yml | 326 ++++++++++++++++++- 1 file changed, 322 insertions(+), 4 deletions(-) diff --git a/.github/workflows/compile-observer-smoke.yml b/.github/workflows/compile-observer-smoke.yml index 0205de1388..b0cddf390e 100644 --- a/.github/workflows/compile-observer-smoke.yml +++ b/.github/workflows/compile-observer-smoke.yml @@ -1,4 +1,4 @@ -# Public CI surface for the reviewed compile-observer candidate. +# Public CI and rolling experimental distribution surface for the reviewed compile-observer candidate. name: Compile observer candidate on: @@ -10,13 +10,23 @@ on: - 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 + - 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: | @@ -32,34 +42,126 @@ jobs: name: Full Linux engine build runs-on: ubuntu-22.04 steps: - - name: Checkout + - 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 From 60ef59633af7685f64ce58c7ce466958c1f5b9f3 Mon Sep 17 00:00:00 2001 From: "Mark E. DeYoung" Date: Sat, 22 Aug 2026 11:30:30 +0200 Subject: [PATCH 07/15] docs: define rolling experimental release channel --- docs/SEMPERSUPRA-EXPERIMENTAL-BUILDS.md | 89 ++++++++++++++++++++----- 1 file changed, 71 insertions(+), 18 deletions(-) diff --git a/docs/SEMPERSUPRA-EXPERIMENTAL-BUILDS.md b/docs/SEMPERSUPRA-EXPERIMENTAL-BUILDS.md index 41cfe0bd1a..3a744957bc 100644 --- a/docs/SEMPERSUPRA-EXPERIMENTAL-BUILDS.md +++ b/docs/SEMPERSUPRA-EXPERIMENTAL-BUILDS.md @@ -18,12 +18,40 @@ SemperSupra builds must be unmistakably distinguishable from official OXCE build Use three distinct levels. 1. **CI build** — compile/test evidence only. No claim that the output is suitable for users. -2. **CI artifact** — downloadable binary intended for bounded testing. Still experimental and unsupported. -3. **GitHub pre-release** — intentionally published preview for broader testing after the relevant parity and runtime gates have passed. +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. If a release is needed before the work is upstream-quality, publish it only as a GitHub **Pre-release**. +Do not create a normal GitHub Release for experimental compile-observer work. Experimental distribution uses GitHub **Pre-release** semantics only. -At the time this policy was written, the `Compile observer candidate` workflow performs compile/test gates only and does **not** upload downloadable artifacts or create releases. +## 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 @@ -36,7 +64,7 @@ semper-oxce-experimental--g--. Examples: ```text -semper-oxce-experimental-8.6.5-g189795f-linux-x86_64.tar.gz +semper-oxce-experimental-8.6.5-g189795f-linux-x86_64.AppImage semper-oxce-experimental-8.6.5-g189795f-windows-x86.zip ``` @@ -53,17 +81,17 @@ The `semper-` and `experimental` qualifiers are intentional provenance, not deco ## Executable provenance -When downloadable binaries are introduced, the build should embed visible provenance in the executable/version string, for example: +Public experimental CI stamps the executable version metadata at build time, for example: ```text Extended 8.6.5 (SemperSupra experimental g189795f) ``` -A renamed archive must not be enough to make a SemperSupra build appear official. +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 artifact should contain `BUILD-INFO.txt` generated by CI with at least: +Every downloadable package contains generated provenance equivalent to: ```text UNOFFICIAL / EXPERIMENTAL BUILD @@ -84,36 +112,61 @@ SemperSupra source: Candidate commit: -Purpose: - +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. ``` -The file should be generated from immutable build inputs rather than maintained manually inside an archive. +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 +``` -## Pre-release naming +Schema version 1 contains at least: -If broader distribution becomes useful, use SemperSupra-specific tags rather than upstream-looking semantic-version tags. +- 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-exp---g +semper-preview---g ``` Recommended title: ```text -UNOFFICIAL — SemperSupra OXCE Experimental Build +UNOFFICIAL — SemperSupra OXCE Preview Build Base: OXCE · g ``` -Mark it as a GitHub **Pre-release**. - -A later, more stable but still non-upstream line can use `semper-preview-...`; reserve normal OXCE version numbers for upstream. +Normal OXCE semantic-version tags remain reserved for upstream. ## Public CI boundary From 0d65009585896decb10558b1d56a0d6db36ed3f3 Mon Sep 17 00:00:00 2001 From: "Mark E. DeYoung" Date: Sat, 22 Aug 2026 11:41:09 +0200 Subject: [PATCH 08/15] ci: verify public experimental channel end to end --- .github/workflows/compile-observer-smoke.yml | 65 ++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/.github/workflows/compile-observer-smoke.yml b/.github/workflows/compile-observer-smoke.yml index b0cddf390e..cfce17132e 100644 --- a/.github/workflows/compile-observer-smoke.yml +++ b/.github/workflows/compile-observer-smoke.yml @@ -387,3 +387,68 @@ jobs: 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 From 730af4c38d9635c18842cb3b5b001d32636b6822 Mon Sep 17 00:00:00 2001 From: "Mark E. DeYoung" Date: Sat, 22 Aug 2026 12:16:50 +0200 Subject: [PATCH 09/15] feat: publish opt-in JSONL compile observer sink --- src/Engine/CompileObserver.h | 138 ++++++++++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 2 deletions(-) diff --git a/src/Engine/CompileObserver.h b/src/Engine/CompileObserver.h index d4ed73a834..7cf88452e9 100644 --- a/src/Engine/CompileObserver.h +++ b/src/Engine/CompileObserver.h @@ -19,7 +19,12 @@ */ #include +#include #include +#include +#include +#include +#include #include namespace OpenXcom @@ -87,6 +92,91 @@ class CompileObserver 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"; +} + +class EnvironmentJsonlObserver final : public CompileObserver +{ +public: + explicit EnvironmentJsonlObserver(const std::string &path) : + _out(path, std::ios::out | std::ios::trunc) + { + if (!_out) + { + throw std::runtime_error("could not open compile observer trace: " + path); + } + } + + void onCompileEvent(const CompileEvent &event) override + { + _out << "{\"schema\":" << event.schemaVersion << ",\"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; +}; + inline CompileObserver *&observerSlot() noexcept { static CompileObserver *observer = nullptr; @@ -98,6 +188,43 @@ 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; + } +} } /** @@ -108,6 +235,7 @@ inline bool &failureSlot() noexcept inline CompileObserver *setCompileObserver(CompileObserver *observer) noexcept { CompileObserver *previous = CompileObserverDetail::observerSlot(); + CompileObserverDetail::environmentCheckedSlot() = true; CompileObserverDetail::observerSlot() = observer; CompileObserverDetail::failureSlot() = false; return previous; @@ -115,16 +243,22 @@ inline CompileObserver *setCompileObserver(CompileObserver *observer) noexcept /** * 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. + * Reports whether the installed observer threw while handling an event or the + * environment-requested trace sink could not be created. * - * The exception is deliberately contained to preserve authoritative engine + * 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 From de082db9f51d69dd1164ec67014b986b5905a6ef Mon Sep 17 00:00:00 2001 From: "Mark E. DeYoung" Date: Sat, 22 Aug 2026 14:05:59 +0200 Subject: [PATCH 10/15] feat: publish validate-only item/research provenance snapshots --- src/main.cpp | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/main.cpp b/src/main.cpp index be672dcaf3..d8b12c074c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -111,6 +111,91 @@ static bool validateRulesetsRequested() noexcept 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. * @@ -136,6 +221,7 @@ static int validateRulesetsAndExit() 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. From 50a0040f6a6bc52703eb17180e2e53a5441a9efc Mon Sep 17 00:00:00 2001 From: "Mark E. DeYoung" Date: Sat, 22 Aug 2026 14:23:57 +0200 Subject: [PATCH 11/15] docs: define source and validator release boundary --- docs/SEMPERSUPRA-EXPERIMENTAL-BUILDS.md | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/SEMPERSUPRA-EXPERIMENTAL-BUILDS.md b/docs/SEMPERSUPRA-EXPERIMENTAL-BUILDS.md index 3a744957bc..a001f37609 100644 --- a/docs/SEMPERSUPRA-EXPERIMENTAL-BUILDS.md +++ b/docs/SEMPERSUPRA-EXPERIMENTAL-BUILDS.md @@ -205,3 +205,31 @@ 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. From 90e17e97f3f676217b26d5f856b55d0ab2093ec7 Mon Sep 17 00:00:00 2001 From: "Mark E. DeYoung" Date: Sat, 22 Aug 2026 14:35:16 +0200 Subject: [PATCH 12/15] ci: attach source snapshot after successful engine release --- .../experimental-source-snapshot.yml | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 .github/workflows/experimental-source-snapshot.yml diff --git a/.github/workflows/experimental-source-snapshot.yml b/.github/workflows/experimental-source-snapshot.yml new file mode 100644 index 0000000000..c209c17a0c --- /dev/null +++ b/.github/workflows/experimental-source-snapshot.yml @@ -0,0 +1,206 @@ +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" ]]; then + if [[ "$conclusion" != "success" ]]; then + echo "public engine build/release gate did not succeed" >&2 + exit 1 + fi + exit 0 + fi + sleep 20 + done + echo "timed out waiting for 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 + gh release download "$EXPERIMENTAL_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern experimental-build-manifest.json \ + --pattern SHA256SUMS.txt \ + --dir dist + + 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 + import json, os + 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: + with urlopen(Request(url, headers=headers), timeout=120) as response: + return response.read() + + 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 From 9e6e3631e45e32d693d3e0a1725aefb88fc32191 Mon Sep 17 00:00:00 2001 From: "Mark E. DeYoung" Date: Sat, 22 Aug 2026 14:52:59 +0200 Subject: [PATCH 13/15] ci: tolerate release propagation and failed-job reruns --- .../experimental-source-snapshot.yml | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/.github/workflows/experimental-source-snapshot.yml b/.github/workflows/experimental-source-snapshot.yml index c209c17a0c..a665910826 100644 --- a/.github/workflows/experimental-source-snapshot.yml +++ b/.github/workflows/experimental-source-snapshot.yml @@ -51,16 +51,20 @@ jobs: url="$(jq -r '.html_url' <<<"$row")" echo "compile gate: status=${status} conclusion=${conclusion} ${url}" - if [[ "$status" == "completed" ]]; then - if [[ "$conclusion" != "success" ]]; then - echo "public engine build/release gate did not succeed" >&2 - exit 1 - fi + 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 public engine build/release gate" >&2 + echo "timed out waiting for a successful public engine build/release gate" >&2 exit 1 - name: Download and validate current release evidence @@ -70,11 +74,22 @@ jobs: run: | set -euo pipefail mkdir -p dist - gh release download "$EXPERIMENTAL_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --pattern experimental-build-manifest.json \ - --pattern SHA256SUMS.txt \ - --dir 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 @@ -173,7 +188,9 @@ jobs: 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'] @@ -182,8 +199,20 @@ jobs: headers = {'User-Agent': 'SemperSupra-source-snapshot-verifier/1'} def get(url: str) -> bytes: - with urlopen(Request(url, headers=headers), timeout=120) as response: - return response.read() + 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', [])} From 5dd8a675e05ab76359f4c42109fde9c59604f789 Mon Sep 17 00:00:00 2001 From: "Mark E. DeYoung" Date: Sat, 22 Aug 2026 15:39:11 +0200 Subject: [PATCH 14/15] observer: add correlated timestamped event envelope --- src/Engine/CompileObserver.h | 48 ++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/src/Engine/CompileObserver.h b/src/Engine/CompileObserver.h index 7cf88452e9..805af25915 100644 --- a/src/Engine/CompileObserver.h +++ b/src/Engine/CompileObserver.h @@ -18,11 +18,15 @@ * along with OpenXcom. If not, see . */ +#include #include #include +#include #include #include +#include #include +#include #include #include #include @@ -107,11 +111,45 @@ inline const char *eventKindName(CompileEventKind kind) noexcept 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) + _out(path, std::ios::out | std::ios::trunc), + _correlationId(environmentCorrelationId()) { if (!_out) { @@ -121,7 +159,11 @@ class EnvironmentJsonlObserver final : public CompileObserver void onCompileEvent(const CompileEvent &event) override { - _out << "{\"schema\":" << event.schemaVersion << ",\"kind\":"; + _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); @@ -175,6 +217,8 @@ class EnvironmentJsonlObserver final : public CompileObserver } std::ofstream _out; + std::string _correlationId; + std::uint64_t _sequence = 0; }; inline CompileObserver *&observerSlot() noexcept From 71c69952f22d098da4ed2380d93f429bd43f088b Mon Sep 17 00:00:00 2001 From: "Mark E. DeYoung" Date: Sat, 22 Aug 2026 15:40:58 +0200 Subject: [PATCH 15/15] test: assert correlated observer JSONL envelope --- tests/CompileObserverSmoke.cpp | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/CompileObserverSmoke.cpp b/tests/CompileObserverSmoke.cpp index 0dce892a00..fd0a7b512e 100644 --- a/tests/CompileObserverSmoke.cpp +++ b/tests/CompileObserverSmoke.cpp @@ -6,6 +6,9 @@ #include "../src/Engine/CompileObserver.h" #include +#include +#include +#include #include #include @@ -110,5 +113,31 @@ int main() 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; }