Compare commits

4 Commits

Author SHA1 Message Date
gamer147
60ac6fc64f Cross-build Windows native bundle on Linux
All checks were successful
Core validation / Linux core gate (push) Successful in 2m13s
Linux release build / Linux x64 artifact (push) Successful in 1m21s
Linux release build / Publish tagged Gitea release (push) Has been skipped
2026-08-03 21:28:18 -04:00
gamer147
304b290a02 Plan Linux-hosted Windows release artifacts 2026-08-03 20:42:08 -04:00
gamer147
cc65e2cb61 Record v0.1.0 release acceptance 2026-08-03 20:26:31 -04:00
gamer147
5fe3cd669c Record non-tag release boundary acceptance
All checks were successful
Linux release build / Linux x64 artifact (push) Successful in 1m26s
Linux release build / Publish tagged Gitea release (push) Successful in 16s
2026-08-03 19:37:34 -04:00
10 changed files with 473 additions and 14 deletions

View File

@@ -49,13 +49,15 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
│ ├── build-linux-x64.sh one-command pinned Linux export/package/smoke pipeline
│ ├── dotnet_publish_proxy.py guarded low-memory Godot export-time publish staging
│ ├── publish_gitea_release.py guarded/retry-safe native Gitea release promotion
│ ├── verify_windows_native.py static AMD64 PE/native-bundle contract verifier
│ ├── bootstrap-godot-linux-x64.sh repo-local Godot 4.7 Linux editor/template bootstrap
│ ├── godot-linux-x64.json immutable Godot editor/template source + SHA-256 manifest
│ ├── install_godot_templates.py selective HTTP-range installer for pinned template members
│ ├── package_linux_x64.py export verifier + notice/checksum/archive packager
│ ├── test_install_godot_templates.py, test_package_linux_x64.py,
│ │ test_dotnet_publish_proxy.py,
│ │ test_publish_gitea_release.py source-only bootstrap/package/publish regressions
│ │ test_publish_gitea_release.py,
│ │ test_verify_windows_native.py source-only bootstrap/package/publish/native regressions
│ ├── sys4load.py loader + disassembler (opcode-decoding)
│ ├── age_opcodes.py GENERATED complete Python ABI view (do not hand-edit)
│ ├── opcodes_build.py generator/linter: vm-map/opcodes.toml -> the 5 artifacts below
@@ -107,7 +109,8 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
├── build/ DERIVED (our-work-side) — generated by tools/; disposable
│ ├── downloads/, toolchains/ immutable dependency caches and repo-local Godot toolchain
│ ├── export/linux-x64/ verified loose Godot Linux export
│ ├── native/linux-x64/ validated FFmpeg shim/shared-library bundle
│ ├── native/linux-x64/ validated Linux FFmpeg shim/shared-library bundle
│ ├── native/win-x64/ validated Windows FFmpeg shim/DLL bundle
│ ├── package/ staged release tree, smoke log, and `.tar.gz` artifact
│ ├── disasm/ <NAME>.asm — human-readable disassembly, one per script
│ ├── text/ extracted text:
@@ -145,7 +148,8 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
│ outside the platform-neutral VM/runtime core
├── native/ authored native runtime boundaries
│ └── age_movie_ffmpeg/ project-owned FFmpeg C ABI, immutable Windows/Linux dependency manifests,
│ and target bootstrap/build scripts (outputs stay under disposable build/)
│ and native/MSVC plus Linux-hosted MinGW target bootstrap/build scripts
│ (outputs stay under disposable build/)
├── tools/frida/ runtime-capture + engine-dump scripts (see tools/frida/README.md)
└── godot/ DELIVERABLE — the Godot/C# ADV front-end (references Age.Engine
plus the optional exact Windows text adapter). Its assembly embeds

View File

