diff --git a/.gitignore b/.gitignore index 0d67169..04dbd49 100755 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /build/ +/out/ __pycache__/ *.py[cod] /private-assets/** diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 deleted file mode 100644 index 8fb244b..0000000 --- a/MANIFEST.sha256 +++ /dev/null @@ -1,38 +0,0 @@ -b3dae83832de92453a8e6e932bbcbe997c58bc9a4b74066ff7e53c908a1abeab .gitignore -5168a04ba9abf1b8143c037f17bbf7e56d84a1fabe6f9a538b3c6035d0f34522 README.md -ad9d3d135525d0ee5b2be814fc84b6217f7d41eb88ac2b7a143e55f2d6c4ecd0 RUN_ME_FIRST.txt -ee5e86375f332c31f6a91fa9bf2a276929af3d6b1669a5f937ef677e914ec37d build-android.sh -3d16bbc8fadf90395353d0b8b4b14454a2aea0dd9a8344cdcd96f04423a3a3e7 code/GameInitialize.pas -af0efa5a633b6de18442174173e9d9f8ac1fafe20979571568c97811d51bf4a8 code/GameViewMain.pas -59e7e9cbd9c4116293770bb2681e4827ea6cdc5323b299aa5e0abf582e6fd3dc code/ProbeAssets.pas -7541f1787bd9fe448dc5ea6922ac4591259c284b6c7228fde32b037cfba04bb2 code/ProbeCamera.pas -e72474c482bfa40700cdab80dcb7b0b113bf0a14daa76c1e75334f1e7380e390 code/ProbePlatform.pas -7709bc7fd77a51b852d09a024237da0a783c3f02162fbbe6bed237db76ad36cc code/ProbeReport.pas -bed130782b3019af6518c1f0fd20b7b1ef06068cf79625765583d9d32e7052f2 config/extra-paths.json -f0dd08f7d300f295d9ac5d861fc73a92b33bb3be372bea96c1a4d5ae9bb6cd94 config/profiles.json -794a2d1eb3af8e9e830874dfe4b1db9da5a1ec040b809f0c9277fa77ec4c63d7 config/upstream.json -34cece697fb7158ddeeb46e9e7f04835a8309c03aa8ea2ad59b359181ba83406 docs/COVERAGE.md -73628325c98e1273422110869293cc90ae7dcd1d633f9b0125b720a134cc0338 docs/FIRST_SERVER_RUN_DIAGNOSIS.md -a70be70ca6764bfc9642e13136e0007de284f19b225ea1c0cc4d9ef628d7a006 docs/SOURCES.md -99776dc1bafcdb98f2204200eb36974dc7477b7c7e2bc74ff1d74da9d329c699 docs/TROUBLESHOOTING.md -41918629f548736f8e508c7f4732e682a3267da1cfe396434326c7b23a20cc4f private-assets/README.md -4c0ed27fb55041d095e3d815cb3bf27b517903a12d7ab4a71e1a8590b659737d reports/preparation/VALIDATION.md -62eead6b2c40c82146d8b7bea5c4080df31fa2b5f3db677276ab70d515c36139 reports/preparation/actual-preflight-SUMMARY.md -a043f356d1cff302ccb3e906d18e6b725abc2be6b839a1aada90c3e83a77de06 reports/preparation/actual-preflight-result.json -719875282fb142ab6afe9eef1d9b3498f47efb4dbf1f97ca24f7ef3542ab1c8d reports/preparation/actual-preflight-toolchain.json -55311bc64f07389b889d3c4de385d2777eb8d044d5c82ac3471c312ba6dbb1cf reports/preparation/android-preflight.log -621ecd1ce1d7f2a35a995ab51d9a6f05ceb1350bcf2907c1415dfec67b6d1774 reports/preparation/environment.json -fe3957cf8d7d2e31d950906db700e277d90422d1fd00568efc9642cea4274d75 reports/preparation/harness-tests.log -694851e41898b814cc346b74f208e968ce970ba597f9b308997fcbb15819c0e3 reports/preparation/manifest-validation.json -5ecf07e8765e01637c2388b3f7e4653f749e972c7d36919eaf315e15fca348ee test-harness.sh -b6c6e8b34c3ef0bad463679f746d03c5174263110171a2cded886e04dae122f0 tests/helpers.py -914aefcca9fa4c7b72db477359503e74ab82c3aa8978925a1d6ce3008f6620f4 tests/test_pipeline.py -5f49078b0f6dd8ef8a1862518ddf949782c35bb0c82e1cae2ae5282a522a3050 tests/test_probe.py -2969d5fd87b45a14a15c2e6812a13e858cec31fd1094006873109e9227161b41 tools/artifacts.py -fc31ac45930fb44ced0c3b4a439eebb549f12d2de6d9b757be46a2c653ece8cc tools/common.py -979bed409a3140c7df1eb371dbb850b3bcbd5ea0fc5a2e9f381fe528bf218595 tools/device.py -08b9399bcebbe2ab7593ce6da5c36bb63832d026c8a7248089a02f7a024a29a6 tools/generate.py -6793b6fb39e7a0fc95aecb76ad768fb9405aecdcee815f81d0a3f122835d0f6d tools/runner.py -3724a3a6e3a0683753592f0c84defe55a8fdc59ce6126e4fc397d6288d2ef4e0 tools/source_audit.py -1ba90c4c89b639b2e15860b1a796b7b037fc1be9c28e3de2e66b73b967a2c29c tools/source_fetch.py -f33d4c3762d17a868cafd915f41a9514ee777ecb1085452becba77c1ba5b618a vendor/README.md diff --git a/README.md b/README.md index ac59dd3..b9ef421 100644 --- a/README.md +++ b/README.md @@ -1,219 +1,59 @@ -# KaM r16020 — Android Architecture Probe 0.3.1 +# KaM Remake Mobile -**Кодовый тест архитектуры: KaM core + Free Pascal + Castle Game Engine → Android.** +This repository is the Android development baseline for the KaM Remake r16020 port. Its upstream authority is KaM Remake r16020, commit `962fb850324b79bbe5480e1c2087d98f83a60917`. It is the permanent single-application build, source, and resource baseline for later port work; it is not yet a complete playable game. -Здесь нет desktop target, готовой игры, игровых бинарных ресурсов или собственного заменителя KaM core. Тест не требует `data/` для проверки компиляции и линковки. Он проверяет, какие **настоящие исходные модули r16020** можно включить в Android-библиотеку вместе с Castle и где возникают конкретные препятствия. +## Build -**Архив содержит код стенда и автоматизацию, не заранее собранный APK.** Во время подготовки проверены Python-скрипты и их контрольные сценарии. Настоящая компиляция Pascal/Android здесь не выполнялась: FPC, Castle, Docker и ADB в среде подготовки отсутствовали. Подробнее: `reports/preparation/VALIDATION.md`. - -## 1. Запуск на сервере — без ресурсов - -Требуются Linux x86_64 или Linux-среда WSL2, Bash, Python **3.10+**, работающий Docker и доступ в интернет. Папка проекта должна быть доступна на запись. На сервере ARM64 Docker-образ Linux/amd64 потребует отдельно настроенной эмуляции; это не основной путь этого теста. +The one supported entrypoint is: ```bash -unzip kam-android-architecture-probe-r16020.zip -cd kam-android-architecture-probe-r16020 -chmod +x build-android.sh test-harness.sh ./build-android.sh ``` -Команда выполняет весь **Android ARM64** цикл, а не сборку Linux-версии игры: +Supported options are `--local`, `--cpu {aarch64,arm,x86_64}`, `--no-fetch`, and `--timeout `. -1. Получает официальный Docker-образ инструментов CGE. При первом запуске фиксирует его неизменяемый digest в `config/toolchain.lock.json`; при следующих использует его же. -2. Загружает **только текстовые исходники** KaM по commit `962fb850324b79bbe5480e1c2087d98f83a60917` и исходники PascalScript по gitlink из этого commit. Полный checkout, история Git, release-архивы и игровые ресурсы не скачиваются. -3. Создаёт 15 изолированных Android-проектов: компилирует и полностью линкует каждый, продолжая остальные проверки после ошибки одной подсистемы. -4. По умолчанию упаковывает три ключевых проекта в APK, когда их линковка успешна: `platform`, `core_types`, `integration`. -5. Сохраняет результаты, конкретные ошибки, версии инструментов, идентификаторы исходников и архив журналов без содержимого ресурсов. +- `--local` uses host tooling while keeping the Android target unchanged. +- `--cpu` selects an ABI for the same application identity. +- `--no-fetch` requires the pinned vendor source to already be present and verified. +- `--timeout` is the per-external-tool-invocation timeout. -**Системные инструменты сборки — бинарные программы и будут скачаны Docker на сервере. Запрет на скачивание игровых бинарных ресурсов соблюдён; полностью автономный комплект SDK/NDK/FPC в этот ZIP не вложен.** Полный оригинальный код KaM также не вложен: его автоматически получает серверный загрузчик по фиксированному commit. В архиве нет ссылок на изменяемый `master` KaM. +Each build is one permanent Castle application transaction. Read `out/current/build.json` for its finalized state. On `PASS`, the current package is described by `out/current/package.json` and is at `out/current/kam-mobile.apk`. -`config/toolchain.lock.json` появляется при первом реальном получении Docker-образа, а не содержит выдуманный digest. Для одинакового окружения на другом сервере перенесите этот lock вместе с проектом. Сам первый выбор CGE-образа зависит от даты запуска; KaM всегда зафиксирован на r16020. +`build//` is transient working state. Its useful logs are `build//logs/compile.log`, `build//logs/package.log`, and, when apksigner is available and invoked, `build//logs/signature.log`. -## 2. Что проверяется +## Source and resources -| Проба | Реальные модули / граница | По умолчанию | -|---|---|---| -| `platform` | CGE + FPC: Android shell, системные потоки, файловые пути, zlib, изображение, input, audio API | Compile + native link + APK | -| `core_types` | `KM_CommonTypes`, `KM_Points`; настоящие функции координат | Compile + native link + APK | -| `core_streams` | `KM_CommonClasses`; настоящая сериализация и зависимости | Compile + native link | -| `mission_tokens` | `KM_MissionScript` | Compile + native link | -| `mission_info` | `KM_MissionScript_Info` | Compile + native link | -| `terrain_io` | `KM_TerrainUtils` | Compile + native link | -| `sprite_loader` | `KM_ResSprites` и его реальные зависимости | Compile + native link | -| `terrain_pathfinding` | `KM_Terrain` и его реальные зависимости | Compile + native link | -| `mission_world` | `KM_MissionScript_Standard` | Compile + native link | -| `simulation` | `KM_Game` как исходный модуль, **не desktop executable** | Compile + native link | -| `script_vm` | `uPSCompiler`, `uPSRuntime` из закреплённого PascalScript | Compile + native link | -| `scripting` | `KM_ScriptingEvents` | Compile + native link | -| `legacy_render` | `KM_Render`, диагностика прежнего backend | Compile + native link; необязательная проба | -| `legacy_audio` | `KM_Sound`, диагностика прежнего backend | Compile + native link; необязательная проба | -| `integration` | Основные корни KaM **вместе с Castle в одной Android-библиотеке** | Compile + native link + APK | +Pinned vendor source is at `vendor/kam_remake`. Project-owned source adaptation and registration are centralized, in order, in `config/extra-paths.json` and `tools/source_resolution.py`. Do not edit generated working manifests for source paths. `code/` is the permanent project-owned application source root; Pascal units enter through normal dependency closure from `GameInitialize`. -Никаких `test_shims`, пустых `KM_*.pas` вместо оригиналов или самостоятельно переписанных MAP/RXX readers в сборочных проектах нет. Модули с транзитивными зависимостями компилируются как есть; их ошибка не скрывается. +Private runtime-resource contents live under `private-assets/`. `config/runtime-resources.json` owns selection and `tools/resource_staging.py` owns staging. Selected files preserve their exact relative paths into Castle data. Proprietary game contents are not committed to public Git, and this baseline neither converts nor repacks them. -Используется один уже существующий upstream define: **`DBG_SKIP_SECURE_AUTH`**. Он отключает необязательный приватный модуль аутентификации multiplayer, недоступный в обычном исходном комплекте. Это локальный Android-проект, не тест сетевого сервиса. Define записывается в каждый соответствующий `target.json`. Остальные игровые подсистемы не отключаются для получения зелёного отчёта. +The permanent application definition is `app/CastleEngineManifest.xml`: `kam_mobile`, `org.kammobile.kamremake`, and `GameInitialize`. -Линковка не отключена через `-Cn`. Стенд запрашивает пересборку (`-B`), отключает smart linking/удаление неиспользуемых секций и включает проверку undefined symbols. Это намеренно более строгая проверка зависимостей, чем пустой вызов одной функции с выброшенным остальным кодом. Проверяются свежие корневые `.ppu`, ELF нужного ABI, отсутствие `DT_TEXTREL`, структура APK и его native dependencies. Если упаковщик удалил только debug symbols, библиотека сопоставляется по неизменным allocated sections и program headers, а не ошибочно отклоняется из-за другой полной SHA-256. Подпись APK проверяется `apksigner`, когда он найден; иначе проверка подписи помечена `NOT_RUN`. +## Optional device helper -## 3. Результаты и коды возврата +After a successful current build, optionally run: -Начинайте с: +```bash +python3 tools/device.py [--serial ] +``` + +It validates the current metadata and APK, selects the authorized device, installs, and launches the application. + +## Current versus historical state + +`out/current/**` is the only current finalized application/package state. `reports/**` and `dist/**` are frozen Task-1 historical evidence; files named `LATEST` there are historical pointers, not current build or package locators. Do not use them when the current build is missing or failed. + +## Repository structure ```text -reports/LATEST-SUMMARY.md -reports/LATEST.json +app/ permanent application definition +code/ permanent project-owned application source +config/ source and runtime-resource owners +tools/ build, source-resolution, resource-staging, and device tools +private-assets/ private runtime-resource contents +vendor/ pinned vendor source +build/ generated transient working state +out/ generated current finalized state +reports/ frozen Task-1 history +dist/ frozen Task-1 history ``` - -Полный результат конкретного запуска: - -```text -reports/runs//SUMMARY.md -reports/runs//result.json -reports/runs///compile-link.log -reports/runs///package.log -reports/runs//source-inventory.json -reports/runs//evidence.zip -``` - -При успешной упаковке: - -```text -dist//kam_probe_platform-aarch64-debug.apk -dist//kam_probe_core_types-aarch64-debug.apk -dist//kam_probe_integration-aarch64-debug.apk -dist/LATEST.json -``` - -Каждый APK появляется **только если соответствующая проба действительно собрана и упакована**. Ошибка `integration` не удаляет успешно полученный `platform` APK. Старые артефакты лежат в своих каталогах build-id и не засчитываются в новый результат. - -| Итог | Exit code | Значение | -|---|---:|---| -| `ANDROID_BUILD_GATE_PASS` | 0 | Все обязательные Android compile/link-пробы и три обязательных APK прошли. Runtime ещё не подразумевается. | -| `NOT_BUILDABLE_AS_IS` | 1 | Обязательная проба встретила ошибку компиляции, линковки или упаковки. Смотреть конкретный журнал. | -| `BLOCKED` | 2 | Проверке мешает отсутствующий инструмент, исходник, окружение или другой незавершённый этап. Не успех. | -| `PARTIAL_SELECTION` | 2 | Выбрана лишь часть профилей. Даже успешный platform APK не считается подтверждением всего core. | - -Отдельные неудачи `legacy_render`/`legacy_audio` не требуют сохранения старых backend и не проваливают gate сами по себе. Но если эти зависимости реально входят в обязательный KaM-корень, ошибка будет видна и заблокирует этот корень. - -**`PASS` означает только указанную проверку.** Компиляция `KM_Game` не означает, что экономика/AI уже запущены, и ошибка `Graphics` не означает «весь KaM нужно переписать». Результат даёт конкретную точку для небольшого Android-адаптера, доработки или подключения существующей библиотеки. Матрица интерпретации есть в `SUMMARY.md` и `docs/COVERAGE.md`. - -## 4. Другие режимы - -Уже установленный CGE/FPC Android toolchain, без Docker: - -```bash -./build-android.sh --local -``` - -`--local` означает использовать инструменты сервера; **target остаётся Android**. `castle-engine`, `fpc`, Java, Android SDK/NDK и cross-compiler должны быть согласованно настроены. Стенд не устанавливает SDK через root и не изменяет системную конфигурацию. - -Повторный запуск с уже загруженным и проверенным кодом: - -```bash -./build-android.sh --no-fetch -``` - -Только проверка Android shell и типов: - -```bash -./build-android.sh --profiles platform,core_types -``` - -Получить APK для каждой успешно слинкованной пробы: - -```bash -./build-android.sh --package-all -``` - -Другие Android ABI, отдельные запуски: - -```bash -./build-android.sh --cpu arm -./build-android.sh --cpu x86_64 -``` - -Android x86_64 — вариант для соответствующего Android-устройства/эмулятора, **не десктопная сборка**. Успех одного ABI не засчитывается за другой. - -Пути к дополнительному исходному пакету или Android native libraries задаются в `config/extra-paths.json`. Пути относительно корня проекта предпочтительны, чтобы одинаково работать внутри Docker и снаружи. По умолчанию списки пусты. Стенд не подставляет другую библиотеку автоматически и не принимает extra path, который обнаруженно перекрывает оригинальный KaM unit. - -## 5. Ресурсы — позже, на вашем сервере - -Для исходной проверки сборки этот раздел можно полностью пропустить. - -Положите рядом каталоги установленной игры, сохранив имена и регистр: - -```text -/srv/kam-r16020/ - data/ - SpriteResource/ - Maps/ - MapsMP/ - Campaigns/ - Tutorials/ -``` - -Содержимое собираете вы из соответствующих установок. Стенд не получает игровые файлы из сети. В Docker папка монтируется read-only; в отдельные сборочные каталоги помещаются её точные копии. - -```bash -./build-android.sh --assets /srv/kam-r16020 -``` - -Это добавляет ресурсы в упаковываемые KaM-пробы (`core_types`, `integration`, либо выбранные с `--package-all`). Независимый `platform` остаётся без игровых ресурсов. Для каждой копии на сервере проверяется SHA-256. При запуске Android получает файлы из assets и копирует в приватный каталог приложения для обычных `TFileStream`-путей, проверяя размер копии. Никакой конвертации `.map/.dat/.rxx` нет. - -**Добавление файлов не запускает миссию автоматически.** Этот режим проверяет доставку данных и нативные файловые пути, а не полноценное декодирование/создание мира. Runtime checksum оригинальных ресурсов и штатные загрузки мира не заявлены как выполненные проверки этого стенда. Большой комплект может заметно увеличить APK, время инициализации и занятое место; для проверки сборки он не нужен. - -Не передавайте проприетарные файлы через публичный Git. `private-assets/`, `vendor/`, `build/`, `dist/` исключены в `.gitignore`. В `evidence.zip` содержимого ресурсов нет, но имена/размеры/хеши могут присутствовать в metadata — просмотрите их перед публичной публикацией. - -## 6. Запуск APK на Android — дополнительный этап - -Для ответа «собирается ли выбранная архитектура?» достаточно серверного compile/link/package-отчёта. Проверка запуска — отдельное свидетельство. - -На машине с ADB и одним авторизованным Android-устройством: - -```bash -python3 tools/device.py --profile platform --exercise-lifecycle -python3 tools/device.py --profile core_types --exercise-lifecycle -python3 tools/device.py --profile integration --exercise-lifecycle -``` - -Указывать профиль имеет смысл только после получения его APK. Несколько устройств: добавьте `--serial `. Скрипт устанавливает/обновляет **только выбранное тестовое приложение**, запускает его и читает приватный отчёт через `run-as`. Старый `runtime.json` этого теста удаляется перед запуском; ресурсы, настройки игры и сохранения не очищаются. ADB не запускается внутри стандартного Docker build-контейнера. - -В APK: процедурная клеточная картинка, drag/pinch/tap, кнопки сохранения отчёта и звука. Автоматически проверяются ABI, native-файлы/seek, FPC zlib, поток и frame callbacks. В профильных APK вызываются настоящие `KMPoint`/`KMPointRight`; в `core_streams` и `integration` — `TKMemoryStreamBinary`. Успешное достижение UI также показывает, что unit initialization импортированного набора не прервало запуск. - -Вывод находится в `reports/device//`. Видимое изображение, удобство жестов и слышимый звук требуют наблюдения: возвращение `SoundEngine.Play` не подменяет проверку звука. КаМ-terrain, здания и симуляция этим APK не изображаются. - -## 7. Проверка самого стенда - -```bash -./test-harness.sh -``` - -Это Python unit/integration tests инструментария без сети, Docker и ресурсов. Они проверяют выбор исходников, пути, gate, генерацию проектов, ELF/APK parser, отказ при старом/неверном артефакте, отказ при пустом выводе компилятора, продолжение после ошибки и приватность evidence. - -**Сквозные контрольные тесты используют явно помеченные временные test doubles компилятора/ELF/APK.** Это тестирование управления сборкой, не эмуляция настоящего KaM и не доказательство компиляции Pascal. Test doubles существуют только в `TemporaryDirectory`; в реальные targets и результаты Android они не попадают. - -## 8. Структура - -```text -build-android.sh Единственная точка входа сборки -code/ Общий Android/CGE runtime стенда -tools/ Загрузка исходников, 15 targets, сборка, аудит, ADB -config/profiles.json Корни проверяемых подсистем -config/upstream.json Зафиксированная база -config/extra-paths.json Явно добавленные зависимости (изначально пусто) -tests/ Тесты инструментария; не игровое ядро -reports/preparation/ Проверки, реально выполненные при подготовке ZIP -docs/ Границы доказательств, источники, разбор ошибок -vendor/ Источники появляются при запуске на сервере -private-assets/ Необязательное закрытое место ваших данных -``` - -`CastleEngineManifest.xml`, `ProbeBuild.pas`, `ProbeImports.pas` создаются отдельно для каждого профиля в `build///`. Корень ZIP сам по себе не является одним CGE-проектом; запускайте `build-android.sh`, а не `castle-engine compile` без target из корня. - -Полноценный порт не требуется для этого теста. Следующее решение принимается по реальному серверному отчёту: устранить обнаруженный общий блокер и повторить те же Android-пробы, не переписывать работающие игровые подсистемы заранее. - -## 0.3.1 — correction after first server report - -- Removed global FPC `-Mdelphi`. KaM units already select Delphi mode through `KaM_Remake.inc`; forcing it globally also changed Castle Game Engine units and broke `CastleLocalizationGetText` before any KaM code was compiled. -- Source inventory now reads Pascal files with `utf-8-sig`, so UTF-8 BOM files such as `src/game/KM_Game.pas` are recognized. -- Added regression tests for both failures. diff --git a/RUN_ME_FIRST.txt b/RUN_ME_FIRST.txt index 4aebb6f..ac337bd 100644 --- a/RUN_ME_FIRST.txt +++ b/RUN_ME_FIRST.txt @@ -1,31 +1,10 @@ -KaM r16020 — Android Architecture Probe 0.3.1 +KaM Remake Mobile -ТОЛЬКО ANDROID. ИГРОВЫХ РЕСУРСОВ И ГОТОВОГО APK В АРХИВЕ НЕТ. +1. Work from this repository root: the Android baseline pinned to KaM Remake r16020, commit 962fb850324b79bbe5480e1c2087d98f83a60917. +2. Ensure the private-assets contents required by config/runtime-resources.json are present on the working server. +3. Run ./build-android.sh, with only these optional flags: --local, --cpu {aarch64,arm,x86_64}, --no-fetch, and --timeout . +4. Read out/current/build.json. On PASS, use out/current/package.json and out/current/kam-mobile.apk. +5. Optionally run: python3 tools/device.py [--serial ] +6. reports/ and dist/ are frozen Task-1 history, not current output. -На Linux-сервере с Python 3.10+ и Docker: - - chmod +x build-android.sh test-harness.sh - ./build-android.sh - -По умолчанию: Android ARM64, 15 настоящих compile/link-проб, -три APK при успешной сборке их профилей. Ни один desktop target не строится. -Исходники r16020 и toolchain автоматически получает сервер; игровые данные не скачиваются. - -Смотреть: - reports/LATEST-SUMMARY.md - reports/runs//evidence.zip - dist//*.apk - -Код возврата: - 0 = обязательный Android build gate пройден; - 1 = обязательная сборка/линковка/упаковка встретила ошибку; - 2 = проверка заблокирована или выбран неполный набор профилей. - -Ресурсы можно подключить ПОЗЖЕ: - ./build-android.sh --assets /srv/kam-r16020 - -Здесь Pascal/APK фактически не собирались. Проверено управление тестами; -среда подготовки не содержала FPC/CGE/Docker. Это кодовый сборочный тест, -а не заявление, что существующий core уже портирован. - -Полная инструкция — README.md. +README.md describes the source, resource, and application owners. diff --git a/app/CastleEngineManifest.xml b/app/CastleEngineManifest.xml new file mode 100644 index 0000000..137bc59 --- /dev/null +++ b/app/CastleEngineManifest.xml @@ -0,0 +1,10 @@ + + + + + + DBG_SKIP_SECURE_AUTH + + + + diff --git a/code/DecisionHostCallback.pas b/code/DecisionHostCallback.pas deleted file mode 100755 index f8183bc..0000000 --- a/code/DecisionHostCallback.pas +++ /dev/null @@ -1,22 +0,0 @@ -unit DecisionHostCallback; -{$mode objfpc}{$H+} -{$ifndef ANDROID}{$fatal Android-only decision host probe}{$endif} -interface -implementation -uses SysUtils,fpjson,CastleWindow,ProbeReport,ProbePlatform,ProbeDecision; -var Window:TCastleWindow; -procedure Initialize; -var R:TProbeReport;Dir:string; -begin - R:=TProbeReport.Create; - try - Dir:=ProbeDirectory;R.Root.Add('report_directory',Dir); - R.Add('android.startup','PASS','Application.OnInitialize reached without a custom TCastleView'); - R.Add('decision.host.callback','PASS','Lifecycle application callback host variant executed'); - RunPlatformChecks(R,Dir);RunDecisionChecks(R,Dir);R.Save(Dir+'runtime.json'); - finally R.Free;end; -end; -initialization - Application.OnInitialize:=@Initialize; - Window:=TCastleWindow.Create(Application);Application.MainWindow:=Window; -end. diff --git a/code/DecisionHostText.pas b/code/DecisionHostText.pas deleted file mode 100755 index 7460fb1..0000000 --- a/code/DecisionHostText.pas +++ /dev/null @@ -1,22 +0,0 @@ -unit DecisionHostText; -{$mode objfpc}{$H+} -{$ifndef ANDROID}{$fatal Android-only text provider probe}{$endif} -interface -implementation -uses cwstring,fpjson,CastleWindow,ProbeReport,ProbePlatform,ProbeTextProvider; -var Window:TCastleWindow; -procedure Initialize; -var R:TProbeReport;Dir:string; -begin - R:=TProbeReport.Create; - try - Dir:=ProbeDirectory;R.Root.Add('report_directory',Dir); - R.Add('android.startup','PASS','Pinned FPC text-provider host initialized'); - R.Add('decision.text.cwstring_unit','PASS','cwstring unit linked and initialized for Android target'); - RunTextProviderChecks(R);R.Save(Dir+'runtime.json'); - finally R.Free;end; -end; -initialization - Application.OnInitialize:=@Initialize; - Window:=TCastleWindow.Create(Application);Application.MainWindow:=Window; -end. diff --git a/code/DecisionHostView.pas b/code/DecisionHostView.pas deleted file mode 100755 index 1e2dab9..0000000 --- a/code/DecisionHostView.pas +++ /dev/null @@ -1,27 +0,0 @@ -unit DecisionHostView; -{$mode objfpc}{$H+} -{$ifndef ANDROID}{$fatal Android-only decision host probe}{$endif} -interface -implementation -uses SysUtils,fpjson,CastleWindow,CastleUIControls,ProbeReport,ProbePlatform,ProbeDecision; -type - TDecisionView=class(TCastleView) - public procedure Start;override;end; -var Window:TCastleWindow; -procedure TDecisionView.Start; -var R:TProbeReport;Dir:string; -begin - inherited;R:=TProbeReport.Create; - try - Dir:=ProbeDirectory;R.Root.Add('report_directory',Dir); - R.Add('android.startup','PASS','Minimal TCastleView Start reached without child controls'); - R.Add('decision.host.view','PASS','TCastleView host variant reached Start'); - RunPlatformChecks(R,Dir);RunDecisionChecks(R,Dir);R.Save(Dir+'runtime.json'); - finally R.Free;end; -end; -procedure Initialize; -begin Window.Container.View:=TDecisionView.Create(Application);end; -initialization - Application.OnInitialize:=@Initialize; - Window:=TCastleWindow.Create(Application);Application.MainWindow:=Window; -end. diff --git a/code/GameInitialize.pas b/code/GameInitialize.pas index 660c43d..8da6fc5 100755 --- a/code/GameInitialize.pas +++ b/code/GameInitialize.pas @@ -1,13 +1,12 @@ unit GameInitialize; {$mode objfpc}{$H+} -{$ifndef ANDROID}{$fatal Android-only architecture probe; there is no desktop target}{$endif} +{$ifndef ANDROID}{$fatal Android-only KaM Remake Mobile application; there is no desktop target}{$endif} interface implementation -uses CastleWindow, GameViewMain; +uses CastleWindow; var Window: TCastleWindow; procedure Initialize; begin - Window.Container.View := TProbeView.Create(Application); end; initialization Application.OnInitialize := @Initialize; diff --git a/code/GameViewMain.pas b/code/GameViewMain.pas deleted file mode 100755 index 58ee6f1..0000000 --- a/code/GameViewMain.pas +++ /dev/null @@ -1,241 +0,0 @@ -unit GameViewMain; -{$mode objfpc}{$H+}{$codepage utf8}{$pointermath on} -interface -uses Classes, SysUtils, CastleUIControls, CastleControls, CastleKeysMouse, - CastleVectors, CastleSoundEngine, ProbeReport, ProbeCamera, ProbeScheduling; -type - TProbeView = class(TCastleView) - private - FStatus,FHelp:TCastleLabel; - FImage:TCastleImageControl; - FSound:TCastleSound; - FReport:TProbeReport; - FDir,FCell:string; - FCamera:TProbeCamera; - FDown:array[0..9] of Boolean; - FPos,FStart:array[0..9] of TVector2; - FMoved,FMulti,FStarted,FListeners:Boolean; - FTaps,FPans,FPinches,FFrames,FUpdates,FPauses,FResumes,FGLCloses,FGLOpens:Integer; - FElapsed:Single; - FSchedule:TDecisionSchedule; - procedure MakeImage; - procedure PlaceImage; - procedure SaveReport; - procedure SaveClicked(Sender:TObject); - procedure SoundClicked(Sender:TObject); - procedure Paused(Sender:TObject); - procedure Resumed(Sender:TObject); - procedure ContextOpened(Sender:TObject); - procedure ContextClosed(Sender:TObject); - function Fingers(out A,B:Integer):Integer; - public - procedure Start; override; - procedure Stop; override; - procedure Render; override; - procedure Update(const SecondsPassed:Single;var HandleInput:Boolean); override; - function Press(const Event:TInputPressRelease):Boolean; override; - function Release(const Event:TInputPressRelease):Boolean; override; - function Motion(const Event:TInputMotion):Boolean; override; - end; -implementation -uses Math, cwstring, fpjson, CastleImages, CastleUriUtils, CastleApplicationProperties, - CastleLog, ProbePlatform, ProbeAssets, ProbeDecision, ProbeTextProvider, ProbeImports, ProbeBuild; -procedure TProbeView.Start; -var B:TCastleButton; DataDir:string; -begin - inherited; - FImage:=TCastleImageControl.Create(FreeAtStop);FImage.Stretch:=True;InsertFront(FImage); - FStatus:=TCastleLabel.Create(FreeAtStop);FStatus.FontSize:=16; - FStatus.Anchor(hpLeft,12);FStatus.Anchor(vpTop,-10);InsertFront(FStatus); - FHelp:=TCastleLabel.Create(FreeAtStop);FHelp.FontSize:=14; - FHelp.Anchor(hpLeft,12);FHelp.Anchor(vpBottom,10);InsertFront(FHelp); - B:=TCastleButton.Create(FreeAtStop);B.Caption:='Save report'; - B.Anchor(hpLeft,12);B.Anchor(vpTop,-90);B.OnClick:=@SaveClicked;InsertFront(B); - B:=TCastleButton.Create(FreeAtStop);B.Caption:='Test sound'; - B.Anchor(hpLeft,175);B.Anchor(vpTop,-90);B.OnClick:=@SoundClicked;InsertFront(B); - FSound:=TCastleSound.Create(FreeAtStop); - FReport:=TProbeReport.Create; - try - FDir:=ProbeDirectory; - FReport.Root.Add('report_directory',FDir); - FReport.Add('android.startup','PASS','CGE application initialized; library and unit initializers reached UI'); - RunPlatformChecks(FReport,FDir); - RunDecisionChecks(FReport,FDir); - FReport.Add('decision.text.cwstring_unit','PASS','cwstring unit linked and initialized in the common Android runtime APK'); - RunTextProviderChecks(FReport); - RunUpstreamChecks(FReport); - FSchedule:=TDecisionSchedule.Create(FReport); - try DataDir:=PrepareOptionalAssets(FReport,FDir); - FReport.Root.Add('native_asset_directory',DataDir); - except on E:Exception do FReport.Add('assets.native_adapter','FAIL',E.ClassName+': '+E.Message);end; - MakeImage; - FCamera.Reset; - FCamera.PixelsPerCell:=Max(4.0,Min((Container.UnscaledWidth-40)/32, - (Container.UnscaledHeight-180)/32)); - FCamera.OffsetY:=40;PlaceImage; - FReport.Add('render.visual_correctness','NOT_RUN','Procedural checker only; inspect on device. Not KaM terrain.'); - FReport.Add('audio.audible','NOT_RUN','Press Test sound and listen; API success does not prove audible output'); - ApplicationProperties.OnPause.Add(@Paused); - ApplicationProperties.OnResume.Add(@Resumed); - ApplicationProperties.OnGLContextOpenObject.Add(@ContextOpened); - ApplicationProperties.OnGLContextCloseObject.Add(@ContextClosed); - FListeners:=True; - FReport.Add('render.context','NOT_RUN','Awaiting the first render callback'); - FStarted:=True; - FStatus.Caption:='Android / '+ProbeProfile+' / '+ProbeCpu+LineEnding+ - 'Automatic smoke: '+FReport.Status+'; device observations separate'+LineEnding+ - 'Architecture probe only. No KaM world or simulation.'; - SaveReport; - except on E:Exception do begin - FReport.Add('android.startup','FAIL',E.ClassName+': '+E.Message); - FStatus.Caption:='STARTUP FAILED: '+E.Message; - if FDir<>'' then SaveReport; - end;end; -end; -procedure TProbeView.MakeImage; -var Image:TRGBAlphaImage;Row:PByte;X,Y:Integer;V:Byte; -begin - Image:=TRGBAlphaImage.Create(256,256); - try - for Y:=0 to 255 do begin - Row:=PByte(Image.RowPtr(Y)); - for X:=0 to 255 do begin - if ((X div 8)+(Y div 8)) mod 2=0 then V:=90 else V:=150; - Row[X*4]:=V;Row[X*4+1]:=V;Row[X*4+2]:=V;Row[X*4+3]:=255; - end; - end; - FImage.Image:=Image;Image:=nil; - finally Image.Free;end; -end; -procedure TProbeView.PlaceImage; -begin - FImage.Left:=FCamera.OffsetX;FImage.Bottom:=FCamera.OffsetY; - FImage.Width:=32*FCamera.PixelsPerCell;FImage.Height:=32*FCamera.PixelsPerCell; - FHelp.Caption:=Format('Synthetic grid | tap %d | pan %d | pinch %d | cell %s', - [FTaps,FPans,FPinches,FCell]); -end; -procedure TProbeView.SaveReport; -var J:TJSONObject; -begin - if (FReport=nil) or (FDir='') then Exit; - try - FReport.Root.Delete('session');J:=TJSONObject.Create; - J.Add('frames',FFrames);J.Add('updates',FUpdates); - J.Add('taps',FTaps);J.Add('pan_events',FPans);J.Add('pinch_events',FPinches); - J.Add('pauses',FPauses);J.Add('resumes',FResumes); - J.Add('gl_open_events',FGLOpens);J.Add('gl_close_events',FGLCloses); - J.Add('last_cell',FCell);J.Add('width',Container.UnscaledWidth); - J.Add('height',Container.UnscaledHeight); - FReport.Root.Add('session',J); - if FFrames>0 then FReport.Check('render.context',ApplicationProperties.IsGLContextOpen,'CGE GL context during render/update'); - if FFrames>=30 then FReport.Add('render.frame_callbacks','PASS','At least 30 render callbacks; visual output still requires observation'); - FReport.Save(FDir+'runtime.json'); - WritelnLog('KaMProbe','KAM_PROBE_REPORT '+FDir+'runtime.json'); - except on E:Exception do begin - WritelnLog('KaMProbe','REPORT_SAVE_FAILED '+E.Message); - FStatus.Caption:='REPORT SAVE FAILED: '+E.Message; - end;end; -end; -procedure TProbeView.SaveClicked(Sender:TObject); -begin SaveReport;FHelp.Caption:='Report: '+FDir+'runtime.json';end; -procedure TProbeView.SoundClicked(Sender:TObject); -begin - try - WriteTestTone(FDir+'generated-test-tone.wav'); - FSound.Url:=FilenameToUriSafe(FDir+'generated-test-tone.wav'); - SoundEngine.Play(FSound); - FReport.Add('audio.api','OBSERVED','CGE Play returned without exception; confirm audibility manually'); - except on E:Exception do FReport.Add('audio.api','FAIL',E.ClassName+': '+E.Message);end; - SaveReport; -end; -procedure TProbeView.Paused(Sender:TObject); -begin Inc(FPauses);if FSchedule<>nil then FSchedule.Paused;FillChar(FDown,SizeOf(FDown),0);FMoved:=True;SaveReport;end; -procedure TProbeView.Resumed(Sender:TObject); -begin Inc(FResumes);if FSchedule<>nil then FSchedule.Resumed;SaveReport;end; -procedure TProbeView.ContextOpened(Sender:TObject); -begin Inc(FGLOpens);end; -procedure TProbeView.ContextClosed(Sender:TObject); -begin Inc(FGLCloses);SaveReport;end; -procedure TProbeView.Stop; -begin - if FListeners then begin - ApplicationProperties.OnPause.Remove(@Paused); - ApplicationProperties.OnResume.Remove(@Resumed); - ApplicationProperties.OnGLContextOpenObject.Remove(@ContextOpened); - ApplicationProperties.OnGLContextCloseObject.Remove(@ContextClosed); - FListeners:=False; - end; - SaveReport;FStarted:=False; - FreeAndNil(FSchedule); - FillChar(FDown,SizeOf(FDown),0);FreeAndNil(FReport); - inherited; -end; -procedure TProbeView.Render; -begin inherited;Inc(FFrames);end; -procedure TProbeView.Update(const SecondsPassed:Single;var HandleInput:Boolean); -begin - inherited;Inc(FUpdates); - if FSchedule<>nil then FSchedule.Tick; - FElapsed:=FElapsed+SecondsPassed; - if FStarted and (FElapsed>=2) then begin SaveReport;FElapsed:=0;end; -end; -function TProbeView.Fingers(out A,B:Integer):Integer; -var I:Integer; -begin - Result:=0;A:=-1;B:=-1; - for I:=0 to High(FDown) do if FDown[I] then begin - Inc(Result);if A<0 then A:=I else if B<0 then B:=I; - end; -end; -function TProbeView.Press(const Event:TInputPressRelease):Boolean; -var A,B,I:Integer; -begin - Result:=inherited;if Result or not FStarted then Exit; - if not Event.IsMouseButton(buttonLeft) then Exit; - I:=Event.FingerIndex;if (I<0) or (I>High(FDown)) then Exit; - if Fingers(A,B)=0 then begin FMoved:=False;FMulti:=False;end; - FDown[I]:=True;FPos[I]:=Event.Position;FStart[I]:=Event.Position; - if Fingers(A,B)>1 then FMulti:=True; - Result:=True; -end; -function TProbeView.Motion(const Event:TInputMotion):Boolean; -var I,N,A,B:Integer;OldDistance,NewDistance:Single;OldMid,NewMid:TVector2; -begin - Result:=inherited; - I:=Event.FingerIndex;if (I<0) or (I>High(FDown)) or not FDown[I] then Exit; - N:=Fingers(A,B); - if N>=2 then begin - OldDistance:=Sqrt(Sqr(FPos[A].X-FPos[B].X)+Sqr(FPos[A].Y-FPos[B].Y)); - OldMid:=(FPos[A]+FPos[B])*0.5; - end; - if Sqr(Event.Position.X-FStart[I].X)+Sqr(Event.Position.Y-FStart[I].Y)>64 then FMoved:=True; - if N=1 then begin - { Do not pan before the movement threshold: a tap must not shift the grid. } - if FMoved then begin - FCamera.Pan(Event.Position.X-FPos[I].X,Event.Position.Y-FPos[I].Y);Inc(FPans); - end; - end; - FPos[I]:=Event.Position; - if N>=2 then begin - NewDistance:=Sqrt(Sqr(FPos[A].X-FPos[B].X)+Sqr(FPos[A].Y-FPos[B].Y)); - NewMid:=(FPos[A]+FPos[B])*0.5; - if (OldDistance>1) and (NewDistance>1) then FCamera.ZoomAt(NewDistance/OldDistance,OldMid.X,OldMid.Y); - FCamera.Pan(NewMid.X-OldMid.X,NewMid.Y-OldMid.Y);Inc(FPinches); - end; - PlaceImage;Result:=True; -end; -function TProbeView.Release(const Event:TInputPressRelease):Boolean; -var I,N,A,B,X,Y:Integer; -begin - Result:=inherited; - if not Event.IsMouseButton(buttonLeft) then Exit; - I:=Event.FingerIndex;if (I<0) or (I>High(FDown)) or not FDown[I] then Exit; - N:=Fingers(A,B);FDown[I]:=False; - if (N=1) and not FMoved and not FMulti then begin - Inc(FTaps); - if FCamera.CellAt(Event.Position.X,Event.Position.Y,32,32,X,Y) then - FCell:=IntToStr(X)+','+IntToStr(Y) else FCell:='outside'; - end; - PlaceImage;SaveReport;Result:=True; -end; -end. diff --git a/code/ProbeAssets.pas b/code/ProbeAssets.pas deleted file mode 100755 index e2e341e..0000000 --- a/code/ProbeAssets.pas +++ /dev/null @@ -1,55 +0,0 @@ -unit ProbeAssets; -{$mode objfpc}{$H+} -interface -uses ProbeReport; -function PrepareOptionalAssets(Report:TProbeReport;const WorkDir:string):string; -implementation -uses Classes,SysUtils,fpjson,jsonparser,CastleDownload,CastleFilesUtils; -function SafeRelative(const S:string):Boolean; -var Parts:TStringList; I:Integer; -begin - Result:=False; - if (S='') or (S[1]='/') or (Pos('\',S)>0) or (Pos(':',S)>0) - or (Pos(#0,S)>0) or (Pos(#10,S)>0) or (Pos(#13,S)>0) then Exit; - Parts:=TStringList.Create; - try - Parts.StrictDelimiter:=True;Parts.Delimiter:='/';Parts.DelimitedText:=S; - for I:=0 to Parts.Count-1 do - if (Parts[I]='') or (Parts[I]='.') or (Parts[I]='..') then Exit; - Result:=True; - finally Parts.Free;end; -end; -function PrepareOptionalAssets(Report:TProbeReport;const WorkDir:string):string; -var Manifest:TJSONData; Files:TJSONArray; I:Integer; Rel,Name,Url:string; - Src:TStream;Dst:TFileStream;Count,Expected:Int64; -begin - Result:=IncludeTrailingPathDelimiter(WorkDir)+'game/'; - Manifest:=GetJSON(FileToString('castle-data:/assets-manifest.json')); - try - Files:=TJSONObject(Manifest).Arrays['files']; - if Files.Count=0 then begin - Report.Add('assets.native_adapter','NOT_RUN','No game resources supplied; code-only build is expected'); - Exit; - end; - Count:=0; - for I:=0 to Files.Count-1 do begin - Rel:=Files.Objects[I].Strings['path']; - if not SafeRelative(Rel) then raise Exception.Create('Unsafe asset name: '+Rel); - Name:=Result+Rel; Url:=Files.Objects[I].Strings['url']; - if Pos('castle-data:/game/',Url)<>1 then raise Exception.Create('Unsupported asset URL'); - Expected:=Files.Objects[I].Int64s['size']; - if not ForceDirectories(ExtractFileDir(Name)) then raise Exception.Create('Cannot create asset directory'); - Src:=Download(Url); - try - Dst:=TFileStream.Create(Name,fmCreate); - try - Dst.CopyFrom(Src,0); - if Dst.Size<>Expected then raise Exception.Create('Size mismatch: '+Rel); - Count:=Count+Dst.Size; - finally Dst.Free;end; - finally Src.Free;end; - end; - Report.Add('assets.native_adapter','PASS',Format('%d files, %d bytes copied to native paths; formats unchanged; NOT full loader proof',[Files.Count,Count])); - finally Manifest.Free;end; -end; -end. diff --git a/code/ProbeCamera.pas b/code/ProbeCamera.pas deleted file mode 100755 index 08dc98b..0000000 --- a/code/ProbeCamera.pas +++ /dev/null @@ -1,33 +0,0 @@ -unit ProbeCamera; -{$mode delphi}{$H+} -{$ASSERTIONS ON}{$R+}{$Q+}{$B-} -interface -uses Math; -type - TProbeCamera = record - OffsetX, OffsetY, PixelsPerCell: Double; - procedure Reset; - procedure Pan(DX,DY: Double); - procedure ZoomAt(Factor,SX,SY: Double); - function CellAt(SX,SY: Double;Cols,Rows:Integer;out X,Y:Integer):Boolean; - end; -implementation -procedure TProbeCamera.Reset; -begin OffsetX:=20;OffsetY:=20;PixelsPerCell:=10;end; -procedure TProbeCamera.Pan(DX,DY:Double); -begin OffsetX:=OffsetX+DX;OffsetY:=OffsetY+DY;end; -procedure TProbeCamera.ZoomAt(Factor,SX,SY:Double); -var WX,WY,NewScale:Double; -begin - if (Factor<=0) or IsNan(Factor) or IsInfinite(Factor) then Exit; - WX:=(SX-OffsetX)/PixelsPerCell;WY:=(SY-OffsetY)/PixelsPerCell; - NewScale:=EnsureRange(PixelsPerCell*Factor,2.0,128.0); - PixelsPerCell:=NewScale;OffsetX:=SX-WX*NewScale;OffsetY:=SY-WY*NewScale; -end; -function TProbeCamera.CellAt(SX,SY:Double;Cols,Rows:Integer;out X,Y:Integer):Boolean; -begin - X:=Floor((SX-OffsetX)/PixelsPerCell)+1; - Y:=Rows-Floor((SY-OffsetY)/PixelsPerCell); - Result:=(X>=1) and (X<=Cols) and (Y>=1) and (Y<=Rows); -end; -end. diff --git a/code/ProbeDecision.pas b/code/ProbeDecision.pas deleted file mode 100755 index df420a4..0000000 --- a/code/ProbeDecision.pas +++ /dev/null @@ -1,120 +0,0 @@ -unit ProbeDecision; -{$mode objfpc}{$H+}{$codepage utf8} -{$ifndef ANDROID}{$fatal Decision probes are Android-only}{$endif} -interface -uses ProbeReport; -procedure RunDecisionChecks(Report:TProbeReport;const WorkDir:string); -implementation -uses Classes,SysUtils,fpjson,jsonparser,CastleDownload,CastleFilesUtils; -procedure WriteAscii(const Name,Value:string); -var F:TFileStream; A:AnsiString; -begin - A:=AnsiString(Value);F:=TFileStream.Create(Name,fmCreate); - try if Length(A)>0 then F.WriteBuffer(A[1],Length(A));finally F.Free;end; -end; -function ReadAscii(const Name:string):string; -var F:TFileStream; A:AnsiString; -begin - Result:='';F:=TFileStream.Create(Name,fmOpenRead or fmShareDenyWrite); - try SetLength(A,F.Size);if Length(A)>0 then F.ReadBuffer(A[1],Length(A));Result:=string(A);finally F.Free;end; -end; -procedure ProbeRenameReplace(Report:TProbeReport;const WorkDir:string); -var Target,Temp,Detail:string; Ok:Boolean; -begin - Target:=WorkDir+'decision-rename-target.txt';Temp:=Target+'.tmp'; - try - WriteAscii(Target,'old');WriteAscii(Temp,'new'); - Ok:=RenameFile(Temp,Target); - if Ok then Ok:=(ReadAscii(Target)='new') and not FileExists(Temp); - Detail:='replace_existing=' + LowerCase(BoolToStr(Ok,True)); - if (not Ok) and FileExists(Temp) then DeleteFile(Temp); - Report.Add('decision.persistence.rename_replace','OBSERVED',Detail); - except on E:Exception do - Report.Add('decision.persistence.rename_replace','OBSERVED','replace_existing=false; error='+E.ClassName+': '+E.Message); - end; -end; -procedure ProbeAppend(Report:TProbeReport;const WorkDir:string); -var Name:string;F:TFileStream;A:AnsiString;Ok:Boolean; - procedure Append(const S:AnsiString); - begin - F:=TFileStream.Create(Name,fmOpenReadWrite or fmShareDenyNone); - try F.Position:=F.Size;if Length(S)>0 then F.WriteBuffer(S[1],Length(S));finally F.Free;end; - end; -begin - Name:=WorkDir+'decision-append.log'; - try - WriteAscii(Name,'A'+LineEnding);Append('B'+LineEnding);Append('C'+LineEnding); - A:=AnsiString(ReadAscii(Name)); - Ok:=A=AnsiString('A'+LineEnding+'B'+LineEnding+'C'+LineEnding); - Report.Add('decision.persistence.append_reopen','OBSERVED', - 'append_reopen_ordered='+LowerCase(BoolToStr(Ok,True))+'; bytes='+IntToStr(Length(A))); - except on E:Exception do - Report.Add('decision.persistence.append_reopen','OBSERVED','append_reopen_ordered=false; error='+E.ClassName+': '+E.Message); - end; -end; -function FirstAsset(out Rel,Url:string;out Expected:Int64):Boolean; -var Manifest:TJSONData;Files:TJSONArray; -begin - Result:=False;Rel:='';Url:='';Expected:=-1; - Manifest:=GetJSON(FileToString('castle-data:/assets-manifest.json')); - try - Files:=TJSONObject(Manifest).Arrays['files']; - if Files.Count=0 then Exit; - Rel:=Files.Objects[0].Strings['path']; - Url:=Files.Objects[0].Strings['url']; - Expected:=Files.Objects[0].Int64s['size']; - Result:=True; - finally Manifest.Free;end; -end; -procedure ProbeCastleStream(Report:TProbeReport); -var S:TStream;Rel,Url:string;Expected,SizeValue:Int64;N:Integer;B:array[0..31] of Byte;SizeOk,SeekOk:Boolean; -begin - if not FirstAsset(Rel,Url,Expected) then begin - Report.Add('decision.fixture.castle_stream','NOT_RUN','No server fixture selected; pass --assets and --asset-path');Exit; - end; - SizeOk:=False;SeekOk:=False;SizeValue:=-1; - try - S:=Download(Url); - try - try SizeValue:=S.Size;SizeOk:=SizeValue=Expected;except SizeOk:=False;end; - try - if Expected>SizeOf(B) then S.Position:=Expected div 2 else S.Position:=0; - N:=S.Read(B,SizeOf(B));SeekOk:=N>0; - except SeekOk:=False;end; - finally S.Free;end; - Report.Add('decision.fixture.castle_stream','OBSERVED', - Format('path=%s; expected_size=%d; size=%d; size_matches=%s; seek_read=%s', - [Rel,Expected,SizeValue,LowerCase(BoolToStr(SizeOk,True)),LowerCase(BoolToStr(SeekOk,True))])); - except on E:Exception do - Report.Add('decision.fixture.castle_stream','OBSERVED','path='+Rel+'; open=false; error='+E.ClassName+': '+E.Message); - end; -end; -procedure ProbeMaterialize(Report:TProbeReport;const WorkDir:string); -var S:TStream;F:TFileStream;Rel,Url,Name:string;Expected:Int64;Ok:Boolean; -begin - if not FirstAsset(Rel,Url,Expected) then begin - Report.Add('decision.fixture.materialized_native_path','NOT_RUN','No server fixture selected; pass --assets and --asset-path');Exit; - end; - Name:=WorkDir+'decision-materialized.dat';Ok:=False; - try - S:=Download(Url); - try - F:=TFileStream.Create(Name,fmCreate); - try F.CopyFrom(S,0);finally F.Free;end; - finally S.Free;end; - F:=TFileStream.Create(Name,fmOpenRead or fmShareDenyWrite); - try Ok:=F.Size=Expected;finally F.Free;end; - Report.Add('decision.fixture.materialized_native_path','OBSERVED', - 'source='+Rel+'; supported='+LowerCase(BoolToStr(Ok,True))+'; native_path='+Name+'; bytes='+IntToStr(Expected)); - except on E:Exception do - Report.Add('decision.fixture.materialized_native_path','OBSERVED','source='+Rel+'; supported=false; error='+E.ClassName+': '+E.Message); - end; -end; -procedure RunDecisionChecks(Report:TProbeReport;const WorkDir:string); -begin - ProbeRenameReplace(Report,WorkDir); - ProbeAppend(Report,WorkDir); - ProbeCastleStream(Report); - ProbeMaterialize(Report,WorkDir); -end; -end. diff --git a/code/ProbePlatform.pas b/code/ProbePlatform.pas deleted file mode 100755 index 12297f9..0000000 --- a/code/ProbePlatform.pas +++ /dev/null @@ -1,131 +0,0 @@ -unit ProbePlatform; -{$mode objfpc}{$H+}{$codepage utf8} -{$ifndef ANDROID}{$fatal This probe may only be compiled for Android}{$endif} -interface -uses Classes, SysUtils, ProbeReport; -function ProbeDirectory: string; -procedure RunPlatformChecks(Report: TProbeReport; const WorkDir: string); -procedure WriteTestTone(const FileName: string); -implementation -uses Math, ZStream, CastleUriUtils, ProbeBuild; -type - TSumThread = class(TThread) - public - Sum: Int64; - procedure Execute; override; - end; -procedure TSumThread.Execute; -var I: Integer; -begin - Sum := 0; - for I := 1 to 10000 do Sum := Sum + I; -end; -function ProbeDirectory: string; -begin - Result := UriToFilenameSafe(ResolveCastleConfigUrl('castle-config:/kam-architecture-probe/')); - if Result = '' then raise Exception.Create('No native path for castle-config'); - Result := IncludeTrailingPathDelimiter(Result); - if not ForceDirectories(Result) then raise Exception.Create('Cannot create private probe directory'); -end; -procedure RunPlatformChecks(Report: TProbeReport; const WorkDir: string); -var - B: array[0..4095] of Byte; - ReadBack: array[0..4095] of Byte; - I: Integer; N: LongInt; LE: Cardinal; - Name, S: string; - FileStream: TFileStream; - Raw, Compressed: TMemoryStream; - C: TCompressionStream; D: TDecompressionStream; - W: TSumThread; - Counter: TStringList; - Launch: Integer; -begin - LE := $04030201; - Report.Check('abi.pointer', SizeOf(Pointer) = ProbePointerBytes, - Format('pointer=%d integer=%d nativeint=%d extended=%d', - [SizeOf(Pointer),SizeOf(Integer),SizeOf(NativeInt),SizeOf(Extended)])); - Report.Check('abi.scalar_layout', (SizeOf(Integer)=4) and (SizeOf(Single)=4) - and (SizeOf(Double)=8) and (SizeOf(Int64)=8), 'Fixed-width scalar assumptions'); - Report.Check('abi.endianness', PByte(@LE)^=1, 'Little-endian native byte order'); - for I := 0 to High(B) do B[I] := Byte(I mod 251); - try - Name := WorkDir + 'native-I-O-тест.bin'; - FileStream := TFileStream.Create(Name, fmCreate); - try FileStream.WriteBuffer(B, SizeOf(B)); finally FileStream.Free; end; - FillChar(ReadBack, SizeOf(ReadBack), 0); - FileStream := TFileStream.Create(Name, fmOpenRead or fmShareDenyWrite); - try - FileStream.Seek(1024, soBeginning); - FileStream.ReadBuffer(ReadBack[1024], 32); - Report.Check('files.seek', CompareMem(@B[1024],@ReadBack[1024],32), 'Native TFileStream seek/read'); - FileStream.Position := 0; FileStream.ReadBuffer(ReadBack,SizeOf(ReadBack)); - Report.Check('files.native_utf8', CompareMem(@B,@ReadBack,SizeOf(B)), 'UTF-8 path / native filename round trip'); - finally FileStream.Free; end; - Name := WorkDir + 'CaseProbe.txt'; - FileStream := TFileStream.Create(Name,fmCreate); FileStream.Free; - Report.Check('files.case_sensitive', not FileExists(WorkDir+'caseprobe.txt'), 'Android case-sensitive lookup; asset names must match'); - except on E:Exception do Report.Add('files.native','FAIL',E.ClassName+': '+E.Message); end; - try - Raw := TMemoryStream.Create; Compressed := TMemoryStream.Create; - try - C := TCompressionStream.Create(clDefault, Compressed); - try C.WriteBuffer(B,SizeOf(B)); finally C.Free; end; - Compressed.Position := 0; - D := TDecompressionStream.Create(Compressed); - try - repeat - N := D.Read(ReadBack,SizeOf(ReadBack)); - if N>0 then Raw.WriteBuffer(ReadBack,N); - until N=0; - finally D.Free; end; - Report.Check('fpc.zstream', (Raw.Size=SizeOf(B)) and CompareMem(Raw.Memory,@B,SizeOf(B)), - 'Real FPC zlib stream round trip; not a KaM resource decoder test'); - finally Raw.Free; Compressed.Free; end; - except on E:Exception do Report.Add('fpc.zstream','FAIL',E.ClassName+': '+E.Message); end; - try - W := TSumThread.Create(True); - try - W.FreeOnTerminate := False; W.Start; W.WaitFor; - Report.Check('fpc.thread',W.Sum=50005000,'Native worker thread joined; no GUI/simulation callbacks'); - finally W.Free; end; - except on E:Exception do Report.Add('fpc.thread','FAIL',E.ClassName+': '+E.Message); end; - try - Counter := TStringList.Create; - try - Name := WorkDir + 'launch-counter.txt'; Launch := 1; - if FileExists(Name) then begin - Counter.LoadFromFile(Name); - Launch := StrToInt(Trim(Counter.Text))+1; - end; - Counter.Text := IntToStr(Launch); Counter.SaveToFile(Name); - S := 'launch_count='+IntToStr(Launch)+'; compare after force-stop/relaunch'; - if Launch>1 then Report.Add('files.persisted_counter','OBSERVED',S) - else Report.Add('files.persisted_counter','NOT_RUN',S); - finally Counter.Free; end; - except on E:Exception do Report.Add('files.persisted_counter','FAIL',E.ClassName+': '+E.Message); end; -end; -procedure WriteTestTone(const FileName: string); -const SampleRate=22050; Samples=5512; -var F:TFileStream; I:Integer; V:SmallInt; - procedure U16(Value:Word); - var B:array[0..1] of Byte; - begin B[0]:=Value and 255; B[1]:=Value shr 8; F.WriteBuffer(B,2); end; - procedure U32(Value:Cardinal); - var B:array[0..3] of Byte; - begin B[0]:=Value and 255; B[1]:=(Value shr 8) and 255; - B[2]:=(Value shr 16) and 255; B[3]:=Value shr 24; F.WriteBuffer(B,4); end; - procedure FourCC(const S:AnsiString); - begin F.WriteBuffer(S[1],4); end; -begin - F:=TFileStream.Create(FileName,fmCreate); - try - FourCC('RIFF');U32(36+Samples*2);FourCC('WAVE');FourCC('fmt '); - U32(16);U16(1);U16(1);U32(SampleRate);U32(SampleRate*2);U16(2);U16(16); - FourCC('data');U32(Samples*2); - for I:=0 to Samples-1 do begin - V:=Round(3000*Sin(2*Pi*440*I/SampleRate)); - U16(Word(Integer(V) and $FFFF)); - end; - finally F.Free; end; -end; -end. diff --git a/code/ProbeReport.pas b/code/ProbeReport.pas deleted file mode 100755 index 4d2ad40..0000000 --- a/code/ProbeReport.pas +++ /dev/null @@ -1,78 +0,0 @@ -unit ProbeReport; -{$mode objfpc}{$H+}{$codepage utf8} -interface -uses SysUtils, fpjson; -type - TProbeReport = class - private - FRoot: TJSONObject; - FTests: TJSONArray; - public - constructor Create; - destructor Destroy; override; - procedure Add(const Id, Status, Detail: string); - procedure Check(const Id: string; const Ok: Boolean; const Detail: string); - procedure Save(const FileName: string); - function Status: string; - property Root: TJSONObject read FRoot; - end; -implementation -uses Classes, CastleLog, ProbeBuild; -constructor TProbeReport.Create; -begin - inherited; - FRoot := TJSONObject.Create; - FRoot.Add('schema', 'kam-android-runtime/1'); - FRoot.Add('build_id', ProbeBuildId); - FRoot.Add('profile', ProbeProfile); - FRoot.Add('cpu_expected', ProbeCpu); - FRoot.Add('upstream_commit', ProbeUpstreamCommit); - FRoot.Add('scope', 'Android architecture smoke, not a running KaM world'); - FRoot.Add('world_simulation', 'NOT_RUN'); - FTests := TJSONArray.Create; - FRoot.Add('tests', FTests); -end; -destructor TProbeReport.Destroy; -begin - FRoot.Free; - inherited; -end; -procedure TProbeReport.Add(const Id, Status, Detail: string); -var J: TJSONObject; I: Integer; -begin - for I := FTests.Count - 1 downto 0 do - if FTests.Objects[I].Get('id', '') = Id then FTests.Delete(I); - J := TJSONObject.Create; - J.Add('id', Id); J.Add('status', Status); J.Add('detail', Detail); - FTests.Add(J); - WritelnLog('KaMProbe', Id + ': ' + Status + ' ' + Detail); -end; -procedure TProbeReport.Check(const Id: string; const Ok: Boolean; const Detail: string); -begin - if Ok then Add(Id, 'PASS', Detail) else Add(Id, 'FAIL', Detail); -end; -function TProbeReport.Status: string; -var I: Integer; -begin - Result := 'PASS'; - for I := 0 to FTests.Count - 1 do - if FTests.Objects[I].Get('status','') = 'FAIL' then Exit('FAIL'); - { Optional NOT_RUN / OBSERVED rows do not make the automatic smoke fail. - They remain visible; PASS applies only to executed automatic checks. } -end; -procedure TProbeReport.Save(const FileName: string); -var S: TStringList; -begin - FRoot.Delete('automatic_smoke_status'); - FRoot.Add('automatic_smoke_status', Status); - if not ForceDirectories(ExtractFileDir(FileName)) then - raise Exception.Create('Cannot create report directory'); - S := TStringList.Create; - try - S.Text := FRoot.FormatJSON; - { Evidence must not depend on the filesystem behaviour measured separately - by decision.persistence.rename_replace. } - S.SaveToFile(FileName); - finally S.Free; end; -end; -end. diff --git a/code/ProbeScheduling.pas b/code/ProbeScheduling.pas deleted file mode 100755 index 06819e4..0000000 --- a/code/ProbeScheduling.pas +++ /dev/null @@ -1,89 +0,0 @@ -unit ProbeScheduling; -{$mode objfpc}{$H+} -{$ifndef ANDROID}{$fatal Decision scheduling probe is Android-only}{$endif} -interface -uses Classes,ProbeReport; -type - TDecisionSchedule=class - private - FReport:TProbeReport; - FWorker:TThread; - FCallbackSteps:Integer; - FCallbackReported,FWorkerReported:Boolean; - FPauses,FResumes:Integer; - public - constructor Create(AReport:TProbeReport); - destructor Destroy;override; - procedure Tick; - procedure Paused; - procedure Resumed; - procedure ObserveInstanceCount; - end; -implementation -uses SysUtils; -type - TDecisionWorker=class(TThread) - public - Executions:Integer; - Sum:Int64; - Done:Boolean; - procedure Execute;override; - end; -var GScheduleInstances:Integer=0; -procedure TDecisionWorker.Execute; -var I:Integer; -begin - Inc(Executions);Sum:=0; - try - for I:=1 to 40 do begin - if Terminated then Exit; - Sum:=Sum+I;Sleep(100); - end; - finally Done:=True;end; -end; -constructor TDecisionSchedule.Create(AReport:TProbeReport); -begin - inherited Create;FReport:=AReport;Inc(GScheduleInstances); - FWorker:=TDecisionWorker.Create(True);FWorker.FreeOnTerminate:=False;FWorker.Start; - ObserveInstanceCount; -end; -destructor TDecisionSchedule.Destroy; -begin - if FWorker<>nil then begin - FWorker.Terminate;FWorker.WaitFor;FWorker.Free; - end; - inherited; -end; -procedure TDecisionSchedule.ObserveInstanceCount; -begin - FReport.Add('decision.schedule.host_instance_count','OBSERVED','instances_in_process='+IntToStr(GScheduleInstances)); -end; -procedure TDecisionSchedule.Tick; -var W:TDecisionWorker; -begin - if not FCallbackReported then begin - Inc(FCallbackSteps); - if FCallbackSteps>=120 then begin - FCallbackReported:=True; - FReport.Add('decision.schedule.callback_progress','OBSERVED', - 'completed=true; steps='+IntToStr(FCallbackSteps)+'; pauses='+IntToStr(FPauses)+'; resumes='+IntToStr(FResumes)); - end; - end; - if not FWorkerReported then begin - W:=TDecisionWorker(FWorker); - if W.Done then begin - W.WaitFor;FWorkerReported:=True; - FReport.Add('decision.schedule.worker_progress','OBSERVED', - 'completed='+LowerCase(BoolToStr((W.Executions=1) and (W.Sum=820),True))+ - '; executions='+IntToStr(W.Executions)+'; sum='+IntToStr(W.Sum)+ - '; pauses='+IntToStr(FPauses)+'; resumes='+IntToStr(FResumes)); - end; - end; -end; -procedure TDecisionSchedule.Paused; -begin Inc(FPauses);FReport.Add('decision.schedule.lifecycle','OBSERVED', - 'event=pause; pauses='+IntToStr(FPauses)+'; resumes='+IntToStr(FResumes));end; -procedure TDecisionSchedule.Resumed; -begin Inc(FResumes);ObserveInstanceCount;FReport.Add('decision.schedule.lifecycle','OBSERVED', - 'event=resume; pauses='+IntToStr(FPauses)+'; resumes='+IntToStr(FResumes));end; -end. diff --git a/code/ProbeTextProvider.pas b/code/ProbeTextProvider.pas deleted file mode 100755 index 66b4c29..0000000 --- a/code/ProbeTextProvider.pas +++ /dev/null @@ -1,34 +0,0 @@ -unit ProbeTextProvider; -{$mode objfpc}{$H+} -interface -uses ProbeReport; -procedure RunTextProviderChecks(Report:TProbeReport); -implementation -uses SysUtils; -function ByteEqual(const S:RawByteString;const B:array of Byte):Boolean; -var I:Integer; -begin - Result:=Length(S)=Length(B);if not Result then Exit; - for I:=0 to High(B) do if Byte(S[I+1])<>B[I] then Exit(False); -end; -procedure RunTextProviderChecks(Report:TProbeReport); -const CP1251Bytes:array[0..5] of Byte=($CF,$F0,$E8,$E2,$E5,$F2); -var A,Back:RawByteString;U:UnicodeString;I:Integer;OkDecode,OkEncode:Boolean; -begin - try - SetLength(A,Length(CP1251Bytes));for I:=0 to High(CP1251Bytes) do A[I+1]:=AnsiChar(CP1251Bytes[I]); - SetCodePage(A,1251,False);U:=UnicodeString(A); - OkDecode:=(Length(U)=6) and (Ord(U[1])=$041F) and (Ord(U[2])=$0440) and - (Ord(U[3])=$0438) and (Ord(U[4])=$0432) and (Ord(U[5])=$0435) and (Ord(U[6])=$0442); - Report.Add('decision.text.cp1251_decode','OBSERVED', - 'supported='+LowerCase(BoolToStr(OkDecode,True))+'; source_codepage='+IntToStr(StringCodePage(A))); - Back:=UTF8Encode(U);SetCodePage(Back,1251,True);OkEncode:=ByteEqual(Back,CP1251Bytes); - Report.Add('decision.text.cp1251_encode','OBSERVED', - 'supported='+LowerCase(BoolToStr(OkEncode,True))+'; result_codepage='+IntToStr(StringCodePage(Back))); - except on E:Exception do begin - Report.Add('decision.text.cp1251_decode','OBSERVED','supported=false; error='+E.ClassName+': '+E.Message); - Report.Add('decision.text.cp1251_encode','OBSERVED','supported=false; error='+E.ClassName+': '+E.Message); - end; -end; -end; -end. diff --git a/config/profiles.json b/config/profiles.json deleted file mode 100755 index 11d4a55..0000000 --- a/config/profiles.json +++ /dev/null @@ -1,19 +0,0 @@ -[ - {"id":"platform","units":[],"required":true,"package":true,"purpose":"Castle/FPC Android shell, storage, threads, compression, touch, procedural GPU image, audio API, lifecycle"}, - {"id":"core_types","units":["KM_CommonTypes","KM_Points"],"required":true,"package":true,"purpose":"Original core types, point arithmetic and ABI witness"}, - {"id":"core_streams","units":["KM_CommonClasses"],"required":true,"package":false,"purpose":"Original serialization stream, records, strings and compression dependencies"}, - {"id":"mission_tokens","units":["KM_MissionScript"],"required":true,"package":false,"purpose":"Original DAT tokenizer and its real transitive dependencies"}, - {"id":"mission_info","units":["KM_MissionScript_Info"],"required":true,"package":false,"purpose":"Original MAP/DAT metadata loader dependency and native-link boundary; no world execution"}, - {"id":"terrain_io","units":["KM_TerrainUtils"],"required":true,"package":false,"purpose":"Original terrain binary I/O dependency boundary"}, - {"id":"sprite_loader","units":["KM_ResSprites"],"required":true,"package":false,"purpose":"Original RX/RXX loader and its graphics/palette/compression dependencies"}, - {"id":"terrain_pathfinding","units":["KM_Terrain"],"required":true,"package":false,"purpose":"Original terrain/pathfinding dependency graph"}, - {"id":"mission_world","units":["KM_MissionScript_Standard"],"required":true,"package":false,"purpose":"Original full mission loader, buildings/units command dependencies"}, - {"id":"simulation","units":["KM_Game"],"required":true,"package":false,"purpose":"Original game core dependency graph, not the desktop executable"}, - {"id":"script_vm","units":["uPSCompiler","uPSRuntime"],"required":true,"package":false,"purpose":"Pinned PascalScript submodule source and ARM ABI support"}, - {"id":"scripting","units":["KM_ScriptingEvents"],"required":true,"package":false,"purpose":"KaM scripting integration dependency graph"}, - {"id":"legacy_render","units":["KM_Render"],"required":false,"package":false,"purpose":"Diagnostic only: discover original renderer's platform dependencies"}, - {"id":"legacy_audio","units":["KM_Sound"],"required":false,"package":false,"purpose":"Diagnostic only: discover original audio's platform dependencies"}, - {"id":"integration","units":["KM_CommonTypes","KM_Points","KM_CommonClasses","KM_MissionScript_Info","KM_TerrainUtils","KM_ResSprites","KM_Terrain","KM_MissionScript_Standard","KM_Game","KM_ScriptingEvents"],"required":true,"package":true,"purpose":"Link actual core roots together with Castle in ONE Android native library and APK"}, - {"id":"oq_host_view","units":[],"required":false,"package":true,"game_unit":"DecisionHostView","purpose":"Decision evidence for T1-RP01-Q01: minimal TCastleView host without child controls"}, - {"id":"oq_host_callback","units":[],"required":false,"package":true,"game_unit":"DecisionHostCallback","purpose":"Decision evidence for T1-RP01-Q01: Application.OnInitialize host without a custom view"} -] diff --git a/config/runtime-resources.json b/config/runtime-resources.json new file mode 100644 index 0000000..8a86663 --- /dev/null +++ b/config/runtime-resources.json @@ -0,0 +1,10 @@ +{ + "schema": "kam-runtime-resources/1", + "entries": [ + {"kind": "directory", "path": "data"}, + {"kind": "directory", "path": "Maps"}, + {"kind": "directory", "path": "MapsMP"}, + {"kind": "directory", "path": "Campaigns"}, + {"kind": "directory", "path": "Tutorials"} + ] +} diff --git a/docs/COVERAGE.md b/docs/COVERAGE.md deleted file mode 100644 index d8200f8..0000000 --- a/docs/COVERAGE.md +++ /dev/null @@ -1,39 +0,0 @@ -# Что можно заключить по этому тесту - -## Цель - -Получить реальные ошибки или успешные результаты **компиляции, полной native-линковки и APK-упаковки** KaM r16020 + FPC/CGE для Android. Отдельно проверить простейший запуск уже собранных APK. Ни desktop-сборка, ни полная игра для этой цели не нужны. - -## Границы свидетельств - -| Свидетельство | Подтверждает | Не подтверждает | -|---|---|---| -| `platform` compile/link | Сборочные настройки CGE/FPC для выбранного Android ABI | Совместимость KaM | -| Корневой модуль compile/link | Этот исходник и активные транзитивные зависимости компилируются/линкуются при записанных defines | Правильность каждого метода, чтение всех ресурсов | -| `integration` compile/link | Совместную линковку выбранных настоящих корней KaM и CGE | Старт миссии, simulation parity, готовность renderer | -| APK inspection | Корректную оболочку ZIP/DEX, нужный ABI, сопоставление библиотеки и статически объявленных зависимостей | Все dynamic `dlopen` пути, запуск Android loader, подпись без отдельной проверки | -| `apksigner` | Подпись конкретного артефакта при успешной проверке | Корректную работу приложения | -| Runtime startup | Прохождение соответствующих инициализаторов и старт CGE UI | Полную игровую инициализацию | -| Реальные point/stream smoke | Несколько конкретных штатных операций и размеров типов на Android | Все сценарии бинарной совместимости сохранений | -| Procedural GPU image / кадры | Вызовы render/update в CGE; визуально — простой texture path | КаМ высоты, сортировку спрайтов, terrain, draw performance | -| Лексический inventory | Места потенциальных зависимостей с файлами и строками | Активность ветви `ifdef` и наличие реальной ошибки компиляции | - -Флаг `elf_16k_alignment_candidate` — только статическое свойство сегментов ELF. Это не сертификат совместимости с 16 KB Android page size и не подтверждение требований Google Play. ZIP alignment и поведение на соответствующем устройстве отдельно этим флагом не проверены. - -## Принятие решения для документации - -`REUSE_CANDIDATE`: исходный модуль собрался без изменений. Формулировка для документации: «Переиспользуем, compile/link подтверждён на ABI X в окружении Y; runtime/поведение проверяются отдельно». - -`PLATFORM_ADAPTER_CANDIDATE`: ошибка на Windows/LCL/старом graphics/audio API. Сначала найти путь зависимости. Перенести конкретную платформенную операцию за границу, а не заменять loader или simulation module целиком. - -`DEPENDENCY_BUILD_OR_CONFIG`: отсутствующий unit/include/native symbol. Различать некомплектный исходный пакет, неверный search path, библиотеку без Android-варианта, CPU-specific assembler и действительно непереносимый API. Записанный triage — подсказка, не окончательный диагноз. - -`SOURCE_PATCH_CANDIDATE`: ошибка языка/RTL/ABI в настоящем исходнике. Нужна локальная проверяемая правка, её основание, повторный результат. Этот стенд намеренно хранит baseline неизменным и не делает такую правку автоматически. - -`NEW_IMPLEMENTATION`: **никогда не назначается автоматически** по одной ошибке компиляции. Нужны исследование существующего кода и явное обоснование, почему адаптация/подключение библиотеки не подходят. Новый мобильный UI и touch presentation ожидаемы, но это не довод переписывать core. - -## Что не входит в текущий запрос - -Не исполняются миссии, экономика, AI, строительство, боя, save/load игрового мира, кампании и библиотечные script-файлы. Их код входит в соответствующие dependency/link-пробы, но поведенческого покрытия от этого не появляется. Полный KaM renderer и мобильные игровые панели не реализованы. MAP/DAT форматы не менялись. Ресурсы не заменены синтетическими файлами «под KaM». - -После первого серверного запуска можно предметно описывать **сборочные и платформенные границы** проекта. Заявления о полной игровой совместимости потребуют других тестов; они не нужны для ответа на текущий вопрос «что мешает собрать нашу архитектуру под Android». diff --git a/docs/FIRST_SERVER_RUN_DIAGNOSIS.md b/docs/FIRST_SERVER_RUN_DIAGNOSIS.md deleted file mode 100644 index 642e44e..0000000 --- a/docs/FIRST_SERVER_RUN_DIAGNOSIS.md +++ /dev/null @@ -1,8 +0,0 @@ -# First server run diagnosis (2026-09-08) - -The first server report did **not** reach KaM portability errors. Two harness defects blocked it first. - -1. `tools/generate.py` passed `-Mdelphi` as a global FPC command-line option. This changed compilation mode for Castle Game Engine units too. `CastleLocalizationGetText` relies on its normal CGE/FPC mode and failed with `Variable identifier expected` and overload errors. KaM units do not need a global mode flag because `KaM_Remake.inc` contains `{$MODE Delphi}` under FPC. Fix: remove global `-Mdelphi`. -2. `tools/source_audit.py` read UTF-8 files without BOM stripping. `src/game/KM_Game.pas` begins with a UTF-8 BOM, so the lexical `^unit` matcher did not index it and the simulation/integration profiles were incorrectly marked `Missing required original source units: KM_Game`. Fix: read with `utf-8-sig`. - -After these fixes, rerun the same Android profiles. New failures should be treated as the first meaningful evidence about KaM/FPC/Android dependencies. diff --git a/docs/SOURCES.md b/docs/SOURCES.md index 88cd0f9..2956956 100644 --- a/docs/SOURCES.md +++ b/docs/SOURCES.md @@ -1,27 +1,24 @@ -# Первичные источники +# Primary sources -Проверено при подготовке: 8 сентября 2026 года. Это ссылки для воспроизведения API/сборочных решений, не утверждение, что Android APK был собран в среде подготовки. +## Pinned KaM authority -## Зафиксированный KaM +- Repository: [reyandme/kam_remake](https://github.com/reyandme/kam_remake) +- Release/tag: r16020 +- Commit: [962fb850324b79bbe5480e1c2087d98f83a60917](https://github.com/reyandme/kam_remake/tree/962fb850324b79bbe5480e1c2087d98f83a60917) +- [KaM_Remake.inc](https://github.com/reyandme/kam_remake/blob/962fb850324b79bbe5480e1c2087d98f83a60917/KaM_Remake.inc) and [PascalScript gitlink](https://github.com/reyandme/kam_remake/blob/962fb850324b79bbe5480e1c2087d98f83a60917/.gitmodules) -- База и исходники: https://github.com/reyandme/kam_remake/tree/962fb850324b79bbe5480e1c2087d98f83a60917 -- Переключатели FPC/существующий `DBG_SKIP_SECURE_AUTH`: https://github.com/reyandme/kam_remake/blob/962fb850324b79bbe5480e1c2087d98f83a60917/KaM_Remake.inc -- Подмодуль PascalScript: https://github.com/reyandme/kam_remake/blob/962fb850324b79bbe5480e1c2087d98f83a60917/.gitmodules -- Оригинальные координаты: https://github.com/reyandme/kam_remake/blob/962fb850324b79bbe5480e1c2087d98f83a60917/src/common/KM_Points.pas -- Оригинальные потоки: https://github.com/reyandme/kam_remake/blob/962fb850324b79bbe5480e1c2087d98f83a60917/src/common/KM_CommonClasses.pas - -Upstream revision, blob SHA-1 и SHA-256 для всех реально используемых файлов записывает `tools/source_fetch.py`. Используется SHA gitlink подмодуля из закреплённого дерева, не его текущая ветка. +Source authority is, in order: pinned upstream r16020 semantics; project-owned registered adaptations; and Castle Android platform/tooling documentation. `vendor/kam_remake` is the pinned source location, with adaptation registration owned by `config/extra-paths.json` and `tools/source_resolution.py`. ## Castle / Android -- Build tool, Android `--os/--cpu`, `--assume-compiled`, debug APK: https://castle-engine.io/build_tool -- Формат manifest, game_units, search_paths, defines, custom_options: https://castle-engine.io/project_manifest -- Официальный toolchain Docker image и его состав: https://castle-engine.io/docker -- Android: https://castle-engine.io/android -- Android assets: https://castle-engine.io/android_faq -- Lifecycle callbacks: https://castle-engine.io/apidoc/html/CastleApplicationProperties.TCastleApplicationProperties.html -- Файлы и config URLs: https://castle-engine.io/apidoc/html/CastleUriUtils.html -- Android ABI: https://developer.android.com/ndk/guides/abis -- Проверка реального устройства: https://developer.android.com/studio/run/device +- [Castle build tool](https://castle-engine.io/build_tool) +- [Castle project manifest](https://castle-engine.io/project_manifest) +- [Castle Docker toolchain](https://castle-engine.io/docker) +- [Castle Android documentation](https://castle-engine.io/android) +- [Castle Android assets](https://castle-engine.io/android_faq) +- [Castle lifecycle callbacks](https://castle-engine.io/apidoc/html/CastleApplicationProperties.TCastleApplicationProperties.html) +- [Castle files and config URLs](https://castle-engine.io/apidoc/html/CastleUriUtils.html) +- [Android ABI guidance](https://developer.android.com/ndk/guides/abis) +- [Android device deployment](https://developer.android.com/studio/run/device) -CGE API развивается. Именно поэтому первый реальный запуск сохраняет digest toolchain, а ошибка платформенного API в `platform` не называется ошибкой KaM. Default SDK/NDK берутся из выбранного CGE, а не из неподтверждённой собственной таблицы версий. +These references support the pinned baseline; mutable upstream `master` is not implementation authority. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 5421498..79afa9f 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -1,45 +1,25 @@ -# Разбор результатов +# Troubleshooting -## Не запускается Docker или нет места/доступа в сеть +The authoritative finalized transaction is `out/current/build.json`. On `PASS`, use `out/current/package.json` and `out/current/kam-mobile.apk`. On `FAIL` or `BLOCKED`, read that metadata and the matching transient logs: `build//logs/compile.log`, `build//logs/package.log`, and `build//logs/signature.log` when apksigner was available and invoked. -Это проблема окружения, не аргумент против FPC/Castle. Смотрите bootstrap report и `docker-pull.log`. Не запускайте скрипт через `sudo` автоматически: сначала настройте доступ к Docker и права на папку проекта. Образ содержит инструменты для многих целей; его размер не равен размеру нашего исходного ZIP. +## Docker, toolchain, or network unavailable -## `platform` не собрался +Check Docker access, free space, network access, and write permission to the repository before rerunning. Do not use `sudo` as an automatic workaround. With `--local`, verify that the installed Castle, FPC, Java, Android SDK/NDK, and Android cross-compiler agree. -Сначала смотреть `platform/compile-link.log`, `toolchain.json` и `platform/CastleEngineManifest.xml`. Это самостоятельная CGE/FPC проба без KaM. Причиной может быть версия API CGE, Android cross-compiler, NDK/linker либо ошибка кода самого стенда. Не приписывайте её KaM. Остальные пробы всё равно запускаются для сбора диагностик. +## Missing Pascal unit, include, package, resource, or native object -## `Can't find unit Graphics / Forms / LCL...` +Follow the active dependency chain in `compile.log`. Desktop LCL units, generated includes, or native objects may need their upstream Android-compatible build input; do not add empty replacements. Source ownership remains the pinned `vendor/kam_remake` receipt plus RP01's registered adaptation/resolution owners, `config/extra-paths.json` and `tools/source_resolution.py`. -Стенд не добавляет desktop LCL в Android автоматически и не создаёт пустую замену. Лексический `source-inventory.json` помогает найти места зависимости, но смотреть нужно активную цепочку в compile log. У нескольких проб может быть один общий блокер. Не считать каждый упавший профиль отдельной переписываемой системой. +## Native link failure or undefined symbol -## Отсутствующий `.res`, generated include или native object +Use `compile.log` to identify the first missing native dependency and confirm it has the selected Android ABI. A desktop library or a same-named stub is not a valid fix. -Загрузчик намеренно выбирает только исходный текст, а не скомпилированные Windows-ресурсы/объекты. Такая ошибка может означать, что ресурс нужно штатно генерировать для Android или отделить платформенный модуль, а не что игровой формат несовместим. Запишите запросивший файл и найдите соответствующий upstream build step. Стенд не подменяет необходимый вход пустым файлом. +## APK or native-library integrity failure -## `No fresh Pascal unit artifact` +Use `package.log` to inspect the packaged APK, ABI, and native-library findings. When present, `signature.log` records apksigner inspection. Do not substitute an older APK: only the current finalized package metadata identifies the current package. -Компилятор завершился, но стенд не нашёл ожидаемый `.ppu` внутри свежего проекта. Нельзя без проверки просто ослабить это условие: сначала установить, куда текущий CGE положил units, действительно ли собран именно исходный корень и не использован чужой cache. В `target.json` есть список источников и options. Это диагностическая проверка стенда, не обязательное свидетельство ошибки core. +## Modified or unrecorded source -## Undefined symbols / native `.so` отсутствует в APK +The pinned vendor receipt/source verification detects changed or unrecorded source. Do not edit `vendor/kam_remake` to obtain a clean result; make project-owned adaptations through the registered RP01 path instead. -Проверка линковки специально строгая. Посмотреть библиотеку и ABI, транзитивные native зависимости и имеющийся вариант для Android. `extra-paths.json` позволяет явно добавить пути к проверенной библиотеке/исходникам. Не добавлять Windows DLL, desktop `.so` или заглушку с тем же именем unit. Настроить library search path недостаточно, если библиотека ещё должна отдельно упаковываться в APK: отчёт `DT_NEEDED` это обнаружит. - -## APK слинкован, но упаковка отказала из-за native identity - -Сравнение разрешает удаление неисполняемой debug-информации, но требует совпадения allocated sections и program headers. Другие изменения упаковщика требуют отдельного исследования. Не принимать произвольный `.apk` из старого `dist/` за новый. При нескольких разных APK outputs стенд консервативно отказывается выбирать случайный; изучите свежий target, прежде чем менять правило отбора. - -## Source modified / Unrecorded binary - -Baseline под `vendor/kam_remake/` проверяется по receipt до и после сборки. Не редактируйте его для получения «неизменённого upstream PASS». Для исследования адаптации нужна отдельная явно обозначенная копия/patch-ветка и запись разницы; текущий стенд проверяет as-is baseline. Повторная загрузка не затирает ваши изменения молча. - -## Код возврата 2 после platform-only - -Это ожидаемый `PARTIAL_SELECTION`. Смотрите индивидуальные статусы. Успешный platform-only запуск специально не делает глобальный architecture gate зелёным. - -## APK запускается и сразу закрывается - -Это может быть ошибка native loader, unit initialization или попытка штатного кода обратиться к данным до старта UI. `runtime.json` при раннем падении может не создаться. Сохраните журнал Android для этого package и библиотек. Device tool не засчитывает отсутствие отчёта за успех. Для полного crash stack может потребоваться отдельное чтение native crash log на устройстве. - -## Что передавать для следующего разбора - -`reports/runs//evidence.zip` содержит версии инструментов, source hashes, manifests, generated imports, compile/package logs и result. В нём нет игровых файлов, APK и полного upstream дерева. По нему можно выделить первый общий блокер. Для runtime дополнительно нужны файлы конкретного `reports/device//`; сначала проверьте, нет ли в журналах нежелательных личных путей/данных. +`reports/**` and `dist/**` are frozen Task-1 historical evidence, not inputs to current diagnosis. diff --git a/open_questions/config/fixture_manifest.json b/open_questions/config/fixture_manifest.json deleted file mode 100755 index df9e2e3..0000000 --- a/open_questions/config/fixture_manifest.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "schema": "kam-task1-open-question-fixtures-v2", - "selection_policy": "exact-whitelist-no-recursive-discovery", - "asset_root_relative": "private-assets", - "primary": [ - { - "id": "town-defence-dat", - "role": "dat", - "path": "Maps/Town Defence/Town Defence.dat", - "expected_size": 5350, - "logical_fixture": "town-defence-pair" - }, - { - "id": "town-defence-map", - "role": "map", - "path": "Maps/Town Defence/Town Defence.map", - "expected_size": 281646, - "logical_fixture": "town-defence-pair" - }, - { - "id": "tileset-rxx", - "role": "rxx", - "path": "data/sprites/Tileset.rxx", - "expected_size": 447964, - "resource_category": "rxTiles", - "logical_fixture": "tileset-rxx" - } - ], - "conditional_fallbacks": [ - { - "id": "layer-map", - "enabled_by_default": false, - "condition": "Use only if Town Defence.map does not exercise the layered/generated-terrain branch required by T1-RP03-Q02/T1-RP03-Q05.", - "files": [ - { - "id": "tbk01-dat", - "role": "dat", - "path": "Maps/The Barbarian King/TBK01/TBK01.dat", - "expected_size": 52903, - "logical_fixture": "tbk01-pair" - }, - { - "id": "tbk01-map", - "role": "map", - "path": "Maps/The Barbarian King/TBK01/TBK01.map", - "expected_size": 368686, - "logical_fixture": "tbk01-pair" - } - ] - } - ], - "notes": [ - "Paths are relative to the existing project private-assets directory.", - "The probe must never recursively discover binary fixtures.", - "Do not add GUI.rxa, Units.rxx, Houses.rxx or any other resource pack unless a future planning decision explicitly changes this manifest." - ] -} diff --git a/open_questions/config/questions.json b/open_questions/config/questions.json deleted file mode 100755 index 98c7e9c..0000000 --- a/open_questions/config/questions.json +++ /dev/null @@ -1,126 +0,0 @@ -{ - "schema": "kam-task1-oq-probe/1", - "questions": [ - { - "id": "T1-Q01", - "tests": [ - "fixture_inventory", - "storage_contract" - ] - }, - { - "id": "T1-Q02", - "tests": [ - "expected_control_independence" - ] - }, - { - "id": "T1-RP01-Q01", - "tests": [ - "castle_host_variants" - ] - }, - { - "id": "T1-RP01-Q02", - "tests": [ - "bounded_workload", - "lifecycle_interrupt" - ] - }, - { - "id": "T1-RP01-Q03", - "tests": [ - "evidence_crash_matrix", - "android_atomic_replace" - ] - }, - { - "id": "T1-RP02-Q01", - "tests": [ - "portable_closure_inventory", - "compile_variant_matrix" - ] - }, - { - "id": "T1-RP02-Q02", - "tests": [ - "text_encoding_inventory", - "text_encoding_vectors" - ] - }, - { - "id": "T1-RP02-Q03", - "tests": [ - "stream_read_inventory", - "truncation_matrix" - ] - }, - { - "id": "T1-RP03-Q01", - "tests": [ - "dat_command_inventory", - "dat_projection_compare" - ] - }, - { - "id": "T1-RP03-Q02", - "tests": [ - "terrain_generation_dependency", - "layered_map_probe" - ] - }, - { - "id": "T1-RP03-Q03", - "tests": [ - "map_memory_estimate", - "map_projection_equivalence" - ] - }, - { - "id": "T1-RP03-Q04", - "tests": [ - "map_revision_inventory", - "map_tail_truncation" - ] - }, - { - "id": "T1-RP03-Q05", - "tests": [ - "fixture_branch_coverage" - ] - }, - { - "id": "T1-RP04-Q01", - "tests": [ - "rxx_fixture_inventory", - "rxx_branch_coverage" - ] - }, - { - "id": "T1-RP04-Q02", - "tests": [ - "sprite_cpu_closure", - "sprite_compile_variants" - ] - }, - { - "id": "T1-RP04-Q03", - "tests": [ - "sprite_memory_estimate", - "sprite_projection_equivalence" - ] - }, - { - "id": "T1-RP05-Q01", - "tests": [ - "failure_isolation" - ] - }, - { - "id": "T1-RP05-Q02", - "tests": [ - "repeatability_matrix" - ] - } - ] -} diff --git a/open_questions/tests/test_probe.py b/open_questions/tests/test_probe.py deleted file mode 100755 index 2c89ed0..0000000 --- a/open_questions/tests/test_probe.py +++ /dev/null @@ -1,106 +0,0 @@ -import importlib.util -import json -import tempfile -import unittest -from pathlib import Path - -HERE = Path(__file__).resolve().parents[1] -PROJECT = HERE.parent - - -def load_probe_module(): - p = HERE / 'tools/decision_probe.py' - spec = importlib.util.spec_from_file_location('decision_probe_testmod', p) - mod = importlib.util.module_from_spec(spec) - assert spec.loader is not None - spec.loader.exec_module(mod) - return mod - - -class ProbeTests(unittest.TestCase): - def test_question_count_and_unique(self): - q = json.loads((HERE/'config/questions.json').read_text(encoding='utf-8'))['questions'] - ids = [x['id'] for x in q] - self.assertEqual(18, len(ids)) - self.assertEqual(len(ids), len(set(ids))) - - def test_every_question_has_test(self): - q = json.loads((HERE/'config/questions.json').read_text(encoding='utf-8'))['questions'] - self.assertTrue(all(x['tests'] for x in q)) - - def test_fixed_manifest_is_minimal_and_exact(self): - m = json.loads((HERE/'config/fixture_manifest.json').read_text(encoding='utf-8')) - self.assertEqual('exact-whitelist-no-recursive-discovery', m['selection_policy']) - self.assertEqual([ - 'Maps/Town Defence/Town Defence.dat', - 'Maps/Town Defence/Town Defence.map', - 'data/sprites/Tileset.rxx', - ], [x['path'] for x in m['primary']]) - self.assertEqual(1, len(m['conditional_fallbacks'])) - self.assertFalse(m['conditional_fallbacks'][0]['enabled_by_default']) - self.assertEqual([ - 'Maps/The Barbarian King/TBK01/TBK01.dat', - 'Maps/The Barbarian King/TBK01/TBK01.map', - ], [x['path'] for x in m['conditional_fallbacks'][0]['files']]) - - def test_binary_selection_ignores_decoys_and_fallback_is_opt_in(self): - mod = load_probe_module() - with tempfile.TemporaryDirectory() as td: - root = Path(td) - assets = root/'private-assets' - (assets/'a').mkdir(parents=True) - (assets/'decoys').mkdir(parents=True) - (assets/'a/one.dat').write_bytes(b'abc') - (assets/'a/two.map').write_bytes(b'defg') - (assets/'a/three.rxx').write_bytes(b'hijkl') - (assets/'a/fallback.map').write_bytes(b'mnopqr') - (assets/'decoys/should-never-be-read.map').write_bytes(b'X'*100) - manifest = root/'manifest.json' - manifest.write_text(json.dumps({ - 'selection_policy':'exact-whitelist-no-recursive-discovery', - 'primary':[ - {'id':'d','role':'dat','path':'a/one.dat','expected_size':3}, - {'id':'m','role':'map','path':'a/two.map','expected_size':4}, - {'id':'r','role':'rxx','path':'a/three.rxx','expected_size':5}, - ], - 'conditional_fallbacks':[ - {'id':'layer-map','enabled_by_default':False,'files':[ - {'id':'fm','role':'map','path':'a/fallback.map','expected_size':6} - ]} - ] - }), encoding='utf-8') - mod.ASSETS = assets - mod.MANIFEST = manifest - mod.INCLUDE_FALLBACKS = set() - primary = [p.relative_to(assets).as_posix() for _,p in mod.selected_files({'.dat','.map','.rxx'})] - self.assertEqual(['a/one.dat','a/two.map','a/three.rxx'], primary) - self.assertNotIn('decoys/should-never-be-read.map', primary) - mod.INCLUDE_FALLBACKS = {'layer-map'} - with_fallback = [p.relative_to(assets).as_posix() for _,p in mod.selected_files({'.dat','.map','.rxx'})] - self.assertEqual(['a/one.dat','a/two.map','a/three.rxx','a/fallback.map'], with_fallback) - - def test_independent_inspector_does_not_import_kam(self): - s = (HERE/'tools/decision_probe.py').read_text(encoding='utf-8') - self.assertNotIn('import KM_', s) - self.assertNotIn('from KM_', s) - - def test_no_recursive_binary_asset_scan_in_probe(self): - s = (HERE/'tools/decision_probe.py').read_text(encoding='utf-8') - self.assertNotIn('ASSETS.rglob', s) - self.assertNotIn('ASSETS.glob', s) - self.assertNotIn("rglob('*')", s) - self.assertNotIn("glob('**", s) - - def test_integrated_paths_do_not_replace_old_runner(self): - self.assertTrue((PROJECT/'run-open-question-tests.sh').is_file()) - compatibility = (HERE/'COMPATIBILITY.md').read_text(encoding='utf-8') - self.assertIn('build-android.sh', compatibility) - self.assertIn('does not overwrite', compatibility) - - def test_no_production_claim(self): - s = (HERE/'README.md').read_text(encoding='utf-8') - self.assertIn('does not replace', s) - - -if __name__ == '__main__': - unittest.main() diff --git a/open_questions/tools/decision_probe.py b/open_questions/tools/decision_probe.py deleted file mode 100755 index 1b03625..0000000 --- a/open_questions/tools/decision_probe.py +++ /dev/null @@ -1,272 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations -import argparse, hashlib, json, re, struct -from pathlib import Path - -HERE = Path(__file__).resolve().parents[1] -PROJECT_ROOT = HERE.parent -SRC = PROJECT_ROOT / 'vendor/kam_remake' -ASSETS = PROJECT_ROOT / 'private-assets' -MANIFEST = HERE / 'config/fixture_manifest.json' -OUT = PROJECT_ROOT / 'reports/open-questions/current' -INCLUDE_FALLBACKS: set[str] = set() - - -def sha(p: Path) -> str: - return hashlib.sha256(p.read_bytes()).hexdigest() - - -def text(p: Path) -> str: - return p.read_text(encoding='utf-8-sig', errors='replace') - - -def emit(name: str, data: dict) -> dict: - OUT.mkdir(parents=True, exist_ok=True) - (OUT / (name + '.json')).write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') - return data - - -def load_manifest() -> dict: - return json.loads(MANIFEST.read_text(encoding='utf-8')) - - -def selected_specs() -> list[dict]: - m = load_manifest() - specs = [dict(x, selection='primary') for x in m.get('primary', [])] - known = {x['id'] for x in m.get('conditional_fallbacks', [])} - unknown = INCLUDE_FALLBACKS - known - if unknown: - raise SystemExit('Unknown fallback id(s): ' + ', '.join(sorted(unknown))) - for group in m.get('conditional_fallbacks', []): - if group['id'] in INCLUDE_FALLBACKS: - specs.extend(dict(x, selection='fallback:' + group['id']) for x in group.get('files', [])) - return specs - - -def resolve_spec(spec: dict) -> Path: - rel = Path(spec['path']) - if rel.is_absolute() or '..' in rel.parts: - raise SystemExit(f"Unsafe fixture path in manifest: {spec['path']}") - root = ASSETS.resolve() - p = (root / rel).resolve() - try: - p.relative_to(root) - except ValueError: - raise SystemExit(f"Fixture escaped asset root: {spec['path']}") - return p - - -def selected_files(exts: set[str]) -> list[tuple[dict, Path]]: - rows = [] - for spec in selected_specs(): - p = resolve_spec(spec) - if p.suffix.lower() in exts: - rows.append((spec, p)) - return rows - - -def fixture_manifest_status() -> dict: - rows = [] - good = True - for spec in selected_specs(): - p = resolve_spec(spec) - row = { - 'id': spec['id'], - 'role': spec['role'], - 'path': spec['path'], - 'selection': spec['selection'], - 'expected_size': spec.get('expected_size'), - 'exists': p.is_file(), - } - if p.is_file(): - row['actual_size'] = p.stat().st_size - row['size_matches'] = spec.get('expected_size') in (None, p.stat().st_size) - if not row['size_matches']: - good = False - else: - row['actual_size'] = None - row['size_matches'] = False - good = False - rows.append(row) - return emit('fixture_manifest_status', { - 'test': 'fixture_manifest_status', - 'selection_policy': load_manifest().get('selection_policy'), - 'assets_root': str(ASSETS), - 'manifest': str(MANIFEST), - 'included_fallbacks': sorted(INCLUDE_FALLBACKS), - 'fixtures': rows, - 'status': 'PASS' if good else 'MISSING_OR_MISMATCH' - }) - - -def fixture_inventory(): - rows = [] - status = 'PASS' - for spec, p in selected_files({'.map', '.dat', '.rxx', '.rx', '.rxa', '.txt', '.libx', '.script'}): - if not p.is_file(): - rows.append({'id': spec['id'], 'path': spec['path'], 'role': spec['role'], 'status': 'MISSING'}) - status = 'MISSING_OR_MISMATCH' - continue - size = p.stat().st_size - size_ok = spec.get('expected_size') in (None, size) - if not size_ok: - status = 'MISSING_OR_MISMATCH' - rows.append({ - 'id': spec['id'], - 'path': spec['path'], - 'role': spec['role'], - 'logical_fixture': spec.get('logical_fixture'), - 'selection': spec['selection'], - 'size': size, - 'expected_size': spec.get('expected_size'), - 'size_matches': size_ok, - 'sha256': sha(p), - 'ext': p.suffix.lower(), - 'resource_category': spec.get('resource_category') - }) - return emit('fixture_inventory', { - 'test': 'fixture_inventory', - 'count': len(rows), - 'fixtures': rows, - 'status': status if rows else 'NEEDS_FIXTURES', - 'selection_policy': 'exact manifest whitelist; no directory scan' - }) - - -def source_patterns(): - pats = { - 'desktop_units': r'\b(?:LCLIntf|LCLType|Graphics|FileUtil|LazUTF8|LazFileUtils|lconvencoding)\b', - 'read_calls': r'\.Read(?:Buffer)?\s*\(', - 'terrain_generation': r'GenerateTerrainTransitions|GetGenTerrainInfoLegacy|GetGenTerrainInfo', - 'tokenizer': r'TokenizeScript\s*\(', - 'render_globals': r'gGFXData|SKIP_RENDER|NO_OGL' - } - out = {k: [] for k in pats} - if SRC.exists(): - for p in SRC.rglob('*.pas'): - s = text(p) - for k, pat in pats.items(): - for m in re.finditer(pat, s, re.I): - out[k].append({'file': p.relative_to(SRC).as_posix(), - 'line': s.count('\n', 0, m.start()) + 1, 'match': m.group(0)}) - return emit('source_inventory', {'test': 'portable_closure_inventory', - 'status': 'PASS' if (SRC/'SOURCE_RECEIPT.json').exists() else 'BLOCKED', - 'patterns': out}) - - -def dat_scan(): - p = SRC/'src/mission/KM_MissionScript.pas' - cmd = re.findall(r"'([A-Z][A-Z0-9_]*)'", text(p)) if p.exists() else [] - rows = [] - for spec, f in selected_files({'.dat'}): - if not f.is_file(): - continue - b = f.read_bytes() - printable = sum(c in b'\t\n\r 0123456789!' for c in b) / max(1, len(b)) - rows.append({'id': spec['id'], 'path': spec['path'], 'size': len(b), - 'likely_encoded': printable < .20, 'bang_count_raw': b.count(b'!')}) - return emit('dat_inventory', {'test': 'dat_command_inventory', 'recognized_source_tokens': sorted(set(cmd)), - 'fixtures': rows, 'status': 'PASS' if p.exists() and rows else 'BLOCKED'}) - - -def map_scan(): - rows = [] - for spec, f in selected_files({'.map'}): - if not f.is_file(): - continue - b = f.read_bytes() - r = {'id': spec['id'], 'path': spec['path'], 'size': len(b), 'sha256': hashlib.sha256(b).hexdigest()} - if len(b) >= 8: - x = struct.unpack_from(' int: - global SRC, ASSETS, MANIFEST, OUT, INCLUDE_FALLBACKS - ap = argparse.ArgumentParser(description='Task 1 open-question evidence selector with an exact binary-fixture whitelist.') - ap.add_argument('action', choices=['verify', 'all', 'source']) - ap.add_argument('--source', type=Path, default=SRC) - ap.add_argument('--assets-root', type=Path, default=ASSETS) - ap.add_argument('--fixture-manifest', type=Path, default=MANIFEST) - ap.add_argument('--include-fallback', action='append', default=[]) - ap.add_argument('--out', type=Path, default=OUT) - a = ap.parse_args() - SRC = a.source.resolve(); ASSETS = a.assets_root.resolve(); MANIFEST = a.fixture_manifest.resolve(); OUT = a.out.resolve() - INCLUDE_FALLBACKS = set(a.include_fallback) - if a.action == 'verify': - result = fixture_manifest_status() - return 0 if result['status'] == 'PASS' else 2 - if a.action == 'all': - allrun() - else: - source_patterns() - return 0 - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/run-open-question-tests.sh b/run-open-question-tests.sh deleted file mode 100755 index 9cdaa19..0000000 --- a/run-open-question-tests.sh +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -cd "$ROOT" - -SOURCE="$ROOT/vendor/kam_remake" -ASSETS="$ROOT/private-assets" -FIXTURE_MANIFEST="$ROOT/open_questions/config/fixture_manifest.json" -INCLUDE_FALLBACK=() - -usage() { - cat < private-assets/Maps/The Barbarian King/TBK01/TBK01.dat + TBK01.map - -No recursive fixture discovery is performed. -This script does NOT run build-android.sh and does NOT execute Task 1 implementation. -EOF2 -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --source) SOURCE="${2:?missing path after --source}"; shift 2 ;; - --assets-root) ASSETS="${2:?missing path after --assets-root}"; shift 2 ;; - --include-layer-fallback) INCLUDE_FALLBACK=(--include-fallback layer-map); shift ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;; - esac -done - -if ! python3 tools/source_fetch.py --verify --destination "$SOURCE" >/dev/null 2>&1; then - if [[ "$SOURCE" != "$ROOT/vendor/kam_remake" ]]; then - echo "Pinned source at $SOURCE is missing or invalid." >&2 - exit 2 - fi - echo "Pinned r16020 source is absent; fetching it with the EXISTING project source_fetch.py..." - python3 tools/source_fetch.py --destination "$SOURCE" -fi - -RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)" -OUT="$ROOT/reports/open-questions/runs/$RUN_ID" -mkdir -p "$OUT" - -# Fail before any binary probe if an exact whitelisted path is absent or has an unexpected size. -python3 open_questions/tools/decision_probe.py verify \ - --source "$SOURCE" \ - --assets-root "$ASSETS" \ - --fixture-manifest "$FIXTURE_MANIFEST" \ - "${INCLUDE_FALLBACK[@]}" \ - --out "$OUT" - -python3 open_questions/tools/decision_probe.py all \ - --source "$SOURCE" \ - --assets-root "$ASSETS" \ - --fixture-manifest "$FIXTURE_MANIFEST" \ - "${INCLUDE_FALLBACK[@]}" \ - --out "$OUT" -env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s open_questions/tests -v - -python3 - "$OUT" "$ROOT/reports/open-questions" <<'PY' -import json,sys -from pathlib import Path -out=Path(sys.argv[1]); root=Path(sys.argv[2]) -idx=json.loads((out/'decision_index.json').read_text(encoding='utf-8')) -inv=json.loads((out/'fixture_inventory.json').read_text(encoding='utf-8')) -branch=json.loads((out/'fixture_branch_coverage.json').read_text(encoding='utf-8')) -manifest=json.loads((out/'fixture_manifest_status.json').read_text(encoding='utf-8')) -latest={'run':out.name,'directory':str(out),'decision_index':str(out/'decision_index.json'), - 'fixture_count':inv['count'],'fixture_status':inv['status'],'branch_status':branch['status'], - 'manifest_status':manifest['status'],'included_fallbacks':idx.get('included_fallbacks',[])} -root.mkdir(parents=True,exist_ok=True) -(root/'LATEST.json').write_text(json.dumps(latest,indent=2,ensure_ascii=False)+'\n',encoding='utf-8') -summary = [ - '# Task 1 open-question probe — latest run','', - f"Run: `{out.name}`", f"Whitelisted files read: **{inv['count']}** ({inv['status']})", - f"Exact manifest paths/sizes: **{manifest['status']}**", - f"Required format families: **{branch['status']}**", - f"Conditional fallbacks enabled: **{', '.join(idx.get('included_fallbacks',[])) or 'none'}**",'', - 'No recursive binary fixture discovery is used.', - 'This run contains only static/fixture evidence. Android runtime micro-tests are a separate stage.', - '',f"Decision index: `{out/'decision_index.json'}`" -] -(root/'LATEST-SUMMARY.md').write_text('\n'.join(summary)+'\n',encoding='utf-8') -PY - -echo -echo "Open-question static probe completed." -echo "Summary: reports/open-questions/LATEST-SUMMARY.md" -echo "Index: reports/open-questions/LATEST.json" -echo "Binary fixture selection came ONLY from open_questions/config/fixture_manifest.json." -echo "This command did NOT run build-android.sh." diff --git a/test-harness.sh b/test-harness.sh deleted file mode 100755 index 3b2f63c..0000000 --- a/test-harness.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -# Tests Python orchestration on the build server. DOES NOT compile a desktop game. -set -euo pipefail -ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -cd "$ROOT" -exec env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -v diff --git a/tests/helpers.py b/tests/helpers.py deleted file mode 100755 index 5091b92..0000000 --- a/tests/helpers.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Generated bytes below are TEST DOUBLES, never real compiler/APK evidence.""" -import struct - -def elf(cpu='aarch64',needed=(),textrel=False): - cls=1 if cpu=='arm' else 2 - machine={'arm':40,'aarch64':183,'x86_64':62}[cpu] - strings=b'\0';offsets=[] - for name in needed:offsets.append(len(strings));strings+=name.encode()+b'\0' - data=bytearray(1024);data[:7]=b'\x7fELF'+bytes([cls,1,1]) - struct.pack_into(' list[str]: rows = [s.strip() for s in log.splitlines() if re.search( r'\b(fatal|error|undefined reference|cannot find|can.t find|not found)\b', s, re.I)] return rows[:limit] - -def classify(log: str) -> str: - """Triage hint, never an automatic decision to rewrite a subsystem.""" - if re.search(r'cannot find.*(ppcross|ppca64|ppcarm)|compiler.*not found|SDK.*not found', log, re.I): - return 'TOOLCHAIN' - if re.search(r"can.t find unit (Windows|Forms|Controls|Graphics|LCL\w*|Dialogs|ShellAPI)", log, re.I): - return 'PLATFORM_BOUNDARY' - if re.search(r'undefined reference|cannot find -l|error while linking', log, re.I): - return 'NATIVE_LINK_DEPENDENCY' - if re.search(r"can.t find unit|cannot open include file", log, re.I): - return 'SOURCE_OR_PACKAGE_DEPENDENCY' - return 'SOURCE_OR_TOOLCHAIN_REVIEW' diff --git a/tools/decision_report.py b/tools/decision_report.py deleted file mode 100755 index 2403ca6..0000000 --- a/tools/decision_report.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Collect Task 1 decision-probe evidence. Does not choose architecture.""" -from __future__ import annotations -import json -from pathlib import Path -from common import ROOT, write_json - -QUESTION_MAP={ - 'T1-RP01-Q01':['oq_host_view','oq_host_callback'], - 'T1-RP01-Q02':['decision.schedule.callback_progress','decision.schedule.worker_progress','decision.schedule.lifecycle'], - 'T1-RP01-Q03':['decision.persistence.rename_replace','decision.persistence.append_reopen'], - 'T1-RP02-Q02':['decision.text.cwstring_unit','decision.text.cp1251_decode','decision.text.cp1251_encode'], - 'T1-Q01':['decision.fixture.castle_stream','decision.fixture.materialized_native_path','fixture_staging_driver'], -} - -def load(path:Path): - return json.loads(path.read_text(encoding='utf-8')) - -def main()->int: - latest_path=ROOT/'reports/LATEST.json' - if not latest_path.is_file(): - raise SystemExit('reports/LATEST.json not found; run build first') - latest=load(latest_path);run_id=latest['run_id'];run_dir=ROOT/latest['directory'];build=load(run_dir/'result.json') - devices=[] - for result_path in sorted((ROOT/'reports/device').glob('*/device-result.json')): - try:rec=load(result_path) - except Exception:continue - if rec.get('build_id')!=run_id and rec.get('package',{}).get('build_id')!=run_id:continue - runtime_path=result_path.with_name('runtime.json') - runtime=load(runtime_path) if runtime_path.is_file() else None - devices.append({'directory':str(result_path.parent.relative_to(ROOT)),'result':rec,'runtime':runtime}) - out={'schema':'kam-task1-decision-evidence/2','build_id':run_id,'gate':build.get('gate'), - 'environment':{'toolchain':build.get('toolchain')},'questions':QUESTION_MAP,'profiles':{},'device_runs':[]} - out['infrastructure_files_changed']=['tools/runner.py','tools/artifacts.py','tools/device.py','tools/decision_report.py', - 'config/profiles.json','code/GameViewMain.pas','code/ProbeReport.pas','CODEX_RUN_TASK1_DECISION_TESTS.md','tests/test_probe.py','tests/test_pipeline.py'] - for row in build.get('profiles',[]): - if row.get('id') in ('platform','oq_host_view','oq_host_callback'): - out['profiles'][row['id']]={'compile_link':row.get('compile_link'),'package':row.get('package'),'apk':row.get('apk'),'errors':row.get('errors',[])} - for d in devices: - rec=d['result'];runtime=d['runtime'];entry={'directory':d['directory'],'profile':rec.get('profile',rec.get('package',{}).get('profile')), - 'status':rec.get('status'),'errors':rec.get('errors',[]),'preflight':rec.get('preflight'),'infrastructure_blocker':rec.get('infrastructure_blocker'),'fixture_staging_driver':rec.get('fixture_staging_driver')} - if runtime: - entry['observations']=[x for x in runtime.get('tests',[]) if str(x.get('id','')).startswith('decision.')] - entry['session']=runtime.get('session') - out['device_runs'].append(entry) - out['history']=[d for d in out['device_runs'] if d.get('status')!='AUTOMATIC_SMOKE_PASS'] - valid_runs=[d for d in out['device_runs'] if d.get('status')=='AUTOMATIC_SMOKE_PASS'] - observations={} - for d in valid_runs: - for item in d.get('observations',[]):observations[item.get('id')]=item - if d.get('fixture_staging_driver') is not None:observations['fixture_staging_driver']=d['fixture_staging_driver'] - blocker=next((d.get('infrastructure_blocker',{}).get('reason') for d in reversed(out['device_runs']) if d.get('infrastructure_blocker')),None) - out['decision_test_results']=[{'question_id':q,'test':test,'status':observations.get(test,{}).get('status','NOT_RUN'), - 'evidence':observations.get(test),'blocker':None if test in observations else blocker} for q,tests in QUESTION_MAP.items() for test in tests] - json_path=ROOT/'reports/TASK1-DECISION-EVIDENCE.json';write_json(json_path,out) - lines=['# Task 1 — decision-probe evidence','',f"Build ID: `{run_id}`",'', - 'Этот отчёт фиксирует наблюдения и не закрывает архитектурные вопросы Task 1.','', - '## Environment','',f"- Toolchain receipt: `reports/runs/{run_id}/toolchain.json`",'- Docker command receipts are retained under `reports/runs/*/docker-command.json`.','', - '## Infrastructure files changed',''] - lines += [f'- `{path}`' for path in out['infrastructure_files_changed']] - lines += ['','## Build/package','', '| Profile | Compile/link | APK | Native library inside APK |','|---|---|---|---|'] - for p in ('platform','oq_host_view','oq_host_callback'): - r=out['profiles'].get(p,{});apk=r.get('apk') or {};lines.append(f"| {p} | {r.get('compile_link',{}).get('status','NOT_RUN')} | {r.get('package',{}).get('status','NOT_RUN')} | `{apk.get('main_library_path','')}` |") - lines+=['','## Device observations',''] - if not valid_runs:lines.append('Valid device runs for this build: NOT_RUN.') - for d in valid_runs: - lines.append(f"### {d.get('profile')} — {d.get('status')}") - lines.append('') - if d.get('fixture_staging_driver') is not None:lines.append('fixture_staging_driver: `'+json.dumps(d['fixture_staging_driver'],ensure_ascii=False)+'`') - if d.get('preflight') is not None:lines.append('preflight: `'+json.dumps(d['preflight'],ensure_ascii=False)+'`') - if d.get('infrastructure_blocker') is not None:lines.append('infrastructure_blocker: `'+json.dumps(d['infrastructure_blocker'],ensure_ascii=False)+'`') - for obs in d.get('observations',[]):lines.append(f"- `{obs.get('id')}` — `{obs.get('status')}` — {obs.get('detail','')}") - if d.get('errors'): - for e in d['errors']:lines.append('- error: '+str(e)) - lines.append('') - if out['history']: - lines+=['## Historical blocked/failed attempts',''] - for d in out['history']: - lines.append(f"- `{d.get('directory')}` / {d.get('profile')} — {d.get('status')}: {d.get('infrastructure_blocker') or d.get('errors')}") - lines+=['## Decision-test results','', '| Question / test | Status | Blocker |','|---|---|---|'] - for item in out['decision_test_results']:lines.append(f"| {item['question_id']} / {item['test']} | {item['status']} | {item['blocker'] or ''} |") - md_path=ROOT/'reports/TASK1-DECISION-EVIDENCE.md';md_path.write_text('\n'.join(lines)+'\n',encoding='utf-8') - print(md_path);print(json_path);return 0 - -if __name__=='__main__':raise SystemExit(main()) diff --git a/tools/device.py b/tools/device.py old mode 100755 new mode 100644 index 8312386..251f8fb --- a/tools/device.py +++ b/tools/device.py @@ -1,134 +1,85 @@ -"""Install and collect a selected Android decision probe with diagnostic evidence.""" +"""Install and launch the current Android application package.""" from __future__ import annotations -import argparse, json, re, shutil, subprocess, time +import argparse, json, shutil, subprocess, xml.etree.ElementTree as ET from pathlib import Path -from common import ROOT, CPUS, ProbeError, safe_relative, sha256, stamp, write_json - -DECISION_PROFILES={'oq_host_view','oq_host_callback'} +from common import (ROOT, CPUS, ProbeError, sha256, CURRENT_BUILD_METADATA, + CURRENT_PACKAGE_METADATA, CURRENT_APK, CURRENT_BUILD_SCHEMA, + CURRENT_PACKAGE_SCHEMA) class Adb: - def __init__(self,serial:str|None): self.base=['adb']+(['-s',serial] if serial else []) + def __init__(self, serial: str | None): self.base=['adb']+(['-s',serial] if serial else []) def run(self,*args:str,check:bool=True,timeout:int=120)->subprocess.CompletedProcess: p=subprocess.run(self.base+list(args),capture_output=True,timeout=timeout) if check and p.returncode: raise ProbeError('ADB command failed: '+' '.join(args)+'\n'+p.stderr.decode(errors='replace')) return p def text(self,*args:str,check:bool=True)->str: return self.run(*args,check=check).stdout.decode(errors='replace').strip() -def own_report_paths(text:str)->list[str]: - paths=[] - for line in text.splitlines(): - name=line.strip()[2:] if line.strip().startswith('./') else line.strip() - try: safe_relative(name) - except ProbeError: continue - if name.endswith('kam-architecture-probe/runtime.json'): paths.append(name) - return paths +def fail(message: str) -> None: raise ProbeError(message) -def validate_runtime(data:dict,package:dict,require_frames:bool=True)->list[str]: - errors=[] - for key,value in (('schema','kam-android-runtime/1'),('build_id',package['build_id']),('profile',package['profile']),('cpu_expected',package['cpu'])): - if data.get(key)!=value: errors.append('Wrong/stale runtime '+key) - if data.get('automatic_smoke_status')!='PASS': errors.append('Automatic smoke did not pass') - tests=data.get('tests') - if not isinstance(tests,list) or not tests: return errors+['No runtime tests'] - by_id={x.get('id'):x for x in tests if isinstance(x,dict)}; profile=package['profile']; required=['android.startup']; observed=[] - if profile not in DECISION_PROFILES: - required += ['abi.pointer','abi.scalar_layout','abi.endianness','files.native_utf8','files.seek','files.case_sensitive','fpc.zstream','fpc.thread'] - observed += ['decision.persistence.rename_replace','decision.persistence.append_reopen'] - if profile=='oq_host_view': required += ['decision.host.view'] - elif profile=='oq_host_callback': required += ['decision.host.callback'] - if profile=='platform': required += ['decision.text.cwstring_unit']; observed += ['decision.text.cp1251_decode','decision.text.cp1251_encode'] - if require_frames: required += ['render.frame_callbacks','render.context'] - for name in required: - if by_id.get(name,{}).get('status')!='PASS': errors.append('Required runtime check did not pass: '+name) - for name in observed: - if name not in by_id: errors.append('Decision observation missing: '+name) - if any(t.get('status')=='FAIL' for t in tests if isinstance(t,dict)): errors.append('One or more runtime checks failed') - if require_frames and data.get('session',{}).get('frames',0)<30: errors.append('Fewer than 30 frames') - return errors +def load_json(path: Path, missing: str) -> dict: + try: value=json.loads(path.read_text()) + except FileNotFoundError: fail(missing) + except (OSError, ValueError) as error: fail(f'Malformed {path.name}: {error}') + if not isinstance(value,dict): fail(f'Malformed {path.name}') + return value -def read_runtime(adb:Adb,pkg:str)->tuple[dict|None,str|None]: - paths=own_report_paths(adb.text('shell','run-as',pkg,'find','.','-name','runtime.json',check=False)) - if len(paths)!=1: return None,None - raw=adb.run('exec-out','run-as',pkg,'cat',paths[0],check=False).stdout - try: return json.loads(raw),paths[0] - except (ValueError,UnicodeDecodeError): return None,None - -def wait_runtime(adb:Adb,pkg:str,predicate,timeout:int)->tuple[dict|None,str|None]: - end=time.monotonic()+timeout - while time.monotonic()None: - out.joinpath(name).write_text('exit='+str(p.returncode)+'\nstdout:\n'+p.stdout.decode(errors='replace')+'\nstderr:\n'+p.stderr.decode(errors='replace'),encoding='utf-8') - -def main()->int: - ap=argparse.ArgumentParser(description=__doc__) - ap.add_argument('--profile',default='platform');ap.add_argument('--serial');ap.add_argument('--index',type=Path,default=ROOT/'dist/LATEST.json') - ap.add_argument('--seconds',type=int,default=30);ap.add_argument('--exercise-lifecycle',action='store_true');ap.add_argument('--stage-file',type=Path) - a=ap.parse_args() - if a.seconds<5: ap.error('--seconds must be at least 5') - out=ROOT/'reports/device'/stamp(); n=1 - while out.exists(): out=ROOT/'reports/device'/(stamp()+'-'+str(n));n+=1 - out.mkdir(parents=True) - record={'schema':'kam-android-device/2','status':'BLOCKED','errors':[],'preflight':{'status':'NOT_RUN'},'manual_visual_touch_audio':'NOT_VERIFIED','world_simulation':'NOT_RUN','command_environment':{'host_adb':shutil.which('adb'),'timeout_seconds':a.seconds}} - adb=None +def manifest_identity() -> dict: try: - index=json.loads(a.index.read_text()); matches=[p for p in index.get('packages',[]) if p.get('profile')==a.profile] - if len(matches)!=1: raise ProbeError('No unique successfully-built APK for '+a.profile) - package=matches[0]; apk=ROOT/package['file']; pkg=package['package'];record.update(build_id=package['build_id'],profile=a.profile,package=package) - if not apk.is_file() or sha256(apk)!=package['sha256']: raise ProbeError('APK missing/changed') - if not re.fullmatch(r'org\.kammobile\.archprobe\.[a-z0-9]+',pkg): raise ProbeError('Refusing unrelated Android package') - if not shutil.which('adb'): record['preflight']={'status':'BLOCKED','reason':'adb is not installed on host'};raise ProbeError(record['preflight']['reason']) - listed=Adb(None).text('devices').splitlines()[1:]; devices=[line.split()[:2] for line in listed if line.strip()] - record['preflight']={'adb':'PASS','devices':devices} - if len(devices)!=1 or len(devices[0])!=2 or devices[0][1]!='device': record['preflight'].update(status='BLOCKED',reason='Require exactly one authorized device; found '+repr(devices));raise ProbeError(record['preflight']['reason']) - if a.serial and a.serial!=devices[0][0]: record['preflight'].update(status='BLOCKED',reason='Requested serial is not the sole authorized device');raise ProbeError(record['preflight']['reason']) - adb=Adb(devices[0][0]); record['device']={'serial':devices[0][0],'abi':adb.text('shell','getprop','ro.product.cpu.abilist'),'android':adb.text('shell','getprop','ro.build.version.release'),'sdk':adb.text('shell','getprop','ro.build.version.sdk'),'model':adb.text('shell','getprop','ro.product.model')} - if CPUS[package['cpu']][0] not in record['device']['abi'].split(','): record['preflight'].update(status='BLOCKED',reason='Device ABI does not support arm64-v8a');raise ProbeError(record['preflight']['reason']) - record['preflight']['status']='PASS' - install=adb.run('install','-r',str(apk),check=False);save_command(out,'install.log',install);record['install']={'package_id':pkg,'initial_exit':install.returncode,'result':install.stdout.decode(errors='replace').strip()} + root=ET.parse(ROOT/'app/CastleEngineManifest.xml').getroot(); version=root.find('version') + if root.tag!='project' or version is None: fail('Malformed permanent manifest') + return {'name':root.attrib['name'],'package':root.attrib['qualified_name'], + 'version':version.attrib['value'],'version_code':int(version.attrib['code'])} + except (OSError, ET.ParseError, KeyError, ValueError) as error: fail(f'Malformed permanent manifest: {error}') + +def current_package() -> tuple[dict, str]: + if not CURRENT_BUILD_METADATA.is_file(): raise RuntimeError('Current build metadata is missing') + build=load_json(CURRENT_BUILD_METADATA,'Current build metadata is missing') + if set(build)!={'schema','build_id','status','cpu','abi','application','upstream_commit','resource_count','compile','package','package_metadata','error'} or build.get('schema')!=CURRENT_BUILD_SCHEMA: fail('Invalid current build schema') + if build.get('status')!='PASS': raise RuntimeError('Current build is not PASS') + app=build.get('application'); identity=manifest_identity() + if not isinstance(app,dict) or any(app.get(key)!=value for key,value in identity.items()): fail('Build application identity mismatch') + if build.get('package_metadata')!='out/current/package.json': fail('Build package metadata locator mismatch') + package=load_json(CURRENT_PACKAGE_METADATA,'Current package metadata is missing') + if set(package)!={'schema','build_id','application','cpu','abi','apk'} or package.get('schema')!=CURRENT_PACKAGE_SCHEMA: fail('Invalid current package schema') + if package.get('build_id')!=build.get('build_id') or package.get('application')!=identity: fail('Package metadata identity mismatch') + if package.get('cpu')!=build.get('cpu') or package.get('abi')!=build.get('abi'): fail('Package build mismatch') + apk=package.get('apk') + if not isinstance(apk,dict) or set(apk)!={'file','sha256','size','native_library_path_in_apk'} or apk.get('file')!='out/current/kam-mobile.apk': fail('Package APK locator mismatch') + if package['cpu'] not in CPUS or package['abi']!=CPUS[package['cpu']][0]: fail('Invalid package ABI') + if not CURRENT_APK.is_file() or apk.get('sha256')!=sha256(CURRENT_APK): fail('Current APK missing or changed') + return package, identity['package'] + +def main() -> int: + ap=argparse.ArgumentParser(description=__doc__); ap.add_argument('--serial'); a=ap.parse_args() + try: + package,pkg=current_package() + except ProbeError as error: + print('FAIL\n'+str(error)); return 1 + except RuntimeError as error: + print('BLOCKED\n'+str(error)); return 2 + except (KeyError, TypeError) as error: + print('FAIL\n'+str(error)); return 1 + if not shutil.which('adb'): + print('BLOCKED\nadb is not installed on host'); return 2 + try: + devices=[line.split()[:2] for line in Adb(None).text('devices').splitlines()[1:] if line.strip()] + if len(devices)!=1 or len(devices[0])!=2 or devices[0][1]!='device': fail('Require exactly one authorized device') + serial=devices[0][0] + if a.serial and a.serial!=serial: fail('Requested serial is not the sole authorized device') + adb=Adb(serial) + if package['abi'] not in adb.text('shell','getprop','ro.product.cpu.abilist').split(','): fail('Device ABI does not support package ABI') + except (ProbeError, OSError, subprocess.TimeoutExpired) as error: + print('BLOCKED\n'+str(error)); return 2 + try: + install=adb.run('install','-r',str(CURRENT_APK),check=False) if install.returncode and b'INSTALL_FAILED_UPDATE_INCOMPATIBLE' in install.stdout+install.stderr: - uninstall=adb.run('uninstall',pkg,check=False);save_command(out,'uninstall.log',uninstall);record['install']['uninstall_for_ephemeral_key']={'exit':uninstall.returncode,'result':uninstall.stdout.decode(errors='replace').strip()} - install=adb.run('install',str(apk),check=False);save_command(out,'install-retry.log',install);record['install'].update(retry_exit=install.returncode,retry_result=install.stdout.decode(errors='replace').strip()) - if install.returncode: raise ProbeError('APK install failed; see install.log') - run_as=adb.run('shell','run-as',pkg,'id',check=False);save_command(out,'run-as.log',run_as);record['run_as']={'status':'PASS' if run_as.returncode==0 else 'BLOCKED','output':run_as.stdout.decode(errors='replace').strip(),'error':run_as.stderr.decode(errors='replace').strip()} - if run_as.returncode: record['infrastructure_blocker']={'stage':'run_as_preflight','reason':'run-as unavailable for '+pkg};raise ProbeError(record['infrastructure_blocker']['reason']) - if a.stage_file: - if not a.stage_file.is_file(): raise ProbeError('Stage file not found: '+str(a.stage_file)) - rel='files/kam-architecture-probe/driver-staged-fixture.bin';adb.run('shell','run-as',pkg,'sh','-c','mkdir -p files/kam-architecture-probe') - p=subprocess.run(adb.base+['exec-in','run-as',pkg,'sh','-c','cat > '+rel],input=a.stage_file.read_bytes(),stdout=subprocess.PIPE,stderr=subprocess.PIPE);save_command(out,'stage.log',p) - size=adb.text('shell','run-as',pkg,'stat','-c','%s',rel,check=False);record['fixture_staging_driver']={'status':'PASS' if p.returncode==0 and size==str(a.stage_file.stat().st_size) else 'FAIL','source':str(a.stage_file),'bytes':a.stage_file.stat().st_size,'device_bytes':size} - adb.run('shell','am','force-stop',pkg); listing=adb.text('shell','run-as',pkg,'find','.','-name','runtime.json',check=False) - for name in own_report_paths(listing): adb.run('shell','run-as',pkg,'rm','--',name) - adb.run('logcat','-c');record['launch_marker_epoch_ms']=int(time.time()*1000) - component=adb.text('shell','cmd','package','resolve-activity','--brief','-a','android.intent.action.MAIN','-c','android.intent.category.LAUNCHER',pkg); candidates=[x.strip() for x in component.splitlines() if x.strip().startswith(pkg+'/')] - if len(candidates)!=1 or not re.fullmatch(r'[\w.$/]+',candidates[0]): raise ProbeError('Cannot resolve probe launch Activity') - launch=adb.run('shell','am','start','-W','-n',candidates[0],check=False);save_command(out,'launch.log',launch);record['launch']={'activity':candidates[0],'exit':launch.returncode,'result':launch.stdout.decode(errors='replace').strip()} - if launch.returncode: raise ProbeError('Launch failed; see launch.log') - data,path=wait_runtime(adb,pkg,lambda d:any(x.get('id')=='android.startup' and x.get('status')=='PASS' for x in d.get('tests',[])),a.seconds) - if data is None: raise ProbeError('android.startup was not observed before timeout; see logcat.log and process.log') - if a.exercise_lifecycle: - adb.run('shell','input','keyevent','KEYCODE_HOME');time.sleep(1);adb.run('shell','am','start','-W','-n',candidates[0]) - expected=[] - if a.exercise_lifecycle and a.profile=='platform': expected=['decision.schedule.callback_progress','decision.schedule.worker_progress','decision.schedule.lifecycle'] - if a.profile=='platform': expected += ['render.frame_callbacks','decision.text.cwstring_unit','decision.text.cp1251_decode','decision.text.cp1251_encode'] - if expected: data,path=wait_runtime(adb,pkg,lambda d: all(any(x.get('id')==name for x in d.get('tests',[])) for name in expected),a.seconds) - if data is None: raise ProbeError('Runtime report disappeared; see logcat.log and process.log') - out.joinpath('runtime.json').write_text(json.dumps(data,ensure_ascii=False,indent=2)+'\n',encoding='utf-8');record['runtime_path']=path - record['errors']+=validate_runtime(data,package,require_frames=package['profile'] not in DECISION_PROFILES) - if expected and not all(any(x.get('id')==name for x in data.get('tests',[])) for name in expected): record['errors'].append('Timed out waiting for observations: '+', '.join(expected)) - if a.stage_file and record.get('fixture_staging_driver',{}).get('status')!='PASS': record['errors'].append('Existing server fixture staging size mismatch') - record['status']='FAIL' if record['errors'] else 'AUTOMATIC_SMOKE_PASS' - except (ProbeError,OSError,ValueError,KeyError,subprocess.TimeoutExpired) as e: - record['errors'].append(str(e)) - if record['preflight'].get('status')=='BLOCKED': record['infrastructure_blocker']={'stage':'device_preflight','reason':record['preflight'].get('reason',str(e))} - finally: - if adb: - p=adb.run('shell','pidof',record.get('package',{}).get('package',''),check=False);save_command(out,'process.log',p);record['process_after_launch']=p.stdout.decode(errors='replace').strip() - p=adb.run('logcat','-d','-v','threadtime',check=False);save_command(out,'logcat.log',p) - write_json(out/'device-result.json',record);print(record['status']+'\n'+str(out));[print(x) for x in record['errors']] - return 0 if record['status']=='AUTOMATIC_SMOKE_PASS' else 1 if record['status']=='FAIL' else 2 + adb.run('uninstall',pkg,check=False); install=adb.run('install',str(CURRENT_APK),check=False) + if install.returncode: fail('APK install failed') + candidates=[line.strip() for line in adb.text('shell','cmd','package','resolve-activity','--brief','-a','android.intent.action.MAIN','-c','android.intent.category.LAUNCHER',pkg).splitlines() if line.strip()] + if len(candidates)!=1 or not candidates[0].startswith(pkg+'/'): fail('Cannot resolve exactly one application launcher activity') + if adb.run('shell','am','start','-W','-n',candidates[0],check=False).returncode: fail('Launch failed') + except (ProbeError, OSError, subprocess.TimeoutExpired) as error: + print('FAIL\n'+str(error)); return 1 + print('PASS'); return 0 + if __name__=='__main__': raise SystemExit(main()) diff --git a/tools/generate.py b/tools/generate.py index 710de2b..d8a894b 100755 --- a/tools/generate.py +++ b/tools/generate.py @@ -1,130 +1,84 @@ """Generate isolated Android CGE targets. No KaM stubs or rewritten game loaders.""" from __future__ import annotations -import json, os, shutil, urllib.parse, xml.etree.ElementTree as ET +import hashlib, os, xml.etree.ElementTree as ET from pathlib import Path -from common import ROOT, COMMIT, VERSION, CPUS, ProbeError, safe_relative, sha256, write_json -from source_resolution import build_source_resolution +from common import ROOT, ProbeError UPSTREAM_DEFINES=['DBG_SKIP_SECURE_AUTH'] # Existing upstream switch; private multiplayer authentication is out of scope. COMPILER_OPTIONS=['-B','-CX-','-XX-','-k--no-undefined','-k--no-gc-sections'] -def imports_source(profile:dict)->tuple[str,list[str]]: - units=list(profile['units']) - points=profile['id'] in ('core_types','core_streams','integration') - streams=profile['id'] in ('core_streams','integration') - if points and 'KM_Points' not in units:units.append('KM_Points') - head='unit ProbeImports;\n{$mode delphi}{$H+}\ninterface\nuses ProbeReport;\nprocedure RunUpstreamChecks(R:TProbeReport);\nimplementation\n' - head+='uses SysUtils'+(' ,'+', '.join(units) if units else '')+';\n' - head+='procedure RunUpstreamChecks(R:TProbeReport);\n' - if points: - head+='var P,Q:TKMPoint;\n' - if streams:head+=' S:TKMemoryStreamBinary; I:Integer; U:UnicodeString;\n' - head+='begin\n' - if not units: - head+=" R.Add('upstream.imports','NOT_RUN','Platform-only APK; no KaM modules imported');\n" - else: - head+=" R.Add('upstream.imports','PASS','Real imported units initialized: "+', '.join(units)+"; this does not execute a game world');\n" - if points: - head+=''' try - P:=KMPoint(17,-3); Q:=KMPointRight(P); - R.Check('core.points',(P.X=17) and (P.Y=-3) and (Q.X=18) and (Q.Y=-3), - 'Calls original KMPoint / KMPointRight, not a local replacement'); - R.Check('core.point_layout',(SizeOf(TKMPoint)=8) and (SizeOf(TKMPointF)=8), - Format('TKMPoint=%d TKMPointF=%d',[SizeOf(TKMPoint),SizeOf(TKMPointF)])); - except on E:Exception do R.Add('core.points','FAIL',E.ClassName+': '+E.Message);end; -''' - if streams: - head+=''' try - S:=TKMemoryStreamBinary.Create; - try - S.Write(Integer(123456)); S.Write(P); S.WriteW(UnicodeString('KaM Android')); - S.Position:=0; S.Read(I); S.Read(Q); S.ReadW(U); - R.Check('core.stream_roundtrip',(I=123456) and (Q.X=P.X) and (Q.Y=P.Y) - and (U=UnicodeString('KaM Android')) and (S.Position=S.Size), - 'Calls original TKMemoryStreamBinary Read/Write/ReadW/WriteW'); - finally S.Free;end; - except on E:Exception do R.Add('core.stream_roundtrip','FAIL',E.ClassName+': '+E.Message);end; -''' - head+='end;\nend.\n' - return head,units +_PROJECT_ATTRIBUTES={ + 'name':'kam_mobile', 'caption':'KaM Remake Mobile', 'author':'KaM Mobile', + 'qualified_name':'org.kammobile.kamremake', 'game_units':'GameInitialize', + 'compiler':'fpc', 'screen_orientation':'landscape', 'fullscreen_immersive':'true', +} -def asset_inventory(root:Path|None, includes:list[str]|None=None)->list[dict]: - if root is None:return [] - root=root.resolve() - if not root.is_dir():raise ProbeError('Asset directory does not exist: '+str(root)) - if includes: - result=[] - seen=set() - for raw in includes: - rel=safe_relative(raw.replace('\\','/')) - if rel in seen:continue - seen.add(rel);p=root/rel - if p.is_symlink():raise ProbeError('Asset symlinks are not allowed: '+str(p)) - if not p.is_file():raise ProbeError('Requested asset path not found under asset root: '+rel) - result.append({'path':rel,'size':p.stat().st_size,'sha256':sha256(p), - 'url':'castle-data:/game/'+urllib.parse.quote(rel,safe='/')}) - return result - result=[] - for p in sorted(root.rglob('*')): - if p.is_symlink():raise ProbeError('Asset symlinks are not allowed: '+str(p)) - if p.is_dir():continue - if not p.is_file():raise ProbeError('Not a regular asset: '+str(p)) - rel=safe_relative(p.relative_to(root).as_posix()) - if rel=='README.md' or '.git' in p.parts:continue - result.append({'path':rel,'size':p.stat().st_size,'sha256':sha256(p), - 'url':'castle-data:/game/'+urllib.parse.quote(rel,safe='/')}) - return result +def _one_child(parent: ET.Element, tag: str) -> ET.Element: + children=parent.findall(tag) + if len(children)!=1:raise ProbeError(f'Manifest requires exactly one <{tag}>') + return children[0] -def make_target(project:Path,profile:dict,cpu:str,build_id:str,source:Path|None, - validated:dict|None,assets_root:Path|None=None,assets:list[dict]|None=None)->dict: +def _manifest_path(path: object, project: Path, kind: str) -> str: + try: + resolved=Path(path).resolve() + if not resolved.is_dir():raise ProbeError(f'{kind} path missing: {resolved}') + relative=os.path.relpath(resolved,project).replace(os.sep,'/') + if not any(char.isspace() for char in relative):return relative + alias=project/'.source-paths'/hashlib.sha256(str(resolved).encode()).hexdigest() + alias.parent.mkdir(exist_ok=True) + if alias.exists() or alias.is_symlink(): + if not alias.is_symlink() or alias.resolve()!=resolved:raise ProbeError(f'{kind} path alias collision: {alias}') + else:alias.symlink_to(resolved,target_is_directory=True) + return os.path.relpath(alias,project).replace(os.sep,'/') + except ProbeError: + raise + except (OSError, TypeError, ValueError, RuntimeError) as error: + raise ProbeError(f'Cannot represent {kind} path {path!r}: {error}') from error + +def _permanent_manifest() -> tuple[ET.ElementTree, ET.Element]: + path=ROOT/'app/CastleEngineManifest.xml' + try: + tree=ET.parse(path) + except (OSError, ET.ParseError) as error: + raise ProbeError(f'Cannot read permanent manifest: {error}') from error + root=tree.getroot() + if root.tag!='project' or root.attrib!=_PROJECT_ATTRIBUTES: + raise ProbeError('Invalid permanent application identity') + version=_one_child(root,'version') + if version.get('value')!='0.4.0' or version.get('code')!='30': + raise ProbeError('Invalid permanent application version') + options=_one_child(root,'compiler_options') + if [child.tag for child in options] != ['search_paths','defines','custom_options','library_paths']: + raise ProbeError('Invalid permanent compiler policy order') + search_paths,defines,custom_options,library_paths=( + _one_child(options,tag) for tag in ('search_paths','defines','custom_options','library_paths')) + if search_paths.findall('path') or library_paths.findall('path'): + raise ProbeError('Permanent manifest path slots must be empty') + if [item.text for item in defines.findall('define')]!=UPSTREAM_DEFINES: + raise ProbeError('Invalid permanent compiler defines') + if [item.text for item in custom_options.findall('option')]!=COMPILER_OPTIONS: + raise ProbeError('Invalid permanent compiler options') + return tree,root + +def materialize_application_project(project: Path, source_resolution: dict) -> dict: + """Create one fresh Castle project from the permanent manifest and RP01 paths.""" if project.exists():raise ProbeError('Target directory must be fresh: '+str(project)) + try: + compiler_paths=tuple(source_resolution['compiler_source_paths']) + library_paths=tuple(source_resolution['library_paths']) + except (KeyError, TypeError) as error: + raise ProbeError(f'Invalid source resolution: {error}') from error + tree,root=_permanent_manifest() project.mkdir(parents=True) - shutil.copytree(ROOT/'code',project/'code') - text,units=imports_source(profile) - (project/'code/ProbeImports.pas').write_text(text,encoding='utf-8') - (project/'code/ProbeBuild.pas').write_text( - 'unit ProbeBuild;\n{$mode objfpc}{$H+}\ninterface\nconst\n'+ - f" ProbeBuildId='{build_id}';\n ProbeProfile='{profile['id']}';\n ProbeCpu='{cpu}';\n"+ - f" ProbeUpstreamCommit='{COMMIT}';\n ProbePointerBytes={CPUS[cpu][3]};\nimplementation\nend.\n") - data=project/'data';data.mkdir() - entries=assets or [] - if entries and assets_root is None:raise ProbeError('Missing asset root') - for e in entries: - dest=data/'game'/e['path'];dest.parent.mkdir(parents=True,exist_ok=True) - shutil.copyfile(assets_root/e['path'],dest) - if sha256(dest)!=e['sha256']:raise ProbeError('Asset changed while staging: '+e['path']) - write_json(data/'assets-manifest.json',{'schema':'kam-probe-assets/1','files':entries}) - name='kam_probe_'+profile['id'] - root=ET.Element('project',name=name,caption='KaM Android: '+profile['id'], - author='KaM Mobile',qualified_name='org.kammobile.archprobe.'+profile['id'].replace('_',''), - game_units=profile.get('game_unit','GameInitialize'),compiler='fpc',screen_orientation='landscape',fullscreen_immersive='true') - ET.SubElement(root,'version',value=VERSION,code='30') - options=ET.SubElement(root,'compiler_options');paths=ET.SubElement(options,'search_paths') - if units: - if source is None or validated is None:raise ProbeError('KaM source resolution is required for '+profile['id']) - resolution=build_source_resolution(validated,source,project/'code') - compiler_source_paths=resolution['compiler_source_paths'] - library_paths=resolution['library_paths'] - else: - compiler_source_paths=(project/'code',) - library_paths=() - for p in compiler_source_paths: - if not p.is_dir():raise ProbeError('Source path missing: '+str(p)) - ET.SubElement(paths,'path',value=os.path.relpath(p,project).replace(os.sep,'/')) - defines=ET.SubElement(options,'defines') - for define in (UPSTREAM_DEFINES if units else []):ET.SubElement(defines,'define').text=define - custom=ET.SubElement(options,'custom_options') - for flag in COMPILER_OPTIONS:ET.SubElement(custom,'option').text=flag - libraries=ET.SubElement(options,'library_paths') - for p in library_paths: - if not p.is_dir():raise ProbeError('Native library path missing: '+str(p)) - ET.SubElement(libraries,'path',value=os.path.relpath(p,project).replace(os.sep,'/')) - # Android SDK versions deliberately inherited from the locked CGE image. - ET.indent(root) - ET.ElementTree(root).write(project/'CastleEngineManifest.xml',encoding='utf-8',xml_declaration=True) - description={'profile':profile['id'],'name':name,'package':'org.kammobile.archprobe.'+profile['id'].replace('_',''), - 'cpu':cpu,'units':units,'game_unit':profile.get('game_unit','GameInitialize'),'source_paths':[str(p) for p in compiler_source_paths], - 'compiler_options':COMPILER_OPTIONS,'upstream_defines':UPSTREAM_DEFINES if units else [], - 'assets':entries,'shims':False} - write_json(project/'target.json',description) - return description + data_root=project/'data';data_root.mkdir() + options=_one_child(root,'compiler_options') + for tag,paths,kind in (('search_paths',compiler_paths,'Source'),('library_paths',library_paths,'Native library')): + slot=_one_child(options,tag) + for child in list(slot):slot.remove(child) + for path in paths:ET.SubElement(slot,'path',value=_manifest_path(path,project,kind)) + ET.indent(tree) + tree.write(project/'CastleEngineManifest.xml',encoding='utf-8',xml_declaration=True) + version=_one_child(root,'version') + return {'project_dir':project,'data_root':data_root,'name':root.get('name'),'caption':root.get('caption'), + 'package':root.get('qualified_name'),'version':version.get('value'),'version_code':int(version.get('code')), + 'game_unit':root.get('game_units'),'compiler_source_paths':compiler_paths,'library_paths':library_paths} diff --git a/tools/resource_staging.py b/tools/resource_staging.py new file mode 100644 index 0000000..81c88c8 --- /dev/null +++ b/tools/resource_staging.py @@ -0,0 +1,179 @@ +"""Load the permanent runtime-resource selection.""" +from __future__ import annotations + +import json +import os +import shutil +import urllib.parse +from pathlib import Path + +from common import ROOT, ProbeError, safe_relative, sha256, write_json + + +RUNTIME_RESOURCE_ROOT = ROOT / 'private-assets' +RUNTIME_RESOURCE_MANIFEST = ROOT / 'config/runtime-resources.json' +_SCHEMA = 'kam-runtime-resources/1' +_KEYS = {'schema', 'entries'} +_ENTRY_KEYS = {'kind', 'path'} + + +def _load_runtime_resource_manifest() -> tuple[dict[str, str], ...]: + """Return the validated authored resource selection, without resolving it.""" + try: + value = json.loads(RUNTIME_RESOURCE_MANIFEST.read_text(encoding='utf-8')) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise ProbeError(f'Cannot read runtime-resource manifest: {error}') from error + if not isinstance(value, dict) or set(value) != _KEYS: + raise ProbeError('Invalid runtime-resource manifest fields') + if value['schema'] != _SCHEMA: + raise ProbeError('Invalid runtime-resource manifest schema') + if not isinstance(value['entries'], list) or not value['entries']: + raise ProbeError('Runtime-resource entries must be a non-empty array') + + seen_paths: set[str] = set() + entries = [] + for entry in value['entries']: + if not isinstance(entry, dict) or set(entry) != _ENTRY_KEYS: + raise ProbeError('Invalid runtime-resource entry fields') + kind, path = entry['kind'], entry['path'] + if not isinstance(kind, str) or kind not in {'file', 'directory'} or not isinstance(path, str) or not path: + raise ProbeError('Invalid runtime-resource entry') + safe_relative(path) + if path in seen_paths: + raise ProbeError(f'Duplicate runtime-resource path: {path!r}') + seen_paths.add(path) + entries.append({'kind': kind, 'path': path}) + return tuple(entries) + + +def _resolve_runtime_resource_files(entries: tuple[dict[str, str], ...]) -> tuple[str, ...]: + """Expand parsed resource entries into exact, safe file identities.""" + root = RUNTIME_RESOURCE_ROOT + if root.is_symlink() or not root.is_dir(): + raise ProbeError(f'Runtime-resource root is not a real directory: {root}') + try: + fixed_root = root.resolve(strict=True) + except OSError as error: + raise ProbeError(f'Cannot resolve runtime-resource root: {error}') from error + + def checked(path): + if path.is_symlink(): + raise ProbeError(f'Runtime-resource symlink is not allowed: {path}') + try: + resolved = path.resolve(strict=True) + except OSError as error: + raise ProbeError(f'Cannot resolve runtime resource: {path}') from error + if not resolved.is_relative_to(fixed_root): + raise ProbeError(f'Runtime resource escapes fixed root: {path}') + return resolved + + def exact(path: str): + current = root + for component in path.split('/'): + if not current.is_dir(): + raise ProbeError(f'Runtime-resource path component is not a directory: {current}') + try: + with os.scandir(current) as scanned: + child = next((Path(entry.path) for entry in scanned + if entry.name == component), None) + except OSError as error: + raise ProbeError(f'Cannot inspect runtime-resource path: {current}') from error + if child is None: + raise ProbeError(f'Runtime-resource path not found: {path}') + checked(child) + current = child + return current + + selected: set[str] = set() + for entry in entries: + kind, authored = entry['kind'], entry['path'] + target = exact(authored) + if kind == 'file': + if target.name == '.gitkeep' or not target.is_file() or target.is_symlink(): + raise ProbeError(f'Runtime-resource file is not a regular selectable file: {authored}') + selected.add(authored) + continue + if not target.is_dir() or target.is_symlink(): + raise ProbeError(f'Runtime-resource directory is not a real directory: {authored}') + produced = False + pending = [target] + while pending: + directory = pending.pop() + try: + children = list(os.scandir(directory)) + except OSError as error: + raise ProbeError(f'Cannot inspect runtime-resource directory: {directory}') from error + for child_entry in children: + child = Path(child_entry.path) + checked(child) + if child_entry.is_symlink(): + raise ProbeError(f'Runtime-resource symlink is not allowed: {child}') + if child_entry.is_dir(follow_symlinks=False): + pending.append(child) + elif child_entry.is_file(follow_symlinks=False): + if child.name != '.gitkeep': + produced = True + selected.add(child.relative_to(root).as_posix()) + else: + raise ProbeError(f'Runtime-resource node is not a regular file or directory: {child}') + if not produced: + raise ProbeError(f'Runtime-resource directory is empty: {authored}') + + folded: dict[str, str] = {} + for path in selected: + prior = folded.setdefault(path.casefold(), path) + if prior != path: + raise ProbeError(f'Case-colliding runtime-resource paths: {prior!r}, {path!r}') + return tuple(sorted(selected, key=lambda path: (path.casefold(), path))) + + +def stage_runtime_resources(castle_data_root: Path) -> tuple[dict[str, object], ...]: + """Stage the selected runtime resources into a Castle data directory.""" + paths = _resolve_runtime_resource_files(_load_runtime_resource_manifest()) + records = [] + for path in paths: + source = RUNTIME_RESOURCE_ROOT / path + if source.is_symlink() or not source.is_file(): + raise ProbeError(f'Runtime-resource file disappeared or changed type: {path}') + try: + digest = sha256(source) + records.append({'path': path, 'size': source.stat().st_size, 'sha256': digest, + 'url': 'castle-data:/game/' + urllib.parse.quote(path, safe='/')}) + except OSError as error: + raise ProbeError(f'Cannot inventory runtime resource: {path}: {error}') from error + + game = castle_data_root / 'game' + manifest = castle_data_root / 'assets-manifest.json' + try: + if game.is_symlink() or game.is_file(): + game.unlink() + elif game.exists(): + shutil.rmtree(game) + if manifest.exists() or manifest.is_symlink(): + manifest.unlink() + for record in records: + path = record['path'] + source = RUNTIME_RESOURCE_ROOT / path + if source.is_symlink() or not source.is_file(): + raise ProbeError(f'Runtime-resource file disappeared or changed type: {path}') + destination = game / path + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + if sha256(destination) != record['sha256']: + raise ProbeError(f'Runtime-resource copy hash mismatch: {path}') + write_json(manifest, {'schema': 'kam-runtime-assets/1', 'files': records}) + except ProbeError: + shutil.rmtree(game, ignore_errors=True) + try: + manifest.unlink(missing_ok=True) + except OSError: + pass + raise + except OSError as error: + shutil.rmtree(game, ignore_errors=True) + try: + manifest.unlink(missing_ok=True) + except OSError: + pass + raise ProbeError(f'Cannot stage runtime resources: {error}') from error + return tuple(records) diff --git a/tools/runner.py b/tools/runner.py index 63546d3..0187440 100755 --- a/tools/runner.py +++ b/tools/runner.py @@ -1,25 +1,19 @@ """One-command Android-only compile, full native link, APK and evidence pipeline.""" from __future__ import annotations -import argparse, json, os, re, shutil, subprocess, sys, uuid, zipfile +import argparse, json, os, re, shutil, subprocess, uuid, zipfile from pathlib import Path -from common import ROOT, COMMIT, CPUS, ProbeError, stamp, sha256, write_json, run_command, first_errors, classify +from common import (ROOT, COMMIT, CPUS, ProbeError, stamp, sha256, write_json, run_command, + first_errors, CURRENT_OUTPUT_DIR, CURRENT_BUILD_METADATA, + CURRENT_PACKAGE_METADATA, CURRENT_APK, CURRENT_BUILD_SCHEMA, + CURRENT_PACKAGE_SCHEMA) from source_fetch import fetch, verify from source_audit import inventory -from source_resolution import load_source_contract, validate_source_contract -from generate import make_target, asset_inventory +from source_resolution import load_source_contract, validate_source_contract, build_source_resolution +from generate import materialize_application_project from artifacts import inspect_elf, inspect_apk +from resource_staging import stage_runtime_resources IMAGE_TAG='kambi/castle-engine-cloud-builds-tools:cge-unstable' -PROFILES=json.loads((ROOT/'config/profiles.json').read_text()) - -def selected_profiles(text:str)->list[dict]: - if text=='all':return PROFILES - names=[s.strip() for s in text.split(',') if s.strip()] - available={p['id'] for p in PROFILES} - if not names or set(names)-available:raise ProbeError('Unknown/empty profiles: '+text) - names=set(names)|{'platform'} # Always establish the same-toolchain platform baseline. - return [p for p in PROFILES if p['id'] in names] - def cge_command(cpu:str,action:str)->list[str]: if cpu not in CPUS:raise ProbeError('Unsupported Android CPU') if action not in ('compile','package'):raise ProbeError('Not an Android build action') @@ -27,73 +21,6 @@ def cge_command(cpu:str,action:str)->list[str]: if action=='package':cmd+=['--assume-compiled','--package-format=android-apk'] return cmd+[action] -def blocked_row(profile:dict,reason:str)->dict: - return {'id':profile['id'],'required':profile['required'],'purpose':profile['purpose'], - 'compile_link':{'status':'BLOCKED','reason':reason},'package':{'status':'NOT_RUN'}, - 'runtime':{'status':'NOT_RUN'},'errors':[]} - -def gate(rows:list[dict])->str: - by_id={r['id']:r for r in rows} - required=[p for p in PROFILES if p['required']] - if any(r['compile_link']['status']=='FAIL' or r['package']['status']=='FAIL' for r in rows if r['required']): - return 'NOT_BUILDABLE_AS_IS' - if any(r['compile_link']['status']=='BLOCKED' for r in rows if r['required']):return 'BLOCKED' - if any(p['id'] not in by_id for p in required):return 'PARTIAL_SELECTION' - for p in required: - r=by_id[p['id']] - if r['compile_link']['status']!='PASS':return 'BLOCKED' - if p['package'] and r['package']['status']!='PASS':return 'BLOCKED' - return 'ANDROID_BUILD_GATE_PASS' - -def render_summary(result:dict,out:Path)->None: - title='# KaM r16020 — Android architecture probe\n\n' - title+=f"Build ID: `{result['run_id']}` · CPU: `{result['cpu']}` · Gate: **{result['gate']}**\n\n" - title+='Нет desktop target. Успех означает сборку выбранных Android-корней, не игровую совместимость.\n\n' - title+='| Проба | Compile + native link | APK | Ошибка / следующий шаг |\n|---|---|---|---|\n' - for r in result['profiles']: - error=(r.get('errors') or [r['compile_link'].get('reason','')])[0] - error=error.replace('|','/').replace('\n',' ')[:260] - title+=f"| {r['id']} | {r['compile_link']['status']} | {r['package']['status']} | {error} |\n" - title+='\n## Что установлено\n\n' - title+='`PASS` compile_link = FPC завершился успешно, найден свежий ELF нужного ABI, проверены корневые PPU. Линковка не отключена; требуются определённые символы. ' - title+='`PASS` APK = упаковка завершилась, проверены ZIP/DEX, правильный ABI, совпадение native library и DT_NEEDED. ' - title+='Подпись проверяется отдельно, если доступен apksigner. `runtime=NOT_RUN` до реального запуска на Android.\n\n' - title+='`NOT_BUILDABLE_AS_IS` — найдено препятствие в текущем коде/окружении, а не доказательство, что core надо переписать. ' - title+='Ошибки legacy_render/legacy_audio диагностические и не входят в обязательный gate, но их транзитивное попадание в core всё равно видно.\n\n' - title+='## Как принимать решение\n\n' - title+='| Свидетельство | Разумное решение |\n|---|---|\n' - title+='| Корень собирается с оригинальными исходниками | Кандидат на reuse; поведение ещё проверяется отдельно |\n' - title+='| Активная зависимость Windows/LCL/desktop GL | Исследовать минимальное отделение платформенной части |\n' - title+='| Отсутствует source unit / package | Проверить комплектность исходников, условные defines и пакет для Android |\n' - title+='| Undefined reference / отсутствует native library | Сборка/подключение Android-библиотеки либо адаптер Castle |\n' - title+='| Один и тот же блокер у многих модулей | Исправить общий корень и повторить пробы; не считать это множеством отдельных переписываний |\n' - title+='| Срабатывает exception при запуске | Снять logcat; проверить unit initialization/lifecycle/доступ к данным |\n' - title+='\nНи один автоматический результат здесь не выносит решение «написать подсистему с нуля».\n' - (out/'SUMMARY.md').write_text(title,encoding='utf-8') - -def finalize(result:dict,out:Path)->int: - result['gate']=gate(result['profiles']) - write_json(out/'result.json',result);render_summary(result,out) - write_json(ROOT/'reports/LATEST.json',{'run_id':result['run_id'],'directory':str(out.relative_to(ROOT)), - 'gate':result['gate'],'summary':str((out/'SUMMARY.md').relative_to(ROOT))}) - shutil.copyfile(out/'SUMMARY.md',ROOT/'reports/LATEST-SUMMARY.md') - # Evidence intentionally excludes APK/.so, user game assets, caches and source trees. - with zipfile.ZipFile(out/'evidence.zip','w',zipfile.ZIP_DEFLATED) as z: - for p in sorted(out.rglob('*')): - if p.is_file() and p.suffix in ('.json','.log','.md','.txt','.xml','.pas'): - z.write(p,p.relative_to(out)) - print('\n'+result['gate']) - print('Report: '+str(out/'SUMMARY.md')) - print('Evidence (no game assets): '+str(out/'evidence.zip')) - return 0 if result['gate']=='ANDROID_BUILD_GATE_PASS' else 1 if result['gate']=='NOT_BUILDABLE_AS_IS' else 2 - -def new_result(cpu:str,profiles:list[dict],reason:str='Not started')->tuple[dict,Path]: - rid=stamp()+'-'+uuid.uuid4().hex[:8] - out=ROOT/'reports/runs'/rid;out.mkdir(parents=True) - return ({'schema':'kam-android-build/1','run_id':rid,'cpu':cpu,'commit':COMMIT, - 'desktop_target':False,'shims':False,'runtime':'NOT_RUN', - 'profiles':[blocked_row(p,reason) for p in profiles]},out) - def tool_versions(out:Path)->dict: commands={'castle':['castle-engine','--version'],'fpc':['fpc','-iV'],'java':['java','-version'], 'adb':['adb','version']} @@ -122,175 +49,154 @@ def signature_tool()->str|None: if os.environ.get(key):paths+=list(Path(os.environ[key]).glob('build-tools/*/apksigner')) return str(sorted(paths)[-1]) if paths else None -def build_local(a:argparse.Namespace,profiles:list[dict])->int: - result,out=new_result(a.cpu,profiles) - result['toolchain']=tool_versions(out) - if not shutil.which('castle-engine'): - for r in result['profiles']:r['compile_link']['reason']='castle-engine not installed; no Android compilation attempted' - return finalize(result,out) - source=ROOT/'vendor/kam_remake';audit=None;validated=None;assets=[] - need_source=any(p['units'] for p in profiles) - try: - contract=load_source_contract() - assets=asset_inventory(Path(a.assets),a.asset_path) if a.assets else [] - result['asset_count']=len(assets) - if need_source: - if (source/'SOURCE_RECEIPT.json').is_file():receipt=verify(source) - elif a.no_fetch:raise ProbeError('Pinned source tree absent and --no-fetch requested') - else:receipt=fetch(source) - write_json(out/'source-receipt.json',receipt) - audit=inventory(source);write_json(out/'source-inventory.json',audit) - validated=validate_source_contract(contract,source,receipt,audit) - except (ProbeError,OSError,ValueError,KeyError) as e: - result['source_or_inputs_error']=str(e) - return finalize(result,out) - work=ROOT/'build'/result['run_id'];work.mkdir(parents=True) - dist=ROOT/'dist'/result['run_id'];dist.mkdir(parents=True) - package_index=[] - for idx,profile in enumerate(profiles): - row=result['profiles'][idx];ident=profile['id'];project=work/ident - print(f"[{idx+1}/{len(profiles)}] Android {a.cpu}: {ident}",flush=True) - try: - if profile['units']: - missing=[u for u in profile['units'] if u.casefold() not in audit['units']] - if missing:raise ProbeError('Missing required original source units: '+', '.join(missing)) - # Resources only enter APKs when explicitly requested. The platform baseline remains independent. - pack=profile['package'] or a.package_all - use_assets=bool(a.assets) and pack and ident=='platform' - description=make_target(project,profile,a.cpu,result['run_id'],source if profile['units'] else None, - validated if profile['units'] else None, - Path(a.assets) if use_assets else None,assets if use_assets else []) - row['target']=description - evidence=out/ident;evidence.mkdir() - for file in ('CastleEngineManifest.xml','target.json'): - shutil.copyfile(project/file,evidence/file) - shutil.copyfile(project/'code/ProbeImports.pas',evidence/'ProbeImports.pas') - row['compile_link']=run_command(cge_command(a.cpu,'compile'),project,evidence/'compile-link.log',a.timeout) - text=(evidence/'compile-link.log').read_text(errors='replace') - if row['compile_link']['status']!='PASS': - row['errors']=first_errors(text) or [text[-1500:]];row['triage']=classify(text);continue - name='lib'+description['name']+'.so' - outputs=list(project.rglob('lib'+description['name']+'*.so')) - if not outputs:raise ProbeError('Compiler exited 0 but no expected native library matching: '+name) - hashes={sha256(p) for p in outputs} - if len(hashes)!=1:raise ProbeError('Ambiguous different native outputs') - library=outputs[0];name=library.name;elf=inspect_elf(library.read_bytes(),a.cpu) - row['native_library']={'file':str(library.relative_to(ROOT)),'sha256':sha256(library),'elf':elf} - ppu_files={p.stem.casefold():p for p in project.rglob('*.ppu')} - evidence_units=[] - for unit in description['units']: - p=ppu_files.get(unit.casefold()) - if p is None or p.read_bytes()[:3]!=b'PPU': - raise ProbeError('No fresh Pascal unit artifact found for '+unit) - evidence_units.append({'unit':unit,'ppu':str(p.relative_to(ROOT)),'sha256':sha256(p), - 'source_candidates':audit['units'].get(unit.casefold(),[])}) - row['compiled_roots']=evidence_units - if not pack:continue - row['package']=run_command(cge_command(a.cpu,'package'),project,evidence/'package.log',a.timeout) - if row['package']['status']!='PASS': - row['errors']+=first_errors((evidence/'package.log').read_text(errors='replace'));continue - candidates=list(project.rglob('*.apk')) - if not candidates:raise ProbeError('Packaging exited 0 but no new APK') - apk_hashes={sha256(p) for p in candidates} - if len(apk_hashes)!=1:raise ProbeError('Multiple different APK outputs; refusing arbitrary selection') - apk=candidates[0] - apk_info=inspect_apk(apk,a.cpu,name,row['native_library']['sha256'],library.read_bytes()) - signer=signature_tool() - if signer: - sign=run_command([signer,'verify','--verbose',str(apk)],project,evidence/'signature.log',120) - row['signature']=sign - if sign['status']!='PASS':row['package']['status']='FAIL';continue - else:row['signature']={'status':'NOT_RUN','reason':'apksigner not found'} - final=dist/(description['name']+'-'+a.cpu+'-debug.apk');shutil.copy2(apk,final) - row['apk']={'file':str(final.relative_to(ROOT)),**apk_info} - package_index.append({'profile':ident,'package':description['package'],'file':str(final.relative_to(ROOT)), - 'sha256':sha256(final),'cpu':a.cpu,'build_id':result['run_id'], - 'native_library_path_in_apk':apk_info['main_library_path']}) - except (ProbeError,OSError,ValueError,KeyError,zipfile.BadZipFile) as e: - row['errors'].append(str(e)) - # A supposedly successful compiler/packager without valid output is a real test failure. - if row['package']['status']=='PASS':row['package']['status']='FAIL' - elif row['compile_link']['status']=='PASS':row['compile_link']['status']='FAIL' - else:row['compile_link']={'status':'BLOCKED','reason':str(e)} - finally: - write_json(out/'result-in-progress.json',result) - if need_source: - try:verify(source) - except ProbeError as e: - result['source_modified_during_build']=str(e) - for r in result['profiles']: - if r['id']!='platform':r['compile_link']['status']='FAIL';r['errors'].append(str(e)) - write_json(dist/'apks.json',{'build_id':result['run_id'],'packages':package_index}) - write_json(ROOT/'dist/LATEST.json',{'build_id':result['run_id'],'directory':str(dist.relative_to(ROOT)), - 'packages':package_index}) - return finalize(result,out) +def _application(descriptor:dict|None)->dict|None: + if descriptor is None:return None + return {key:descriptor[key] for key in ('name','caption','package','version','version_code','game_unit')} -def docker_build(a:argparse.Namespace,profiles:list[dict])->int: - result,out=new_result(a.cpu,profiles,'Docker bootstrap not completed') - if not shutil.which('docker'): - for r in result['profiles']:r['compile_link']['reason']='Docker not installed. Use --local only with a configured CGE/FPC Android toolchain.' - return finalize(result,out) +def _record(build_id:str, cpu:str, status:str, application:dict|None=None, + resource_count:int|None=None, compile:dict|None=None, package:dict|None=None, + package_metadata:str|None=None, error:str|None=None)->dict: + return {'schema':CURRENT_BUILD_SCHEMA,'build_id':build_id,'status':status,'cpu':cpu,'abi':CPUS[cpu][0], + 'application':application,'upstream_commit':COMMIT,'resource_count':resource_count, + 'compile':compile or {'status':'NOT_RUN','exit_code':None}, + 'package':package or {'status':'NOT_RUN','exit_code':None}, + 'package_metadata':package_metadata,'error':error} + +def _finish(record:dict)->int: + if record['status']!='PASS': + CURRENT_OUTPUT_DIR.mkdir(parents=True,exist_ok=True) + for path in (CURRENT_PACKAGE_METADATA,CURRENT_APK): + path.unlink(missing_ok=True) + record['package_metadata']=None + write_json(CURRENT_BUILD_METADATA,record) + return {'PASS':0,'FAIL':1,'BLOCKED':2}[record['status']] + +def _valid_docker_handoff(record:object,build_id:str,code:int)->bool: + fields={'schema','build_id','status','cpu','abi','application','upstream_commit','resource_count','compile','package','package_metadata','error'} + statuses={'PASS':0,'FAIL':1,'BLOCKED':2};stage_statuses=set(statuses)|{'NOT_RUN'} + if not isinstance(record,dict) or set(record)!=fields or record.get('schema')!=CURRENT_BUILD_SCHEMA or record.get('build_id')!=build_id or statuses.get(record.get('status'))!=code:return False + cpu=record.get('cpu') + if cpu not in CPUS or record.get('abi')!=CPUS[cpu][0] or record.get('upstream_commit')!=COMMIT:return False + if record['resource_count'] is not None and (type(record['resource_count']) is not int or record['resource_count']<0):return False + app=record['application'] + if app is not None and (not isinstance(app,dict) or set(app)!={'name','caption','package','version','version_code','game_unit'} or any(not isinstance(app[key],str) or not app[key] for key in ('name','caption','package','version','game_unit')) or type(app['version_code']) is not int):return False + for stage in ('compile','package'): + value=record[stage] + if not isinstance(value,dict) or set(value)!={'status','exit_code'} or value.get('status') not in stage_statuses:return False + if (value['status']=='NOT_RUN') != (value['exit_code'] is None) or value['exit_code'] is not None and type(value['exit_code']) is not int:return False + if record['status']=='PASS': + return app is not None and record['resource_count'] is not None and record['compile']['status']==record['package']['status']=='PASS' and record['package_metadata']=='out/current/package.json' and record['error'] is None + return record['package_metadata'] is None and isinstance(record['error'],str) and bool(record['error']) + +def _error(log:Path)->str: + text=log.read_text(errors='replace') if log.exists() else '' + return (first_errors(text) or [text[-1500:] or 'Build command failed'])[0] + +def _prepare(build_id:str, diagnostics:bool=True)->tuple[Path,Path]: + work=ROOT/'build'/build_id; logs=work/'logs' + logs.mkdir(parents=True,exist_ok=True) + if diagnostics:tool_versions(logs) + return work,logs + +def build_local(a:argparse.Namespace,build_id:str)->int: + work,logs=_prepare(build_id, not bool(os.environ.get('KAM_BUILD_ID'))) + app=None; resources=None; compile={'status':'NOT_RUN','exit_code':None}; package={'status':'NOT_RUN','exit_code':None} try: + vendor_root=ROOT/'vendor/kam_remake' + receipt=verify(vendor_root) if (vendor_root/'SOURCE_RECEIPT.json').is_file() else (fetch(vendor_root) if not a.no_fetch else (_ for _ in ()).throw(ProbeError('Pinned source tree absent and --no-fetch requested'))) + audited=inventory(vendor_root) + validated=validate_source_contract(load_source_contract(),vendor_root,receipt,audited) + resolution=build_source_resolution(validated,vendor_root,ROOT/'code') + descriptor=materialize_application_project(work/'application',resolution);app=_application(descriptor) + resources=stage_runtime_resources(descriptor['data_root']) + except (ProbeError,OSError,ValueError,KeyError) as error: + return _finish(_record(build_id,a.cpu,'BLOCKED',app,None,compile,package,error=str(error))) + if not shutil.which('castle-engine'): + return _finish(_record(build_id,a.cpu,'BLOCKED',app,len(resources),compile,package,error='castle-engine not installed')) + project=descriptor['project_dir']; compile_result=run_command(cge_command(a.cpu,'compile'),project,logs/'compile.log',a.timeout) + compile={'status':compile_result['status'],'exit_code':compile_result['exit_code']} + if compile['status']!='PASS': + return _finish(_record(build_id,a.cpu,compile['status'],app,len(resources),compile,package,error=_error(logs/'compile.log'))) + try: + candidates=list(project.rglob(f"lib{descriptor['name']}_android_{a.cpu}.so")) + if len(candidates)!=1:raise ProbeError('Compiler exited 0 but expected exactly one native library') + library=candidates[0]; inspect_elf(library.read_bytes(),a.cpu) + except (ProbeError,OSError,ValueError) as error: + compile['status']='FAIL' + return _finish(_record(build_id,a.cpu,'FAIL',app,len(resources),compile,package,error=str(error))) + package_result=run_command(cge_command(a.cpu,'package'),project,logs/'package.log',a.timeout) + package={'status':package_result['status'],'exit_code':package_result['exit_code']} + if package['status']!='PASS': + return _finish(_record(build_id,a.cpu,package['status'],app,len(resources),compile,package,error=_error(logs/'package.log'))) + try: + candidates=list(project.rglob('*.apk')) + if not candidates:raise ProbeError('Packaging exited 0 but no APK') + if len({sha256(path) for path in candidates})!=1:raise ProbeError('Multiple different APK outputs') + apk=candidates[0]; apk_info=inspect_apk(apk,a.cpu,library.name,sha256(library),library.read_bytes()) + signer=signature_tool() + if signer: + signed=run_command([signer,'verify','--verbose',str(apk)],project,logs/'signature.log',a.timeout) + if signed['status']!='PASS':raise ProbeError(_error(logs/'signature.log')) + except (ProbeError,OSError,ValueError,KeyError,zipfile.BadZipFile) as error: + package['status']='FAIL' + return _finish(_record(build_id,a.cpu,'FAIL',app,len(resources),compile,package,error=str(error))) + try: + CURRENT_OUTPUT_DIR.mkdir(parents=True,exist_ok=True) + temporary=CURRENT_APK.with_name(CURRENT_APK.name+'.tmp') + shutil.copyfile(apk,temporary);os.replace(temporary,CURRENT_APK) + package_data={'schema':CURRENT_PACKAGE_SCHEMA,'build_id':build_id, + 'application':{key:app[key] for key in ('name','package','version','version_code')}, + 'cpu':a.cpu,'abi':CPUS[a.cpu][0], + 'apk':{'file':'out/current/kam-mobile.apk','sha256':sha256(CURRENT_APK),'size':CURRENT_APK.stat().st_size, + 'native_library_path_in_apk':apk_info['main_library_path']}} + write_json(CURRENT_PACKAGE_METADATA,package_data) + return _finish(_record(build_id,a.cpu,'PASS',app,len(resources),compile,package, + 'out/current/package.json',None)) + except (OSError,ValueError,KeyError) as error: + package['status']='FAIL' + return _finish(_record(build_id,a.cpu,'FAIL',app,len(resources),compile,package,error=str(error))) + +def docker_build(a:argparse.Namespace,build_id:str)->int: + compile={'status':'NOT_RUN','exit_code':None};package={'status':'NOT_RUN','exit_code':None} + try: + work,logs=_prepare(build_id) + if not shutil.which('docker'):raise ProbeError('Docker not installed') lock=ROOT/'config/toolchain.lock.json' if lock.is_file(): image=json.loads(lock.read_text())['image'] if not re.fullmatch(r'[\w./-]+@sha256:[a-f0-9]{64}',image):raise ProbeError('Invalid toolchain digest lock') - p=subprocess.run(['docker','image','inspect',image],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) - if p.returncode: - r=run_command(['docker','pull','--platform=linux/amd64',image],ROOT,out/'docker-pull.log',a.timeout) - if r['status']!='PASS':raise ProbeError('Cannot obtain locked Docker image; see docker-pull.log') + if subprocess.run(['docker','image','inspect',image],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode: + if run_command(['docker','pull','--platform=linux/amd64',image],ROOT,logs/'docker-pull.log',a.timeout)['status']!='PASS':raise ProbeError('Cannot obtain locked Docker image') else: - r=run_command(['docker','pull','--platform=linux/amd64',IMAGE_TAG],ROOT,out/'docker-pull.log',a.timeout) - if r['status']!='PASS':raise ProbeError('Docker image pull failed; see docker-pull.log') - raw=subprocess.check_output(['docker','image','inspect',IMAGE_TAG],text=True) - info=json.loads(raw)[0];digests=info.get('RepoDigests',[]) - image=next((d for d in digests if 'castle-engine-cloud-builds-tools@sha256:' in d),None) + if run_command(['docker','pull','--platform=linux/amd64',IMAGE_TAG],ROOT,logs/'docker-pull.log',a.timeout)['status']!='PASS':raise ProbeError('Docker image pull failed') + info=json.loads(subprocess.check_output(['docker','image','inspect',IMAGE_TAG],text=True))[0] + image=next((value for value in info.get('RepoDigests',[]) if 'castle-engine-cloud-builds-tools@sha256:' in value),None) if image is None:raise ProbeError('Image has no immutable RepoDigest') - write_json(lock,{'image':image,'image_id':info['Id'],'resolved_from':IMAGE_TAG,'locked_at':stamp(), - 'note':'Subsequent runs use this digest, never silently advance the tag.'}) - cache=ROOT/'.cache/home';cache.mkdir(parents=True,exist_ok=True) - cmd=['docker','run','--rm','--platform=linux/amd64','--user','0:0', - '-e','HOME=/work/.cache/home','-v',str(ROOT)+':/work','-w','/work'] + write_json(lock,{'image':image,'image_id':info['Id'],'resolved_from':IMAGE_TAG,'locked_at':stamp(),'note':'Subsequent runs use this digest, never silently advance the tag.'}) + cmd=['docker','run','--rm','--platform=linux/amd64','--user','0:0','-e','HOME=/work/.cache/home', + '-e','KAM_BUILD_ID='+build_id,'-v',str(ROOT)+':/work','-w','/work'] if os.environ.get('GITHUB_TOKEN'):cmd+=['-e','GITHUB_TOKEN'] - if a.assets: - resource=Path(a.assets).resolve() - if not resource.is_dir():raise ProbeError('Asset directory not found: '+str(resource)) - cmd+=['-v',str(resource)+':/probe-assets:ro'] - cmd+=['--entrypoint','python3',image,'tools/runner.py','--local','--cpu',a.cpu, - '--profiles',a.profiles,'--timeout',str(a.timeout)] + cmd+=['--entrypoint','python3',image,'tools/runner.py','--local','--cpu',a.cpu,'--timeout',str(a.timeout)] if a.no_fetch:cmd+=['--no-fetch'] - if a.package_all:cmd+=['--package-all'] - if a.assets:cmd+=['--assets','/probe-assets'] - for rel in a.asset_path or []:cmd+=['--asset-path',rel] - write_json(out/'docker-command.json',{'command':cmd,'image':image}) - # Stream the user's real build directly. No background processes/promises. - latest=ROOT/'reports/LATEST.json' - before=latest.read_bytes() if latest.exists() else None code=subprocess.call(cmd,cwd=ROOT) - after=latest.read_bytes() if latest.exists() else None - if code in (0,1,2) and after is not None and after!=before: - fresh=json.loads(after) - if (ROOT/fresh['directory']/'result.json').is_file():return code - raise ProbeError('Container could not finish the report; Docker exit code '+str(code)) - except (ProbeError,OSError,ValueError,KeyError,subprocess.CalledProcessError) as e: - for r in result['profiles']:r['compile_link']['reason']=str(e) - return finalize(result,out) + if code not in (0,1,2):raise ProbeError('Docker exit code '+str(code)) + record=json.loads(CURRENT_BUILD_METADATA.read_text()) + if not _valid_docker_handoff(record,build_id,code):raise ProbeError('Container current build metadata is missing, stale, or inconsistent') + return code + except (ProbeError,OSError,ValueError,KeyError,subprocess.CalledProcessError) as error: + return _finish(_record(build_id,a.cpu,'BLOCKED',None,None,compile,package,error=str(error))) def main()->int: ap=argparse.ArgumentParser(description=__doc__) ap.add_argument('--local',action='store_true',help='Use installed toolchain; still compile Android only') ap.add_argument('--cpu',choices=CPUS,default='aarch64') - ap.add_argument('--profiles',default='all',help='all or comma-separated IDs from config/profiles.json') ap.add_argument('--no-fetch',action='store_true',help='Require previously fetched/verified source tree') - ap.add_argument('--assets',help='Optional private KaM resource root; no resources are downloaded') - ap.add_argument('--asset-path',action='append',help='Relative path under --assets to stage. Repeat for a small selected fixture set.') - ap.add_argument('--package-all',action='store_true',help='Package every successfully linked probe for device startup checks') ap.add_argument('--timeout',type=int,default=1800,help='Per-stage safety timeout in seconds') a=ap.parse_args() - if a.asset_path and not a.assets:ap.error('--asset-path requires --assets') if a.timeout<1:ap.error('--timeout must be positive') - try:profiles=selected_profiles(a.profiles) - except ProbeError as e:ap.error(str(e)) - return build_local(a,profiles) if a.local else docker_build(a,profiles) + inherited=os.environ.get('KAM_BUILD_ID') + if inherited and not re.fullmatch(r'\d{8}T\d{6}Z-[0-9a-f]{8}',inherited):ap.error('Invalid KAM_BUILD_ID') + build_id=inherited or stamp()+'-'+uuid.uuid4().hex[:8] + return build_local(a,build_id) if a.local else docker_build(a,build_id) if __name__=='__main__':raise SystemExit(main())