@@ -77,7 +77,59 @@ branch and manual runs never receive release authority, and no personal secret o
introduced. Promotion rechecks the clean source commit, archive checksum, and accepted smoke result after
artifact download. It is retry-safe but fail-closed: a matching partial release can receive missing assets,
while mismatched release identity or a same-name/different-size asset is never edited, deleted, or overwritten.
The next acceptance gate is a deliberate first version tag and inspection of its five release attachments.
The first hosted develop run containing promotion completed successfully at `f0f5f12` on 2026-08-03 and Gitea
reported the tag-only job as skipped, accepting the non-tag permission boundary. The remaining acceptance gate
was a deliberate first version tag and inspection of its five release attachments. That gate passed with the
lightweight `v0.1.0` tag at `5fe3cd6` on 2026-08-03: the tag build and promotion job completed successfully, and
the Gitea release carries the archive, archive checksum, build metadata, payload ledger, and smoke log. The Linux
CI/CD path is therefore accepted end to end.
## Planned Windows x64 CI/CD artifact
The Windows artifact will be cross-built on the existing Linux runner; neither a Windows runner nor Wine is part
of its build contract. The .NET SDK publishes `win-x64`, the Linux Godot editor exports through the pinned Windows
x86-64 template, and MinGW-w64 compiles the one project-owned native component. The pinned BtbN Windows FFmpeg
SDK already carries the required headers, runtime DLLs, and MinGW `.dll.a` import libraries. The conditional
`Age.Engine.Text.Windows` project contains managed P/Invoke declarations and therefore compiles on Linux without
loading GDI. Wine would add only an execution test, which is explicitly outside the requested CI gate.
The MinGW native foundation was completed and locally accepted on 2026-08-03. The Bash bootstrap resolves the
exact manifest-named SDK instead of an arbitrary previously extracted Windows archive; the matching build script
uses MinGW-w64, suppresses the PE timestamp, statically links its GCC support, and stages only the shim, five
pinned FFmpeg DLLs, and LGPL text. The source-only verifier checks all six images as AMD64 PE, requires the seven
AGE movie ABI exports and five FFmpeg imports, and rejects extra DLLs plus Cygwin/MSYS imports without executing
the result. Both the new MinGW output and the existing MSVC output pass that same contract. Two consecutive
MinGW builds produced the identical shim SHA-256
`9852680ca32bc37200d0b63ad89acb5453ad913bdcf4441a15c8ac9356d4da8c`. This is a compiler-independent contract
comparison, not an expectation that MSVC and MinGW produce byte-identical binaries.
The Windows build command will mirror the accepted Linux entry point while keeping target checks separate. It
will regenerate opcode metadata, selectively install the Windows template from the existing pinned Godot TPZ,
bootstrap/hash-check the pinned Windows FFmpeg SDK, cross-build and inspect `age_movie_ffmpeg.dll`, perform an
exact self-contained `ExportRelease/win-x64` publish, and run the real Godot Windows export. The low-memory publish
proxy will become target-parameterized rather than duplicating its fail-closed project/configuration/RID/output
checks. The accepted Linux path must remain byte- and behavior-compatible throughout this extension.
CI acceptance is structural because the produced executable will not run on the Linux host. The verifier must
require the Windows executable/PCK/self-contained managed directory, `Age.Engine.Text.Windows.dll`, the project
shim, all five pinned FFmpeg DLLs, and the FFmpeg license; reject Linux shared objects and incomplete/cross-target
payloads; and inspect every project-owned PE image as AMD64. The portable ZIP will carry the project license,
third-party notices, clean source/dependency metadata, and a complete payload checksum ledger. A manual packaged
`--package-smoke` command remains available for real-Windows acceptance, but its result is not fabricated or
reported as a hosted CI gate.
Develop, manual, and tag events will run independent Linux and Windows build jobs, each with read-only repository
permission and its own manifest-keyed cache/workflow artifact. Tag promotion will require both jobs, download
both already-built archives, verify their commit/build evidence, and publish two distributable release assets:
`OpenMaidEngine-Himegari-linux-x64.tar.gz` and `OpenMaidEngine-Himegari-windows-x64.zip`. A third small
`RELEASE-SHA256SUMS` attachment will authenticate both archives while keeping the release below Gitea's observed
five-attachment boundary; detailed build metadata, payload ledgers, and logs remain inside each archive and in
the retained workflow artifacts. The release notes will state that Linux received the dynamic package smoke and
Windows received structural cross-target verification only.
Code signing, an installer, Windows-on-Linux execution, and retroactively modifying the accepted Linux-only
`v0.1.0` release are outside this effort. A future signing slice can use `osslsigncode` and protected credentials
without changing how the unsigned portable ZIP is assembled.
## Dependency inventory
@@ -225,7 +277,9 @@ on every future native target rather than relying on a Windows-only DirectShow c
The Windows-x64 spike is now complete. `bootstrap-win64.ps1` verifies the immutable archive SHA before
extraction, and `build-win64.ps1` builds the shim with MSVC and places the DLL, import artifacts, required LGPL
shared libraries, and license under disposable `build/native/win-x64`. The managed resolver accepts
shared libraries, and license under disposable `build/native/win-x64`. The Linux-hosted
`bootstrap-win64.sh`/`build-win64.sh` path builds the same exported ABI with MinGW and applies the shared static
PE/bundle verifier. The managed resolver accepts
`AGE_FFMPEG_NATIVE_DIR` for the isolated gate and otherwise reserves application-local and
`runtimes/<rid>/native` lookup. Representative VFS results are `MVB961` 280x352/500 ms, `MVB238`
280x352/866 ms, `MVB908` 400x400/333 ms, and `CHAPTER` 800x600/12016 ms, all with changing frames and

View File

@@ -1025,8 +1025,49 @@ do not mix mechanical moves with semantic changes.
smoke result, and fixed archive/evidence set after download. Retries may
complete missing attachments on a matching partial release, but mismatches and same-name/different-size
collisions fail without edit, deletion, or overwrite. Pure creation/resume/refusal regressions and workflow
lint pass locally. The immediate acceptance gate is a deliberately chosen first `v*` tag and inspection of
the resulting release/archive/evidence; no tag is created by this slice.
lint pass locally. The first hosted develop run containing this job completed successfully at `f0f5f12` on
2026-08-03 and the promotion job was skipped, accepting the non-tag permission boundary. The remaining gate
then passed with lightweight tag `v0.1.0` at `5fe3cd6`: its hosted build and promotion completed successfully,
and the resulting Gitea release exposes all five expected archive/evidence attachments. This accepts the Linux
CI/CD path end to end; future version tags use the same build-once/promote-on-success route.
**Planned Linux-hosted Windows release artifact (2026-08-03):** extend the accepted pipeline without a
Windows runner or Wine. The technical target and verification boundary live in `docs/platform-portability.md`.
The bounded execution order is:
1. **MinGW native foundation.** Add Linux-hosted bootstrap/build scripts over the existing pinned Windows
FFmpeg manifest and its `.dll.a` import libraries. Produce an AMD64 `age_movie_ffmpeg.dll`, copy the exact
five runtime DLLs/license, and verify PE architecture, exported ABI entry points, and FFmpeg imports. Keep
the existing MSVC path as the independent Windows-local builder.
2. **Cross-export and package contract.** Add the Windows x86-64 Godot preset and selectively pinned template;
parameterize the low-memory publish proxy for `win-x64`; generalize package smoke so the downloaded ZIP can
be tested manually on Windows; and add one Linux-hosted `build-windows-x64.sh` entry point. A new Windows
verifier/packager will reject Linux or incomplete payloads and emit a normalized, licensed, checksummed
`OpenMaidEngine-Himegari-windows-x64.zip`. Synthetic package/proxy regressions and a local WSL cross-export
are the slice gate; executing the `.exe` is not.
3. **Hosted Windows artifact job.** Add a read-only Windows-target job beside the existing Linux job on the
same `ubuntu-latest` runner. Provision/report MinGW, cache only hash-verified Godot/FFmpeg inputs, call the
accepted Windows build command, and retain the ZIP plus structural verification/build evidence. A develop
run must leave tag promotion skipped and keep the existing Linux job green.
4. **Dual-platform tag promotion.** Require both build jobs before promotion, download rather than rebuild
their outputs, refactor the release helper around two platform archives, and publish those two distributable
assets plus one combined archive-checksum attachment. Preserve job-local release authority, retry-safe
missing-asset completion, collision refusal, and the existing Gitea attachment ceiling. No version tag is
created until the user explicitly selects one after the hosted develop proof.
**Slice 1 completed 2026-08-03:** the Linux-hosted bootstrap and MinGW build now consume the exact pinned
Windows SDK and stage the six-file AMD64 native DLL set plus license. A source-only verifier pins PE machine,
seven AGE ABI exports, five FFmpeg imports, exact bundle membership, and the no-Cygwin/MSYS boundary. Its unit
tests are part of core validation. Both the MinGW bundle and the independent MSVC bundle pass the same
contract, and two timestamp-suppressed MinGW builds produced an identical shim hash. Slice 2 is now the active
next step: Windows Godot template/export, target-parameterized publish proxy, structural package contract, and
normalized ZIP.
**Completion gate:** one deliberately selected future tag produces a single Gitea release containing the
Linux `.tar.gz`, Windows `.zip`, and combined archive checksums; both archives bind to the tag commit, Linux
retains its dynamic packaged smoke, Windows passes all structural PE/payload gates, and neither platform job
receives release-write permission. Signing, installers, Wine, Windows runners, and mutation of `v0.1.0` remain
out of scope.
**Not cleanup targets:** generated `build/` output, the two intentional solution files, historical
`docs/superpowers/` plans/specifications, and fidelity-specific complexity that is directly covered by the

View File

@@ -279,14 +279,17 @@ send either the `step-limit` console block or the generated JSON.
## Native FFmpeg movie shim (Windows and Linux x64)
The target-specific dependency manifests pin immutable LGPL shared FFmpeg archives and SHA-256 hashes. The
Windows tools use PowerShell/MSVC; the Linux tools run in Bash with Python 3, curl, a C compiler, binutils, and
the ordinary archive/core utilities available.
The target-specific dependency manifests pin immutable LGPL shared FFmpeg archives and SHA-256 hashes. Windows
development can use PowerShell/MSVC. Linux-hosted builds use Bash with Python 3, curl, MinGW-w64/binutils for the
Windows target, a native C compiler/binutils for Linux, and the ordinary archive/core utilities available.
| Tool | Purpose | Run | Reads → Writes |
|---|---|---|---|
| `native/age_movie_ffmpeg/bootstrap-win64.ps1` | Download, hash-check, extract, and version-check the pinned FFmpeg SDK. Returns the resolved SDK root. | `.\\native\\age_movie_ffmpeg\\bootstrap-win64.ps1 [-Destination <dir>]` | `dependency-win64.json`, network/archive cache → disposable `build/downloads/`, `build/ffmpeg-sdk/` |
| `native/age_movie_ffmpeg/build-win64.ps1` | Discover the MSVC x64 toolchain, build `age_movie_ffmpeg.dll`, and stage its exact shared-library/license dependencies. | `.\\native\\age_movie_ffmpeg\\build-win64.ps1 -SdkRoot <bootstrap-output> [-OutputDirectory <dir>]` | C ABI source + FFmpeg SDK → disposable `build/native/win-x64/` by default |
| `native/age_movie_ffmpeg/bootstrap-win64.sh` | Download, SHA-256-check, and extract the exact pinned Windows FFmpeg SDK, including its MinGW import libraries. Prints the resolved SDK root. | `native/age_movie_ffmpeg/bootstrap-win64.sh [destination]` | `dependency-win64.json`, network/archive cache → disposable `build/downloads/`, `build/ffmpeg-sdk/` |
| `native/age_movie_ffmpeg/build-win64.sh` | Cross-build the AMD64 PE shim with MinGW, stage exactly five FFmpeg DLLs and the license, and run the static native-bundle contract gate. `MINGW_CC` and `MINGW_OBJDUMP` may override the standard tool names. | `sdk_root="$(native/age_movie_ffmpeg/bootstrap-win64.sh)"` then `native/age_movie_ffmpeg/build-win64.sh "$sdk_root" [output-directory]` | C ABI source + Windows FFmpeg SDK → disposable `build/native/win-x64/` by default, including `verification.json` |
| `tools/verify_windows_native.py` | Without executing Windows code, require the exact native bundle, AMD64 PE architecture, all seven ABI exports and five FFmpeg imports, and no Cygwin/MSYS compatibility runtime. Optionally writes the machine-readable verification report used by later packaging. | `python3 -X utf8 tools/verify_windows_native.py <bundle> [--objdump <MinGW-objdump>] [--report <json>]` | disposable Windows native bundle → stdout and optional JSON report |
| `native/age_movie_ffmpeg/bootstrap-linux-x64.sh` | Download, SHA-256-check, extract, and version-check the pinned Linux x64 FFmpeg SDK. Prints the resolved SDK root. | `native/age_movie_ffmpeg/bootstrap-linux-x64.sh [destination]` | `dependency-linux-x64.json`, network/archive cache → disposable `build/downloads/`, `build/ffmpeg-sdk/` |
| `native/age_movie_ffmpeg/build-linux-x64.sh` | Build the ELF64 x86-64 shim; stage the five exact FFmpeg SONAME libraries and license; reject stale `.so` files, unresolved/nonlocal FFmpeg dependencies, a missing `$ORIGIN` runpath, or a glibc requirement newer than the pinned 2.28 baseline. | `sdk_root="$(native/age_movie_ffmpeg/bootstrap-linux-x64.sh)"` then `native/age_movie_ffmpeg/build-linux-x64.sh "$sdk_root" [output-directory]` | C ABI source + FFmpeg SDK → disposable `build/native/linux-x64/` by default |
| `tools/movie-corpus-gate` | Discover every MPEG program stream stored under an `.AGF` catalog entry; decode every video frame and, when present, every audio block through the unpaced FFmpeg session; validate independent sequence dimensions, metadata, RGBA size, stereo finite PCM, monotonic video/audio timestamps, EOF, timeout, and teardown; then emit a per-asset JSON report. Audio fields include sample rate/channels, block and PCM-frame counts, first/last PTS, signal presence, and decode time. Exit 0 means the expected corpus count and every asset passed; exit 1 is a gate failure; exit 2 means the native shim is absent. | `dotnet run --project tools/movie-corpus-gate -- --output build/movie-corpus-ffmpeg.json --expected-count 213 --max-item-ms 30000` · optional `--native-dir <dir>` | `SYS4INI.BIN` + loose/ALF VFS assets + staged FFmpeg shim → stdout progress + disposable `build/movie-corpus-ffmpeg.json` |

View File

@@ -23,10 +23,10 @@ if ($actualHash -ne $manifest.sha256) {
if (-not (Test-Path -LiteralPath $extractRoot)) {
Expand-Archive -LiteralPath $archivePath -DestinationPath $Destination
}
$sdkRoot = Get-ChildItem -LiteralPath $Destination -Directory |
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'include\libavformat\avformat.h') } |
Select-Object -First 1 -ExpandProperty FullName
if (-not $sdkRoot) { throw "FFmpeg SDK was not found under $Destination" }
$sdkRoot = $extractRoot
if (-not (Test-Path -LiteralPath (Join-Path $sdkRoot 'include\libavformat\avformat.h'))) {
throw "Pinned FFmpeg SDK was not found at $sdkRoot"
}
$reported = & (Join-Path $sdkRoot 'bin\ffmpeg.exe') -version | Select-Object -First 1
$expectedVersion = $manifest.ffmpeg_version -replace '-20260721$', ''
if ($reported -notlike "*$expectedVersion*" ) {

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "$script_dir/../.." && pwd)"
destination="${1:-$repo_root/build/ffmpeg-sdk}"
manifest="$script_dir/dependency-win64.json"
manifest_value() {
python3 -c 'import json, sys; print(json.load(open(sys.argv[1], encoding="utf-8"))[sys.argv[2]])' \
"$manifest" "$1"
}
for command in python3 curl sha256sum awk; do
command -v "$command" >/dev/null 2>&1 || {
echo "required command was not found: $command" >&2
exit 1
}
done
archive="$(manifest_value archive)"
url="$(manifest_value url)"
expected_hash="$(manifest_value sha256)"
archive_root="${archive%.zip}"
download_directory="$repo_root/build/downloads"
archive_path="$download_directory/$archive"
sdk_root="$destination/$archive_root"
mkdir -p -- "$download_directory" "$destination"
if [[ ! -f "$archive_path" ]]; then
curl --fail --location --retry 3 --output "$archive_path" "$url"
fi
actual_hash="$(sha256sum "$archive_path" | awk '{ print $1 }')"
if [[ "$actual_hash" != "$expected_hash" ]]; then
echo "FFmpeg archive SHA-256 mismatch: expected $expected_hash, got $actual_hash" >&2
exit 1
fi
if [[ ! -d "$sdk_root" ]]; then
python3 -m zipfile -e "$archive_path" "$destination"
fi
for required in \
"$sdk_root/include/libavformat/avformat.h" \
"$sdk_root/lib/libavformat.dll.a" \
"$sdk_root/lib/libavcodec.dll.a" \
"$sdk_root/lib/libavutil.dll.a" \
"$sdk_root/lib/libswscale.dll.a" \
"$sdk_root/lib/libswresample.dll.a" \
"$sdk_root/bin/ffmpeg.exe" \
"$sdk_root/LICENSE.txt"; do
if [[ ! -e "$required" ]]; then
echo "FFmpeg SDK file was not found after extraction: $required" >&2
exit 1
fi
done
printf '%s\n' "$sdk_root"

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 1 || $# -gt 2 ]]; then
echo "usage: $0 <ffmpeg-sdk-root> [output-directory]" >&2
exit 2
fi
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "$script_dir/../.." && pwd)"
sdk_root="$(cd -- "$1" && pwd)"
output_directory_input="${2:-$repo_root/build/native/win-x64}"
mkdir -p -- "$output_directory_input"
output_directory="$(cd -- "$output_directory_input" && pwd)"
include_directory="$sdk_root/include"
library_directory="$sdk_root/lib"
binary_directory="$sdk_root/bin"
compiler="${MINGW_CC:-x86_64-w64-mingw32-gcc}"
objdump="${MINGW_OBJDUMP:-x86_64-w64-mingw32-objdump}"
shim="$output_directory/age_movie_ffmpeg.dll"
for command in "$compiler" "$objdump" python3; do
command -v "$command" >/dev/null 2>&1 || {
echo "required command was not found: $command" >&2
exit 1
}
done
for required in \
"$include_directory/libavformat/avformat.h" \
"$library_directory/libavformat.dll.a" \
"$library_directory/libavcodec.dll.a" \
"$library_directory/libavutil.dll.a" \
"$library_directory/libswscale.dll.a" \
"$library_directory/libswresample.dll.a" \
"$sdk_root/LICENSE.txt"; do
if [[ ! -e "$required" ]]; then
echo "missing FFmpeg SDK file: $required" >&2
exit 1
fi
done
"$compiler" \
-std=c11 -O2 -Wall -Wextra -static-libgcc \
-I "$include_directory" \
-shared -Wl,--no-undefined -Wl,--no-insert-timestamp \
-o "$shim" "$script_dir/age_movie.c" \
-L "$library_directory" \
-lavformat -lavcodec -lavutil -lswscale -lswresample
runtime_libraries=(
avformat-62.dll
avcodec-62.dll
avutil-60.dll
swscale-9.dll
swresample-6.dll
)
for runtime in "${runtime_libraries[@]}"; do
source_library="$binary_directory/$runtime"
if [[ ! -f "$source_library" ]]; then
echo "missing FFmpeg runtime library: $source_library" >&2
exit 1
fi
cp -- "$source_library" "$output_directory/$runtime"
done
cp -- "$sdk_root/LICENSE.txt" "$output_directory/FFmpeg-LICENSE.txt"
python3 -X utf8 "$repo_root/tools/verify_windows_native.py" \
"$output_directory" \
--objdump "$objdump" \
--report "$output_directory/verification.json"
printf '%s\n' "$shim"

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env python3
from __future__ import annotations
import struct
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
import verify_windows_native
def write_pe(path: Path, machine: int = verify_windows_native.PE_MACHINE_AMD64) -> None:
data = bytearray(128)
data[:2] = b"MZ"
struct.pack_into("<I", data, 0x3C, 64)
data[64:68] = b"PE\0\0"
struct.pack_into("<H", data, 68, machine)
path.write_bytes(data)
def objdump_text(*, omit_export: str | None = None, extra_import: str | None = None) -> str:
lines = [*(f"DLL Name: {name}" for name in verify_windows_native.RUNTIME_DLLS)]
lines.extend(
f"[ 0] {name}" for name in sorted(verify_windows_native.REQUIRED_EXPORTS)
if name != omit_export
)
if extra_import is not None:
lines.append(f"DLL Name: {extra_import}")
return "\n".join(lines)
def create_bundle(root: Path) -> Path:
for name in (verify_windows_native.SHIM, *verify_windows_native.RUNTIME_DLLS):
write_pe(root / name)
(root / "FFmpeg-LICENSE.txt").write_text("LGPL\n", encoding="utf-8")
return root
class VerifyWindowsNativeTests(unittest.TestCase):
def test_accepts_exact_amd64_bundle_and_contract(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
bundle = create_bundle(Path(temporary))
completed = Mock(returncode=0, stdout=objdump_text(), stderr="")
with patch.object(verify_windows_native.subprocess, "run", return_value=completed):
report = verify_windows_native.verify_bundle(bundle, "objdump")
self.assertEqual("win-x64", report["target"])
self.assertEqual(len(verify_windows_native.REQUIRED_EXPORTS), len(report["shim_exports"]))
def test_rejects_wrong_machine_and_unexpected_dll(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
bundle = create_bundle(Path(temporary))
write_pe(bundle / verify_windows_native.RUNTIME_DLLS[0], machine=0x014C)
with self.assertRaisesRegex(ValueError, "not AMD64"):
verify_windows_native.verify_bundle(bundle, "objdump")
write_pe(bundle / verify_windows_native.RUNTIME_DLLS[0])
write_pe(bundle / "stale.dll")
with self.assertRaisesRegex(ValueError, "unexpected DLL"):
verify_windows_native.verify_bundle(bundle, "objdump")
def test_rejects_missing_export_and_compatibility_runtime(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
bundle = create_bundle(Path(temporary))
missing = next(iter(verify_windows_native.REQUIRED_EXPORTS))
completed = Mock(
returncode=0,
stdout=objdump_text(omit_export=missing, extra_import="cygwin1.dll"),
stderr="",
)
with patch.object(verify_windows_native.subprocess, "run", return_value=completed):
with self.assertRaisesRegex(ValueError, "compatibility runtime"):
verify_windows_native.verify_bundle(bundle, "objdump")
completed.stdout = objdump_text(omit_export=missing)
with patch.object(verify_windows_native.subprocess, "run", return_value=completed):
with self.assertRaisesRegex(ValueError, "missing AGE ABI export"):
verify_windows_native.verify_bundle(bundle, "objdump")
if __name__ == "__main__":
unittest.main()

View File

@@ -34,6 +34,7 @@ CORE_TESTS = (
"test_package_linux_x64.py",
"test_dotnet_publish_proxy.py",
"test_publish_gitea_release.py",
"test_verify_windows_native.py",
"test_diff_optrace.py",
"test_engine_ctx.py",
"test_ghidra_handler_map.py",

142
tools/verify_windows_native.py Executable file
View File

@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Verify the project-owned Windows x64 FFmpeg bundle without executing it."""
from __future__ import annotations
import argparse
import json
import re
import struct
import subprocess
import sys
from pathlib import Path
from typing import Any
SHIM = "age_movie_ffmpeg.dll"
RUNTIME_DLLS = (
"avformat-62.dll",
"avcodec-62.dll",
"avutil-60.dll",
"swscale-9.dll",
"swresample-6.dll",
)
REQUIRED_FILES = (SHIM, *RUNTIME_DLLS, "FFmpeg-LICENSE.txt")
REQUIRED_EXPORTS = frozenset({
"age_movie_abi_version",
"age_movie_open",
"age_movie_seek",
"age_movie_decode_video",
"age_movie_decode_audio",
"age_movie_last_error",
"age_movie_close",
})
FORBIDDEN_RUNTIME_IMPORTS = frozenset({"cygwin1.dll", "msys-2.0.dll"})
PE_MACHINE_AMD64 = 0x8664
def pe_machine(path: Path) -> int:
with path.open("rb") as stream:
header = stream.read(64)
if len(header) != 64 or header[:2] != b"MZ":
raise ValueError(f"file has no DOS/PE header: {path}")
pe_offset = struct.unpack_from("<I", header, 0x3C)[0]
stream.seek(pe_offset)
pe_header = stream.read(6)
if len(pe_header) != 6 or pe_header[:4] != b"PE\0\0":
raise ValueError(f"file has no PE signature: {path}")
return struct.unpack_from("<H", pe_header, 4)[0]
def parse_objdump(output: str) -> tuple[set[str], set[str]]:
imports = {
match.group(1).strip()
for match in re.finditer(r"^\s*DLL Name:\s*(\S+)\s*$", output, re.MULTILINE)
}
exports = {
name for name in REQUIRED_EXPORTS
if re.search(rf"(?<![A-Za-z0-9_]){re.escape(name)}(?![A-Za-z0-9_])", output)
}
return imports, exports
def verify_bundle(bundle: Path, objdump: str) -> dict[str, Any]:
bundle = bundle.resolve()
if not bundle.is_dir():
raise ValueError(f"Windows native bundle directory was not found: {bundle}")
missing = [name for name in REQUIRED_FILES if not (bundle / name).is_file()]
if missing:
raise ValueError("Windows native bundle is incomplete; missing: " + ", ".join(missing))
expected_dlls = {name.lower() for name in (SHIM, *RUNTIME_DLLS)}
unexpected_dlls = sorted(
path.name
for path in bundle.iterdir()
if path.is_file() and path.suffix.lower() == ".dll" and path.name.lower() not in expected_dlls
)
if unexpected_dlls:
raise ValueError("Windows native bundle has unexpected DLLs: " + ", ".join(unexpected_dlls))
machines: dict[str, str] = {}
for name in (SHIM, *RUNTIME_DLLS):
machine = pe_machine(bundle / name)
if machine != PE_MACHINE_AMD64:
raise ValueError(f"Windows native file is not AMD64 PE (0x{machine:04x}): {name}")
machines[name] = "AMD64"
process = subprocess.run(
(objdump, "-p", str(bundle / SHIM)),
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if process.returncode != 0:
raise RuntimeError(f"objdump failed for {SHIM}: {process.stderr.strip()}")
imports, exports = parse_objdump(process.stdout)
import_names = {name.lower() for name in imports}
missing_imports = sorted(name for name in RUNTIME_DLLS if name.lower() not in import_names)
if missing_imports:
raise ValueError("Windows shim is missing FFmpeg imports: " + ", ".join(missing_imports))
forbidden_imports = sorted(import_names & FORBIDDEN_RUNTIME_IMPORTS)
if forbidden_imports:
raise ValueError("Windows shim imports a non-native compatibility runtime: " + ", ".join(forbidden_imports))
missing_exports = sorted(REQUIRED_EXPORTS - exports)
if missing_exports:
raise ValueError("Windows shim is missing AGE ABI exports: " + ", ".join(missing_exports))
return {
"schema_version": 1,
"target": "win-x64",
"bundle": str(bundle),
"machines": machines,
"shim_imports": sorted(imports, key=str.lower),
"shim_exports": sorted(exports),
}
def main(arguments: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("bundle", type=Path)
parser.add_argument("--objdump", default="x86_64-w64-mingw32-objdump")
parser.add_argument("--report", type=Path)
args = parser.parse_args(arguments)
report = verify_bundle(args.bundle, args.objdump)
rendered = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
if args.report is not None:
args.report.parent.mkdir(parents=True, exist_ok=True)
with args.report.open("w", encoding="utf-8", newline="\n") as stream:
stream.write(rendered)
print(
f"WINDOWS NATIVE OK: dlls={len(report['machines'])} "
f"exports={len(report['shim_exports'])} ffmpeg-imports={len(RUNTIME_DLLS)}"
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, RuntimeError, ValueError) as error:
print(f"Windows native verification failed: {error}", file=sys.stderr)
raise SystemExit(1)