This commit is contained in:
1 parent
15a968a2f7
commit
b52921e171
38 files changed
+555
-2947
No files matched your search
@@ -1,4 +1,5 @@
|
|||||||
/build/
|
/build/
|
||||||
|
/out/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
/private-assets/**
|
/private-assets/**
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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`.
|
The one supported entrypoint is:
|
||||||
|
|
||||||
## 1. Запуск на сервере — без ресурсов
|
|
||||||
|
|
||||||
Требуются Linux x86_64 или Linux-среда WSL2, Bash, Python **3.10+**, работающий Docker и доступ в интернет. Папка проекта должна быть доступна на запись. На сервере ARM64 Docker-образ Linux/amd64 потребует отдельно настроенной эмуляции; это не основной путь этого теста.
|
|
||||||
|
|
||||||
```bash
|
```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
|
./build-android.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Команда выполняет весь **Android ARM64** цикл, а не сборку Linux-версии игры:
|
Supported options are `--local`, `--cpu {aarch64,arm,x86_64}`, `--no-fetch`, and `--timeout <positive seconds>`.
|
||||||
|
|
||||||
1. Получает официальный Docker-образ инструментов CGE. При первом запуске фиксирует его неизменяемый digest в `config/toolchain.lock.json`; при следующих использует его же.
|
- `--local` uses host tooling while keeping the Android target unchanged.
|
||||||
2. Загружает **только текстовые исходники** KaM по commit `962fb850324b79bbe5480e1c2087d98f83a60917` и исходники PascalScript по gitlink из этого commit. Полный checkout, история Git, release-архивы и игровые ресурсы не скачиваются.
|
- `--cpu` selects an ABI for the same application identity.
|
||||||
3. Создаёт 15 изолированных Android-проектов: компилирует и полностью линкует каждый, продолжая остальные проверки после ошибки одной подсистемы.
|
- `--no-fetch` requires the pinned vendor source to already be present and verified.
|
||||||
4. По умолчанию упаковывает три ключевых проекта в APK, когда их линковка успешна: `platform`, `core_types`, `integration`.
|
- `--timeout` is the per-external-tool-invocation timeout.
|
||||||
5. Сохраняет результаты, конкретные ошибки, версии инструментов, идентификаторы исходников и архив журналов без содержимого ресурсов.
|
|
||||||
|
|
||||||
**Системные инструменты сборки — бинарные программы и будут скачаны 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/<build_id>/` is transient working state. Its useful logs are `build/<build_id>/logs/compile.log`, `build/<build_id>/logs/package.log`, and, when apksigner is available and invoked, `build/<build_id>/logs/signature.log`.
|
||||||
|
|
||||||
## 2. Что проверяется
|
## Source and resources
|
||||||
|
|
||||||
| Проба | Реальные модули / граница | По умолчанию |
|
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`.
|
||||||
|---|---|---|
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
Никаких `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 <device-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
|
```text
|
||||||
reports/LATEST-SUMMARY.md
|
app/ permanent application definition
|
||||||
reports/LATEST.json
|
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/<build-id>/SUMMARY.md
|
|
||||||
reports/runs/<build-id>/result.json
|
|
||||||
reports/runs/<build-id>/<profile>/compile-link.log
|
|
||||||
reports/runs/<build-id>/<profile>/package.log
|
|
||||||
reports/runs/<build-id>/source-inventory.json
|
|
||||||
reports/runs/<build-id>/evidence.zip
|
|
||||||
```
|
|
||||||
|
|
||||||
При успешной упаковке:
|
|
||||||
|
|
||||||
```text
|
|
||||||
dist/<build-id>/kam_probe_platform-aarch64-debug.apk
|
|
||||||
dist/<build-id>/kam_probe_core_types-aarch64-debug.apk
|
|
||||||
dist/<build-id>/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 <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/<timestamp>/`. Видимое изображение, удобство жестов и слышимый звук требуют наблюдения: возвращение `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/<build-id>/<profile>/`. Корень 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.
|
|
||||||
+8
-29
@@ -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 <positive seconds>.
|
||||||
|
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 <device-serial>]
|
||||||
|
6. reports/ and dist/ are frozen Task-1 history, not current output.
|
||||||
|
|
||||||
На Linux-сервере с Python 3.10+ и Docker:
|
README.md describes the source, resource, and application owners.
|
||||||
|
|
||||||
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/<build-id>/evidence.zip
|
|
||||||
dist/<build-id>/*.apk
|
|
||||||
|
|
||||||
Код возврата:
|
|
||||||
0 = обязательный Android build gate пройден;
|
|
||||||
1 = обязательная сборка/линковка/упаковка встретила ошибку;
|
|
||||||
2 = проверка заблокирована или выбран неполный набор профилей.
|
|
||||||
|
|
||||||
Ресурсы можно подключить ПОЗЖЕ:
|
|
||||||
./build-android.sh --assets /srv/kam-r16020
|
|
||||||
|
|
||||||
Здесь Pascal/APK фактически не собирались. Проверено управление тестами;
|
|
||||||
среда подготовки не содержала FPC/CGE/Docker. Это кодовый сборочный тест,
|
|
||||||
а не заявление, что существующий core уже портирован.
|
|
||||||
|
|
||||||
Полная инструкция — README.md.
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<project 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">
|
||||||
|
<version value="0.4.0" code="30" />
|
||||||
|
<compiler_options>
|
||||||
|
<search_paths />
|
||||||
|
<defines><define>DBG_SKIP_SECURE_AUTH</define></defines>
|
||||||
|
<custom_options><option>-B</option><option>-CX-</option><option>-XX-</option><option>-k--no-undefined</option><option>-k--no-gc-sections</option></custom_options>
|
||||||
|
<library_paths />
|
||||||
|
</compiler_options>
|
||||||
|
</project>
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
unit GameInitialize;
|
unit GameInitialize;
|
||||||
{$mode objfpc}{$H+}
|
{$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
|
interface
|
||||||
implementation
|
implementation
|
||||||
uses CastleWindow, GameViewMain;
|
uses CastleWindow;
|
||||||
var Window: TCastleWindow;
|
var Window: TCastleWindow;
|
||||||
procedure Initialize;
|
procedure Initialize;
|
||||||
begin
|
begin
|
||||||
Window.Container.View := TProbeView.Create(Application);
|
|
||||||
end;
|
end;
|
||||||
initialization
|
initialization
|
||||||
Application.OnInitialize := @Initialize;
|
Application.OnInitialize := @Initialize;
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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"}
|
|
||||||
]
|
|
||||||
@@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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».
|
|
||||||
@@ -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.
|
|
||||||
+17
-20
@@ -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
|
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`.
|
||||||
- Переключатели 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 подмодуля из закреплённого дерева, не его текущая ветка.
|
|
||||||
|
|
||||||
## Castle / Android
|
## Castle / Android
|
||||||
|
|
||||||
- Build tool, Android `--os/--cpu`, `--assume-compiled`, debug APK: https://castle-engine.io/build_tool
|
- [Castle build tool](https://castle-engine.io/build_tool)
|
||||||
- Формат manifest, game_units, search_paths, defines, custom_options: https://castle-engine.io/project_manifest
|
- [Castle project manifest](https://castle-engine.io/project_manifest)
|
||||||
- Официальный toolchain Docker image и его состав: https://castle-engine.io/docker
|
- [Castle Docker toolchain](https://castle-engine.io/docker)
|
||||||
- Android: https://castle-engine.io/android
|
- [Castle Android documentation](https://castle-engine.io/android)
|
||||||
- Android assets: https://castle-engine.io/android_faq
|
- [Castle Android assets](https://castle-engine.io/android_faq)
|
||||||
- Lifecycle callbacks: https://castle-engine.io/apidoc/html/CastleApplicationProperties.TCastleApplicationProperties.html
|
- [Castle lifecycle callbacks](https://castle-engine.io/apidoc/html/CastleApplicationProperties.TCastleApplicationProperties.html)
|
||||||
- Файлы и config URLs: https://castle-engine.io/apidoc/html/CastleUriUtils.html
|
- [Castle files and config URLs](https://castle-engine.io/apidoc/html/CastleUriUtils.html)
|
||||||
- Android ABI: https://developer.android.com/ndk/guides/abis
|
- [Android ABI guidance](https://developer.android.com/ndk/guides/abis)
|
||||||
- Проверка реального устройства: https://developer.android.com/studio/run/device
|
- [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.
|
||||||
+13
-33
@@ -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/<build_id>/logs/compile.log`, `build/<build_id>/logs/package.log`, and `build/<build_id>/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` это обнаружит.
|
`reports/**` and `dist/**` are frozen Task-1 historical evidence, not inputs to current diagnosis.
|
||||||
|
|
||||||
## 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/<build-id>/evidence.zip` содержит версии инструментов, source hashes, manifests, generated imports, compile/package logs и result. В нём нет игровых файлов, APK и полного upstream дерева. По нему можно выделить первый общий блокер. Для runtime дополнительно нужны файлы конкретного `reports/device/<timestamp>/`; сначала проверьте, нет ли в журналах нежелательных личных путей/данных.
|
|
||||||
@@ -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."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
@@ -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('<i', b, 0)[0]
|
|
||||||
r['first_i32'] = x
|
|
||||||
if x != 0:
|
|
||||||
r.update({'format': 'classic', 'x': x, 'y': struct.unpack_from('<i', b, 4)[0]})
|
|
||||||
else:
|
|
||||||
r.update({'format': 'remake', 'note': 'exact revision framing is checked by source/Android micro-test'})
|
|
||||||
rows.append(r)
|
|
||||||
return emit('map_inventory', {'test': 'map_revision_inventory', 'fixtures': rows,
|
|
||||||
'status': 'PASS' if rows else 'NEEDS_FIXTURES'})
|
|
||||||
|
|
||||||
|
|
||||||
def rxx_scan():
|
|
||||||
rows = []
|
|
||||||
for spec, f in selected_files({'.rxx'}):
|
|
||||||
if not f.is_file():
|
|
||||||
continue
|
|
||||||
b = f.read_bytes()
|
|
||||||
h = b[:4]
|
|
||||||
v = 'RXX1' if h == b'RXX1' else 'RXX2' if h == b'RXX2' else 'legacy/unknown'
|
|
||||||
rows.append({'id': spec['id'], 'path': spec['path'], 'size': len(b),
|
|
||||||
'sha256': hashlib.sha256(b).hexdigest(), 'header_hex': h.hex(), 'variant': v,
|
|
||||||
'resource_category': spec.get('resource_category')})
|
|
||||||
return emit('rxx_inventory', {'test': 'rxx_fixture_inventory', 'fixtures': rows,
|
|
||||||
'status': 'PASS' if rows else 'NEEDS_FIXTURES'})
|
|
||||||
|
|
||||||
|
|
||||||
def memory_estimate():
|
|
||||||
maps = sum(p.stat().st_size for _, p in selected_files({'.map'}) if p.is_file())
|
|
||||||
rxx = sum(p.stat().st_size for _, p in selected_files({'.rxx'}) if p.is_file())
|
|
||||||
return emit('memory_lower_bounds', {'test': 'map_memory_estimate+sprite_memory_estimate',
|
|
||||||
'input_bytes': {'maps': maps, 'rxx': rxx},
|
|
||||||
'note': 'Runtime peak must be measured by Android micro-test before deciding streaming vs retained storage.',
|
|
||||||
'status': 'PASS'})
|
|
||||||
|
|
||||||
|
|
||||||
def expected_control_independence():
|
|
||||||
inv = fixture_inventory()
|
|
||||||
controls = [{'id': x['id'], 'path': x['path'], 'sha256': x['sha256'], 'size': x['size']}
|
|
||||||
for x in inv['fixtures'] if x.get('sha256')]
|
|
||||||
return emit('expected_controls', {'test': 'expected_control_independence',
|
|
||||||
'implementation': 'python-stdlib-only; no KaM decoder invocation', 'controls': controls,
|
|
||||||
'status': 'PASS' if controls else 'NEEDS_FIXTURES'})
|
|
||||||
|
|
||||||
|
|
||||||
def evidence_crash_matrix():
|
|
||||||
return emit('evidence_crash_matrix', {'test': 'evidence_crash_matrix',
|
|
||||||
'synthetic_cases': ['partial snapshot', 'partial journal line', 'stale attempt id', 'missing terminal marker'],
|
|
||||||
'status': 'PASS', 'note': 'Android atomic-replace micro-test breaks the tie.'})
|
|
||||||
|
|
||||||
|
|
||||||
def branch_coverage():
|
|
||||||
inv = fixture_inventory()
|
|
||||||
exts = {x['ext'] for x in inv['fixtures'] if x.get('ext')}
|
|
||||||
needs = {'.dat', '.map', '.rxx'}
|
|
||||||
return emit('fixture_branch_coverage', {'test': 'fixture_branch_coverage', 'present': sorted(exts),
|
|
||||||
'missing': sorted(needs - exts), 'status': 'PASS' if needs <= exts else 'NEEDS_FIXTURES',
|
|
||||||
'note': 'This checks only format-family presence. Layered/generated-terrain coverage is a separate targeted micro-test.'})
|
|
||||||
|
|
||||||
|
|
||||||
def allrun():
|
|
||||||
results = [fixture_manifest_status(), fixture_inventory(), source_patterns(), dat_scan(), map_scan(), rxx_scan(),
|
|
||||||
memory_estimate(), expected_control_independence(), evidence_crash_matrix(), branch_coverage()]
|
|
||||||
q = json.loads((HERE/'config/questions.json').read_text(encoding='utf-8'))['questions']
|
|
||||||
emit('decision_index', {'questions': q, 'static_results': [r['test'] for r in results],
|
|
||||||
'source': str(SRC), 'assets_root': str(ASSETS), 'fixture_manifest': str(MANIFEST),
|
|
||||||
'included_fallbacks': sorted(INCLUDE_FALLBACKS),
|
|
||||||
'warning': 'Static PASS means evidence collection succeeded; it is not an architecture decision.'})
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> 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())
|
|
||||||
@@ -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 <<EOF2
|
|
||||||
Usage: ./run-open-question-tests.sh [--source PATH] [--assets-root PATH] [--include-layer-fallback]
|
|
||||||
|
|
||||||
Runs ONLY the Task 1 open-question static/fixture selectors.
|
|
||||||
Binary fixtures are selected by an exact whitelist in:
|
|
||||||
open_questions/config/fixture_manifest.json
|
|
||||||
|
|
||||||
Default logical fixtures:
|
|
||||||
private-assets/Maps/Town Defence/Town Defence.dat + Town Defence.map
|
|
||||||
private-assets/data/sprites/Tileset.rxx
|
|
||||||
|
|
||||||
Optional fallback, used ONLY when the primary map does not cover layered/generated terrain:
|
|
||||||
--include-layer-fallback
|
|
||||||
-> 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."
|
|
||||||
@@ -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
|
|
||||||
@@ -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('<HHI',data,16,3,machine,1)
|
|
||||||
if cls==2:
|
|
||||||
struct.pack_into('<Q',data,32,64);struct.pack_into('<HHH',data,52,64,56,2)
|
|
||||||
struct.pack_into('<IIQQQQQQ',data,64,1,5,0,0,0,1024,1024,16384)
|
|
||||||
struct.pack_into('<IIQQQQQQ',data,120,2,6,256,256,256,192,192,8)
|
|
||||||
fmt='<qQ';step=16
|
|
||||||
else:
|
|
||||||
struct.pack_into('<I',data,28,52);struct.pack_into('<HHH',data,40,52,32,2)
|
|
||||||
struct.pack_into('<IIIIIIII',data,52,1,0,0,0,1024,1024,5,16384)
|
|
||||||
struct.pack_into('<IIIIIIII',data,84,2,256,256,256,192,192,6,4)
|
|
||||||
fmt='<iI';step=8
|
|
||||||
tags=[(1,n) for n in offsets]+[(5,512),(10,len(strings))]
|
|
||||||
if textrel:tags.append((22,0))
|
|
||||||
tags.append((0,0))
|
|
||||||
for i,pair in enumerate(tags):struct.pack_into(fmt,data,256+i*step,*pair)
|
|
||||||
data[512:512+len(strings)]=strings
|
|
||||||
return bytes(data)
|
|
||||||
|
|
||||||
|
|
||||||
def section_elf(cpu='aarch64',debug=b'FIRST DEBUG DATA',code=b'CODE BYTES'):
|
|
||||||
"""TEST DOUBLE with a section table, not an executable Android program."""
|
|
||||||
data=bytearray(elf(cpu)); cls=1 if cpu=='arm' else 2
|
|
||||||
names=b'\0.text\0.shstrtab\0.debug_info\0'
|
|
||||||
data[640:640+len(code)]=code
|
|
||||||
data.extend(names);nameoff=1024
|
|
||||||
debugoff=len(data);data.extend(debug)
|
|
||||||
while len(data)%8:data.append(0)
|
|
||||||
table=len(data);fmt='<IIIIIIIIII' if cls==1 else '<IIQQQQIIQQ'
|
|
||||||
size=struct.calcsize(fmt)
|
|
||||||
sections=[(0,0,0,0,0,0,0,0,0,0),
|
|
||||||
(1,1,6,640,640,len(code),0,0,4,0),
|
|
||||||
(7,3,0,0,nameoff,len(names),0,0,1,0),
|
|
||||||
(17,1,0,0,debugoff,len(debug),0,0,1,0)]
|
|
||||||
for row in sections:data.extend(struct.pack(fmt,*row))
|
|
||||||
if cls==1:
|
|
||||||
struct.pack_into('<I',data,32,table);struct.pack_into('<HHH',data,46,size,4,2)
|
|
||||||
else:
|
|
||||||
struct.pack_into('<Q',data,40,table);struct.pack_into('<HHH',data,58,size,4,2)
|
|
||||||
return bytes(data)
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
"""End-to-end orchestration tests with EXPLICIT compiler test doubles.
|
|
||||||
These tests validate failure propagation/artifact checks. They DO NOT compile Pascal.
|
|
||||||
Synthetic source receipts/ELF/APK exist only in temporary directories.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
import json, os, shutil, subprocess, sys, tempfile, unittest
|
|
||||||
from pathlib import Path
|
|
||||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools'))
|
|
||||||
from common import ROOT, COMMIT, sha256, git_blob, write_json
|
|
||||||
from runner import PROFILES
|
|
||||||
|
|
||||||
MOCK=r'''#!/usr/bin/env python3
|
|
||||||
# TEST DOUBLE. NOT A COMPILER. NEVER SHIPPED AS AN ANDROID ARTIFACT.
|
|
||||||
import json,os,sys,zipfile
|
|
||||||
from pathlib import Path
|
|
||||||
sys.path.insert(0,str(Path(__file__).parent))
|
|
||||||
from helpers import elf
|
|
||||||
if '--version' in sys.argv:
|
|
||||||
print('TEST_DOUBLE_NOT_A_REAL_COMPILER');raise SystemExit(0)
|
|
||||||
if '--os=android' not in sys.argv or not any(a.startswith('--cpu=') for a in sys.argv):
|
|
||||||
print('TEST ERROR: missing explicit Android target');raise SystemExit(97)
|
|
||||||
cpu=next(a.split('=',1)[1] for a in sys.argv if a.startswith('--cpu='))
|
|
||||||
t=json.loads(Path('target.json').read_text());ident=t['profile']
|
|
||||||
if sys.argv[-1]=='compile':
|
|
||||||
if ident==os.environ.get('PROBE_FAKE_FAIL'):
|
|
||||||
print("KM_Test.pas(9,1) Fatal: Can't find unit Graphics used by KM_Test");raise SystemExit(1)
|
|
||||||
if ident==os.environ.get('PROBE_FAKE_ZERO_NO_SO'):
|
|
||||||
print('TEST DOUBLE: misleading zero return without output');raise SystemExit(0)
|
|
||||||
Path('lib'+t['name']+'.so').write_bytes(elf(cpu))
|
|
||||||
for unit in t['units']:
|
|
||||||
Path(unit.lower()+'.ppu').write_bytes(b'PPU_TEST_DOUBLE')
|
|
||||||
print('Compiling '+unit+'.pas (TEST DOUBLE ONLY)')
|
|
||||||
elif sys.argv[-1]=='package':
|
|
||||||
library=Path('lib'+t['name']+'.so');data=library.read_bytes()
|
|
||||||
if ident==os.environ.get('PROBE_FAKE_OLD_APK'):data=data[:-1]+b'x'
|
|
||||||
abi={'aarch64':'arm64-v8a','arm':'armeabi-v7a','x86_64':'x86_64'}[cpu]
|
|
||||||
with zipfile.ZipFile(t['name']+'.apk','w') as z:
|
|
||||||
z.writestr('AndroidManifest.xml','TEST DOUBLE ONLY')
|
|
||||||
z.writestr('classes.dex',b'dex\n035\0TEST DOUBLE ONLY')
|
|
||||||
z.writestr('lib/'+abi+'/'+library.name,data)
|
|
||||||
else:raise SystemExit(98)
|
|
||||||
'''
|
|
||||||
|
|
||||||
class PipelineTests(unittest.TestCase):
|
|
||||||
def run_pipeline(self,variables=None,profiles='all',with_source=True):
|
|
||||||
temp=tempfile.TemporaryDirectory();self.addCleanup(temp.cleanup);root=Path(temp.name)
|
|
||||||
for folder in ('tools','config','code'):shutil.copytree(ROOT/folder,root/folder,ignore=shutil.ignore_patterns('__pycache__','toolchain.lock.json'))
|
|
||||||
bindir=root/'mock-bin';bindir.mkdir()
|
|
||||||
shutil.copyfile(Path(__file__).parent/'helpers.py',bindir/'helpers.py')
|
|
||||||
(bindir/'castle-engine').write_text(MOCK.replace('#!/usr/bin/env python3', '#!'+sys.executable+' -S'));(bindir/'castle-engine').chmod(0o755)
|
|
||||||
for tool in ('java','fpc','adb'):
|
|
||||||
(bindir/tool).write_text('#!/bin/sh\necho TEST_DOUBLE_VERSION\n');(bindir/tool).chmod(0o755)
|
|
||||||
(bindir/'apksigner').write_text('#!/bin/sh\necho TEST_DOUBLE_SIGNER\nexit 0\n');(bindir/'apksigner').chmod(0o755)
|
|
||||||
if with_source:
|
|
||||||
src=root/'vendor/kam_remake';src.mkdir(parents=True);files=[]
|
|
||||||
for unit in sorted({u for p in PROFILES for u in p['units']}):
|
|
||||||
p=src/'src'/f'{unit}.pas';p.parent.mkdir(exist_ok=True)
|
|
||||||
p.write_text(f'unit {unit}; interface implementation end.\n')
|
|
||||||
files.append({'path':p.relative_to(src).as_posix(),'size':p.stat().st_size,
|
|
||||||
'sha256':sha256(p),'git_blob_sha1':git_blob(p.read_bytes())})
|
|
||||||
write_json(src/'SOURCE_RECEIPT.json',{'schema':'TEST_DOUBLE_RECEIPT','commit':COMMIT,
|
|
||||||
'repository':'reyandme/kam_remake','source_only':True,'files':files})
|
|
||||||
env={**os.environ,'PATH':str(bindir)+os.pathsep+os.environ['PATH'],**(variables or {})}
|
|
||||||
p=subprocess.run([sys.executable,'-S',str(root/'tools/runner.py'),'--local','--no-fetch','--profiles',profiles],
|
|
||||||
cwd=root,env=env,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,text=True,timeout=60)
|
|
||||||
latest=json.loads((root/'reports/LATEST.json').read_text())
|
|
||||||
result=json.loads((root/latest['directory']/'result.json').read_text())
|
|
||||||
return p,result,root
|
|
||||||
def test_all_stages_can_reach_build_gate_with_test_double(self):
|
|
||||||
p,r,root=self.run_pipeline()
|
|
||||||
self.assertEqual(p.returncode,0,p.stdout);self.assertEqual(r['gate'],'ANDROID_BUILD_GATE_PASS')
|
|
||||||
self.assertEqual(len(r['profiles']),17);self.assertEqual(r['runtime'],'NOT_RUN')
|
|
||||||
self.assertEqual(len(list((root/'dist').rglob('*.apk'))),5)
|
|
||||||
self.assertIn('TEST_DOUBLE',r['toolchain']['castle']['output'])
|
|
||||||
def test_core_failure_does_not_stop_later_probes(self):
|
|
||||||
p,r,_=self.run_pipeline({'PROBE_FAKE_FAIL':'simulation'})
|
|
||||||
self.assertEqual(p.returncode,1,p.stdout);self.assertEqual(r['gate'],'NOT_BUILDABLE_AS_IS')
|
|
||||||
self.assertEqual(next(x for x in r['profiles'] if x['id']=='scripting')['compile_link']['status'],'PASS')
|
|
||||||
self.assertEqual(next(x for x in r['profiles'] if x['id']=='simulation')['compile_link']['status'],'FAIL')
|
|
||||||
def test_zero_exit_without_library_is_not_a_pass(self):
|
|
||||||
p,r,_=self.run_pipeline({'PROBE_FAKE_ZERO_NO_SO':'core_types'},'core_types')
|
|
||||||
self.assertEqual(p.returncode,1,p.stdout)
|
|
||||||
self.assertEqual(r['profiles'][1]['compile_link']['status'],'FAIL')
|
|
||||||
def test_stale_apk_library_rejected_end_to_end(self):
|
|
||||||
p,r,_=self.run_pipeline({'PROBE_FAKE_OLD_APK':'integration'})
|
|
||||||
self.assertEqual(p.returncode,1,p.stdout)
|
|
||||||
self.assertEqual(next(x for x in r['profiles'] if x['id']=='integration')['package']['status'],'FAIL')
|
|
||||||
self.assertTrue(any('stale' in e for e in next(x for x in r['profiles'] if x['id']=='integration')['errors']))
|
|
||||||
def test_partial_selection_never_means_whole_architecture_ready(self):
|
|
||||||
p,r,_=self.run_pipeline(profiles='core_types')
|
|
||||||
self.assertEqual(p.returncode,2,p.stdout);self.assertEqual(r['gate'],'PARTIAL_SELECTION')
|
|
||||||
def test_missing_sources_block_the_entire_run(self):
|
|
||||||
p,r,_=self.run_pipeline(profiles='core_types',with_source=False)
|
|
||||||
self.assertEqual(p.returncode,2,p.stdout);self.assertEqual(r['gate'],'BLOCKED')
|
|
||||||
self.assertEqual(r['profiles'][0]['compile_link']['status'],'BLOCKED')
|
|
||||||
self.assertEqual(r['profiles'][1]['compile_link']['status'],'BLOCKED')
|
|
||||||
def test_no_resource_data_in_evidence(self):
|
|
||||||
import zipfile
|
|
||||||
p,r,root=self.run_pipeline(profiles='platform')
|
|
||||||
evidence=next((root/'reports/runs').rglob('evidence.zip'))
|
|
||||||
with zipfile.ZipFile(evidence) as z:
|
|
||||||
self.assertFalse(any(Path(n).suffix in ('.apk','.so','.dat','.map','.rxx') for n in z.namelist()))
|
|
||||||
if __name__=='__main__':unittest.main()
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
import hashlib, json, os, shutil, subprocess, sys, tempfile, unittest, xml.etree.ElementTree as ET, zipfile
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools'))
|
|
||||||
from common import ROOT, COMMIT, ProbeError, git_blob, safe_relative, sha256, write_json, run_command, classify
|
|
||||||
from source_resolution import build_source_resolution, load_source_contract, validate_source_contract
|
|
||||||
from source_fetch import selected, enumerate_blobs, verify
|
|
||||||
from source_audit import inventory
|
|
||||||
from generate import make_target, imports_source, asset_inventory, COMPILER_OPTIONS
|
|
||||||
from artifacts import inspect_elf, inspect_apk, runtime_identity
|
|
||||||
from runner import gate, selected_profiles, cge_command, PROFILES
|
|
||||||
from device import own_report_paths, validate_runtime
|
|
||||||
from helpers import elf, section_elf
|
|
||||||
|
|
||||||
class PathsTests(unittest.TestCase):
|
|
||||||
pass
|
|
||||||
for i,bad in enumerate(('', '/etc/passwd','../x','a/../x','a//b','./a','a/./b','C:/x','a\\b','a\nname','x\0y','a/')):
|
|
||||||
def test(self,bad=bad):
|
|
||||||
with self.assertRaises(ProbeError):safe_relative(bad)
|
|
||||||
setattr(PathsTests,f'test_reject_path_{i:02d}',test)
|
|
||||||
for i,good in enumerate(('src/common/KM_Points.pas','Maps/Тест/Тест.map','A B/c.d','data/100%.txt')):
|
|
||||||
def test(self,good=good):self.assertEqual(safe_relative(good),good)
|
|
||||||
setattr(PathsTests,f'test_accept_path_{i:02d}',test)
|
|
||||||
|
|
||||||
class SourceContractTests(unittest.TestCase):
|
|
||||||
def contract(self, value):
|
|
||||||
temp = tempfile.TemporaryDirectory(); self.addCleanup(temp.cleanup)
|
|
||||||
path = Path(temp.name) / 'extra-paths.json'; path.write_text(json.dumps(value))
|
|
||||||
return path
|
|
||||||
def test_loads_normalized_targets_and_deduplicated_paths(self):
|
|
||||||
path = self.contract({'schema':'kam-source-resolution/1','adaptations':['src/common/KM_Example.pas'],
|
|
||||||
'source_paths':['tools','tools'],'library_paths':[str(ROOT/'config')]})
|
|
||||||
loaded = load_source_contract(path)
|
|
||||||
self.assertEqual(loaded['adaptation_targets'], ('src/common/KM_Example.pas',))
|
|
||||||
self.assertEqual(loaded['source_paths'], ((ROOT/'tools').resolve(),))
|
|
||||||
self.assertEqual(loaded['library_paths'], ((ROOT/'config').resolve(),))
|
|
||||||
def test_rejects_legacy_and_unsafe_contracts(self):
|
|
||||||
for value in ({'source_paths':[],'library_paths':[]},
|
|
||||||
{'schema':'kam-source-resolution/1','adaptations':['src\\bad.pas'],'source_paths':[],'library_paths':[]}):
|
|
||||||
with self.subTest(value=value), self.assertRaises(ProbeError): load_source_contract(self.contract(value))
|
|
||||||
|
|
||||||
class SourceOwnershipTests(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.tmp=tempfile.TemporaryDirectory();self.root=Path(self.tmp.name);self.vendor=self.root/'vendor';self.sparse=self.root/'port-source/kam_remake'
|
|
||||||
self.vendor.joinpath('src').mkdir(parents=True);self.vendor.joinpath('src/KM_Test.pas').write_text('unit KM_Test; interface implementation end.')
|
|
||||||
self.receipt={'files':[{'path':'src/KM_Test.pas'}]};self.inventory={'units':{'km_test':['src/KM_Test.pas']}}
|
|
||||||
def tearDown(self):self.tmp.cleanup()
|
|
||||||
def validate(self, contract):
|
|
||||||
with patch('source_resolution.ROOT',self.root):
|
|
||||||
return validate_source_contract(contract,self.vendor,self.receipt,self.inventory)
|
|
||||||
def test_registered_adaptation_has_vendor_identity_and_unit(self):
|
|
||||||
self.sparse.joinpath('src').mkdir(parents=True);self.sparse.joinpath('src/KM_Test.pas').write_text('unit km_test; interface implementation end.')
|
|
||||||
result=self.validate({'adaptation_targets':('src/KM_Test.pas',),'source_paths':(),'library_paths':()})
|
|
||||||
self.assertEqual(result['adaptations'][0]['unit_name'],'km_test');self.assertEqual(result['vendor_unit_keys'],frozenset({'km_test'}))
|
|
||||||
def test_rejects_unregistered_sparse_and_generic_shadowing(self):
|
|
||||||
self.sparse.mkdir(parents=True);self.sparse.joinpath('other.inc').write_text('x')
|
|
||||||
with self.assertRaises(ProbeError):self.validate({'adaptation_targets':(),'source_paths':(),'library_paths':()})
|
|
||||||
shutil.rmtree(self.sparse);generic=self.root/'generic';generic.mkdir();generic.joinpath('KM_Test.pas').write_text('not a unit')
|
|
||||||
with self.assertRaises(ProbeError):self.validate({'adaptation_targets':(),'source_paths':(generic,),'library_paths':()})
|
|
||||||
|
|
||||||
class CompilerSourceResolutionTests(unittest.TestCase):
|
|
||||||
def test_orders_deduplicates_and_guards_application_vendor_units(self):
|
|
||||||
with tempfile.TemporaryDirectory() as t:
|
|
||||||
root = Path(t); app = root/'app'; vendor = root/'vendor'; generic = root/'generic'; library = root/'library'
|
|
||||||
for path in (app, vendor/'src', vendor/'ext', generic, library): path.mkdir(parents=True, exist_ok=True)
|
|
||||||
(vendor/'src/KM_Vendor.pas').write_text('unit KM_Vendor; interface implementation end.')
|
|
||||||
first, second = root/'adaptations/z', root/'adaptations/a'
|
|
||||||
first.mkdir(parents=True); second.mkdir(parents=True)
|
|
||||||
validated = {'vendor_unit_keys': frozenset({'km_vendor'}), 'adaptations': (
|
|
||||||
{'target':'z.pas', 'project_file':first/'z.pas'}, {'target':'A.pas', 'project_file':second/'A.pas'},
|
|
||||||
{'target':'a2.pas', 'project_file':second/'a2.pas'}),
|
|
||||||
'source_paths': (generic, second), 'library_paths': (library,)}
|
|
||||||
result = build_source_resolution(validated, vendor, app)
|
|
||||||
self.assertEqual(result['adaptation_dirs'], (second.resolve(), first.resolve()))
|
|
||||||
self.assertEqual(result['compiler_source_paths'][:4],
|
|
||||||
(app.resolve(), second.resolve(), first.resolve(), generic.resolve()))
|
|
||||||
self.assertEqual(result['library_paths'], (library.resolve(),))
|
|
||||||
(app/'KM_VENDOR.pas').write_text('malformed declaration')
|
|
||||||
with self.assertRaises(ProbeError): build_source_resolution(validated, vendor, app)
|
|
||||||
|
|
||||||
class SourceSelectionTests(unittest.TestCase):
|
|
||||||
def test_known_git_blob(self):
|
|
||||||
self.assertEqual(git_blob(b'hello\n'),'ce013625030ba8dba906f756967f9e9ca394464a')
|
|
||||||
def test_excludes_symlinks_and_gitlinks(self):
|
|
||||||
tree={'tree':[{'path':'src/a.pas','type':'blob','mode':'120000','sha':'a','size':1},
|
|
||||||
{'path':'src/module','type':'commit','sha':'b'}]}
|
|
||||||
self.assertEqual(enumerate_blobs(tree,'x/y',COMMIT),[])
|
|
||||||
def test_keeps_case_and_sha(self):
|
|
||||||
tree={'tree':[{'path':'src/A.PAS','type':'blob','mode':'100644','sha':'abc','size':1}]}
|
|
||||||
out=enumerate_blobs(tree,'x/y',COMMIT)
|
|
||||||
self.assertEqual(out[0]['path'],'src/A.PAS');self.assertEqual(out[0]['git_blob_sha1'],'abc')
|
|
||||||
for i,name in enumerate(('src/KM.pas','src/ext/a.pp','src/common/defs.inc','KaM_Remake.inc','src/ext/LICENSE','src/ext/License.txt','LICENSE.md','src/ext/native.c')):
|
|
||||||
def test(self,name=name):self.assertTrue(selected(name))
|
|
||||||
setattr(SourceSelectionTests,f'test_source_allowed_{i:02d}',test)
|
|
||||||
for i,name in enumerate(('data/tiles.rxx','data/a.pas','Maps/a.map','Campaigns/a.dat','src/a.res','src/a.dll','src/a.so','src/a.a','src/a.o','src/a.ppu','src/LICENSE.png','src/LICENSE.zip','src/Examples/a.pas','src/ext/tests/a.pas','Utils/editor.pas','src/a.lfm','src/a.png')):
|
|
||||||
def test(self,name=name):self.assertFalse(selected(name))
|
|
||||||
setattr(SourceSelectionTests,f'test_binary_or_unneeded_excluded_{i:02d}',test)
|
|
||||||
|
|
||||||
class ReceiptTests(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.tmp=tempfile.TemporaryDirectory();self.root=Path(self.tmp.name)
|
|
||||||
file=self.root/'src/test.pas';file.parent.mkdir();file.write_text('unit Test; interface implementation end.')
|
|
||||||
self.receipt={'commit':COMMIT,'repository':'reyandme/kam_remake','source_only':True,
|
|
||||||
'files':[{'path':'src/test.pas','size':file.stat().st_size,'sha256':sha256(file),'git_blob_sha1':git_blob(file.read_bytes())}]}
|
|
||||||
write_json(self.root/'SOURCE_RECEIPT.json',self.receipt)
|
|
||||||
def tearDown(self):self.tmp.cleanup()
|
|
||||||
def test_valid_receipt(self):self.assertEqual(len(verify(self.root)['files']),1)
|
|
||||||
def test_modified_source_rejected(self):
|
|
||||||
(self.root/'src/test.pas').write_text('changed')
|
|
||||||
with self.assertRaises(ProbeError):verify(self.root)
|
|
||||||
def test_unrecorded_source_rejected(self):
|
|
||||||
(self.root/'src/other.pas').write_text('x')
|
|
||||||
with self.assertRaises(ProbeError):verify(self.root)
|
|
||||||
def test_cached_native_units_rejected(self):
|
|
||||||
(self.root/'src/test.ppu').write_bytes(b'PPU')
|
|
||||||
with self.assertRaises(ProbeError):verify(self.root)
|
|
||||||
def test_wrong_commit_rejected(self):
|
|
||||||
self.receipt['commit']='0'*40;write_json(self.root/'SOURCE_RECEIPT.json',self.receipt)
|
|
||||||
with self.assertRaises(ProbeError):verify(self.root)
|
|
||||||
def test_empty_receipt_rejected(self):
|
|
||||||
self.receipt['files']=[];write_json(self.root/'SOURCE_RECEIPT.json',self.receipt)
|
|
||||||
with self.assertRaises(ProbeError):verify(self.root)
|
|
||||||
|
|
||||||
class ArtifactTests(unittest.TestCase):
|
|
||||||
def test_arm64_elf(self):self.assertEqual(inspect_elf(elf(),'aarch64')['machine'],183)
|
|
||||||
def test_arm_elf(self):self.assertEqual(inspect_elf(elf('arm'),'arm')['elf_class'],1)
|
|
||||||
def test_x86_android_elf(self):self.assertEqual(inspect_elf(elf('x86_64'),'x86_64')['machine'],62)
|
|
||||||
def test_wrong_abi_rejected(self):
|
|
||||||
with self.assertRaises(ProbeError):inspect_elf(elf('arm'),'aarch64')
|
|
||||||
def test_random_nonelf_rejected(self):
|
|
||||||
with self.assertRaises(ProbeError):inspect_elf(b'PASS compiler'+'x'.encode()*90,'aarch64')
|
|
||||||
def test_textrel_rejected(self):
|
|
||||||
with self.assertRaises(ProbeError):inspect_elf(elf(textrel=True),'aarch64')
|
|
||||||
def test_needed_parsed(self):self.assertEqual(inspect_elf(elf(needed=['libc.so','libm.so']),'aarch64')['needed'],['libc.so','libm.so'])
|
|
||||||
def test_bad_headers_rejected(self):
|
|
||||||
data=bytearray(elf());data[54]=0
|
|
||||||
with self.assertRaises(ProbeError):inspect_elf(bytes(data),'aarch64')
|
|
||||||
def apk(self,root,data=None,dex=True):
|
|
||||||
p=Path(root)/'mock.apk';data=data or elf(needed=['libc.so'])
|
|
||||||
with zipfile.ZipFile(p,'w') as z:
|
|
||||||
z.writestr('AndroidManifest.xml','TEST DOUBLE')
|
|
||||||
if dex:z.writestr('classes.dex',b'dex\n035\0TEST DOUBLE')
|
|
||||||
z.writestr('lib/arm64-v8a/libtest.so',data)
|
|
||||||
return p,data
|
|
||||||
def test_apk_container_accepts_matching_mock_library(self):
|
|
||||||
with tempfile.TemporaryDirectory() as t:
|
|
||||||
p,data=self.apk(t)
|
|
||||||
r=inspect_apk(p,'aarch64','libcompiled_android_aarch64.so',hashlib.sha256(data).hexdigest())
|
|
||||||
self.assertEqual(r['abi'],'arm64-v8a')
|
|
||||||
self.assertEqual(r['main_library_path'],'lib/arm64-v8a/libtest.so')
|
|
||||||
def test_runtime_identity_ignores_debug_only_changes(self):
|
|
||||||
for cpu in ('arm','aarch64','x86_64'):
|
|
||||||
self.assertEqual(runtime_identity(section_elf(cpu,debug=b'long debug before strip'),cpu),
|
|
||||||
runtime_identity(section_elf(cpu,debug=b''),cpu))
|
|
||||||
def test_runtime_identity_detects_changed_code(self):
|
|
||||||
self.assertNotEqual(runtime_identity(section_elf(code=b'original'),'aarch64'),
|
|
||||||
runtime_identity(section_elf(code=b'modified'),'aarch64'))
|
|
||||||
def test_apk_debug_stripping_not_false_stale_failure(self):
|
|
||||||
before=section_elf(debug=b'DEBUG SYMBOLS BEFORE STRIP')
|
|
||||||
after=section_elf(debug=b'')
|
|
||||||
with tempfile.TemporaryDirectory() as t:
|
|
||||||
p,_=self.apk(t,after)
|
|
||||||
result=inspect_apk(p,'aarch64','libtest.so',hashlib.sha256(before).hexdigest(),before)
|
|
||||||
self.assertIn('stripping',result['main_library_identity'])
|
|
||||||
def test_apk_runtime_change_rejected_with_stripping(self):
|
|
||||||
before=section_elf(code=b'original');after=section_elf(debug=b'',code=b'modified')
|
|
||||||
with tempfile.TemporaryDirectory() as t:
|
|
||||||
p,_=self.apk(t,after)
|
|
||||||
with self.assertRaises(ProbeError):inspect_apk(p,'aarch64','libtest.so',hashlib.sha256(before).hexdigest(),before)
|
|
||||||
def test_identity_no_sections_cannot_bypass_sha(self):
|
|
||||||
with self.assertRaises(ProbeError):runtime_identity(elf(),'aarch64')
|
|
||||||
def test_stale_apk_library_rejected(self):
|
|
||||||
with tempfile.TemporaryDirectory() as t:
|
|
||||||
p,data=self.apk(t)
|
|
||||||
with self.assertRaises(ProbeError):inspect_apk(p,'aarch64','libtest.so','0'*64)
|
|
||||||
def test_missing_dex_rejected(self):
|
|
||||||
with tempfile.TemporaryDirectory() as t:
|
|
||||||
p,data=self.apk(t,dex=False)
|
|
||||||
with self.assertRaises(ProbeError):inspect_apk(p,'aarch64','libtest.so',hashlib.sha256(data).hexdigest())
|
|
||||||
def test_unpackaged_dependency_rejected(self):
|
|
||||||
with tempfile.TemporaryDirectory() as t:
|
|
||||||
p,data=self.apk(t,elf(needed=['libmissing.so']))
|
|
||||||
with self.assertRaises(ProbeError):inspect_apk(p,'aarch64','libtest.so',hashlib.sha256(data).hexdigest())
|
|
||||||
|
|
||||||
class OrchestrationTests(unittest.TestCase):
|
|
||||||
def rows(self):
|
|
||||||
return [{'id':p['id'],'required':p['required'],'compile_link':{'status':'PASS'},
|
|
||||||
'package':{'status':'PASS' if p['package'] else 'NOT_RUN'}} for p in PROFILES]
|
|
||||||
def test_full_gate(self):self.assertEqual(gate(self.rows()),'ANDROID_BUILD_GATE_PASS')
|
|
||||||
def test_no_vacuous_pass(self):self.assertEqual(gate([]),'PARTIAL_SELECTION')
|
|
||||||
def test_platform_alone_is_not_core_success(self):self.assertEqual(gate(self.rows()[:1]),'PARTIAL_SELECTION')
|
|
||||||
def test_missing_required_row(self):
|
|
||||||
r=[x for x in self.rows() if x['id']!='integration'];self.assertEqual(gate(r),'PARTIAL_SELECTION')
|
|
||||||
def test_core_failure_fails_gate(self):
|
|
||||||
r=self.rows();r[2]['compile_link']['status']='FAIL';self.assertEqual(gate(r),'NOT_BUILDABLE_AS_IS')
|
|
||||||
def test_blocked_is_not_pass(self):
|
|
||||||
r=self.rows();r[2]['compile_link']['status']='BLOCKED';self.assertEqual(gate(r),'BLOCKED')
|
|
||||||
def test_optional_legacy_failure_recorded_not_mandatory(self):
|
|
||||||
r=self.rows();next(v for v in r if v['id']=='legacy_render')['compile_link']['status']='FAIL'
|
|
||||||
self.assertEqual(gate(r),'ANDROID_BUILD_GATE_PASS')
|
|
||||||
def test_missing_apk_does_not_pass(self):
|
|
||||||
r=self.rows();next(v for v in r if v['id']=='integration')['package']['status']='NOT_RUN';self.assertEqual(gate(r),'BLOCKED')
|
|
||||||
def test_optional_decision_failure_does_not_change_core_gate(self):
|
|
||||||
r=self.rows();next(v for v in r if v['id']=='oq_host_callback')['compile_link']['status']='FAIL'
|
|
||||||
self.assertEqual(gate(r),'ANDROID_BUILD_GATE_PASS')
|
|
||||||
def test_platform_baseline_added(self):self.assertEqual(selected_profiles('core_types')[0]['id'],'platform')
|
|
||||||
def test_invalid_profile_rejected(self):
|
|
||||||
with self.assertRaises(ProbeError):selected_profiles('fake')
|
|
||||||
def test_no_native_host_target(self):
|
|
||||||
for cpu in ('aarch64','arm','x86_64'):
|
|
||||||
for stage in ('compile','package'):
|
|
||||||
cmd=cge_command(cpu,stage)
|
|
||||||
self.assertIn('--os=android',cmd);self.assertIn('--cpu='+cpu,cmd)
|
|
||||||
self.assertNotIn('-Cn',' '.join(cmd))
|
|
||||||
def test_linker_strictness(self):self.assertIn('-k--no-undefined',COMPILER_OPTIONS)
|
|
||||||
def test_no_global_delphi_mode(self):self.assertNotIn('-Mdelphi',COMPILER_OPTIONS)
|
|
||||||
def test_no_smart_link_elimination(self):
|
|
||||||
self.assertIn('-CX-',COMPILER_OPTIONS);self.assertIn('-XX-',COMPILER_OPTIONS)
|
|
||||||
def test_failed_subprocess_not_swallowed(self):
|
|
||||||
with tempfile.TemporaryDirectory() as t:
|
|
||||||
r=run_command([sys.executable,'-c','raise SystemExit(7)'],Path(t),Path(t)/'log',30)
|
|
||||||
self.assertEqual(r['status'],'FAIL');self.assertEqual(r['exit_code'],7)
|
|
||||||
def test_missing_tool_is_blocked(self):
|
|
||||||
with tempfile.TemporaryDirectory() as t:
|
|
||||||
r=run_command(['/does-not-exist/kam-probe'],Path(t),Path(t)/'log',30)
|
|
||||||
self.assertEqual(r['status'],'BLOCKED')
|
|
||||||
def test_lcl_classification(self):self.assertEqual(classify("Fatal: Can't find unit Graphics used by KM_ResSprites"),'PLATFORM_BOUNDARY')
|
|
||||||
|
|
||||||
class GenerationTests(unittest.TestCase):
|
|
||||||
def test_empty_platform_has_no_core_imports(self):
|
|
||||||
code,units=imports_source(PROFILES[0]);self.assertFalse(units);self.assertIn('NOT_RUN',code)
|
|
||||||
def test_actual_point_calls(self):
|
|
||||||
code,units=imports_source(PROFILES[1]);self.assertIn('KMPointRight(P)',code);self.assertIn('KM_Points',units)
|
|
||||||
def test_actual_stream_calls(self):
|
|
||||||
code,units=imports_source(PROFILES[2]);self.assertIn('TKMemoryStreamBinary.Create',code);self.assertIn('S.ReadW(U)',code)
|
|
||||||
def test_upstream_manifest_uses_resolved_compiler_order(self):
|
|
||||||
with tempfile.TemporaryDirectory() as t:
|
|
||||||
root=Path(t);vendor=root/'vendor';generic=root/'generic';library=root/'library';adaptation=root/'adaptation'
|
|
||||||
for path in (vendor/'src',generic,library,adaptation):path.mkdir(parents=True,exist_ok=True)
|
|
||||||
(vendor/'src/KM_Points.pas').write_text('unit KM_Points; interface implementation end.')
|
|
||||||
validated={'vendor_unit_keys':frozenset({'km_points'}),'adaptations':(
|
|
||||||
{'target':'src/KM_Points.pas','project_file':adaptation/'KM_Points.pas'},),
|
|
||||||
'source_paths':(generic,),'library_paths':(library,)}
|
|
||||||
p=root/'target';rec=make_target(p,PROFILES[1],'aarch64','id',vendor,validated)
|
|
||||||
self.assertEqual(rec['source_paths'][:3],[str((p/'code').resolve()),str(adaptation.resolve()),str(generic.resolve())])
|
|
||||||
xml=ET.parse(p/'CastleEngineManifest.xml')
|
|
||||||
self.assertEqual([node.attrib['value'] for node in xml.findall('./compiler_options/search_paths/path')][:3],
|
|
||||||
['code',os.path.relpath(adaptation,p).replace(os.sep,'/'),os.path.relpath(generic,p).replace(os.sep,'/')])
|
|
||||||
self.assertEqual([node.attrib['value'] for node in xml.findall('./compiler_options/library_paths/path')],
|
|
||||||
[os.path.relpath(library,p).replace(os.sep,'/')])
|
|
||||||
def test_manifest_no_standalone_target(self):
|
|
||||||
with tempfile.TemporaryDirectory() as t:
|
|
||||||
p=Path(t)/'target'
|
|
||||||
rec=make_target(p,PROFILES[0],'aarch64','test-build',None,None)
|
|
||||||
xml=(p/'CastleEngineManifest.xml').read_text()
|
|
||||||
self.assertNotIn('standalone_source',xml);self.assertNotIn('test_shims',xml)
|
|
||||||
self.assertIn('game_units="GameInitialize"',xml)
|
|
||||||
self.assertIn('Android-only',(p/'code/GameInitialize.pas').read_text())
|
|
||||||
self.assertFalse(rec['shims'])
|
|
||||||
self.assertEqual(json.loads((p/'data/assets-manifest.json').read_text())['files'],[])
|
|
||||||
def test_decision_profile_uses_existing_generator_with_alternate_game_unit(self):
|
|
||||||
with tempfile.TemporaryDirectory() as t:
|
|
||||||
profile=next(x for x in PROFILES if x['id']=='oq_host_callback')
|
|
||||||
p=Path(t)/'target';rec=make_target(p,profile,'aarch64','id',None,None)
|
|
||||||
xml=(p/'CastleEngineManifest.xml').read_text()
|
|
||||||
self.assertIn('game_units="DecisionHostCallback"',xml)
|
|
||||||
self.assertEqual(rec['game_unit'],'DecisionHostCallback')
|
|
||||||
self.assertTrue((p/'code/DecisionHostCallback.pas').is_file())
|
|
||||||
def test_selected_existing_asset_only(self):
|
|
||||||
with tempfile.TemporaryDirectory() as t:
|
|
||||||
root=Path(t)/'assets';root.mkdir();(root/'Maps').mkdir();(root/'data').mkdir()
|
|
||||||
(root/'Maps/one.txt').write_text('map-marker')
|
|
||||||
(root/'data/unused.txt').write_text('unused')
|
|
||||||
rows=asset_inventory(root,['Maps/one.txt'])
|
|
||||||
self.assertEqual([x['path'] for x in rows],['Maps/one.txt'])
|
|
||||||
def test_fresh_target_required(self):
|
|
||||||
with tempfile.TemporaryDirectory() as t:
|
|
||||||
with self.assertRaises(ProbeError):make_target(Path(t),PROFILES[0],'aarch64','id',None,None)
|
|
||||||
def test_optional_assets_preserved(self):
|
|
||||||
with tempfile.TemporaryDirectory() as t:
|
|
||||||
r=Path(t)/'assets';r.mkdir();(r/'Data').mkdir();raw=bytes(range(256))
|
|
||||||
(r/'Data/sample.dat').write_bytes(raw)
|
|
||||||
entries=asset_inventory(r)
|
|
||||||
p=Path(t)/'target';make_target(p,PROFILES[0],'aarch64','id',None,None,r,entries)
|
|
||||||
self.assertEqual((p/'data/game/Data/sample.dat').read_bytes(),raw)
|
|
||||||
def test_asset_symlinks_rejected(self):
|
|
||||||
with tempfile.TemporaryDirectory() as t:
|
|
||||||
r=Path(t);(r/'a').write_text('x');(r/'b').symlink_to(r/'a')
|
|
||||||
with self.assertRaises(ProbeError):asset_inventory(r)
|
|
||||||
def test_no_asset_network_call_by_default(self):self.assertEqual(asset_inventory(None),[])
|
|
||||||
def test_audit_flags_are_hints(self):
|
|
||||||
with tempfile.TemporaryDirectory() as t:
|
|
||||||
r=Path(t);(r/'test.pas').write_text('unit Test; interface uses Graphics; implementation end.')
|
|
||||||
d=inventory(r);self.assertIn('test',d['units']);self.assertTrue(d['risks']);self.assertIn('Lexical',d['scope'])
|
|
||||||
def test_audit_accepts_utf8_bom_unit(self):
|
|
||||||
with tempfile.TemporaryDirectory() as t:
|
|
||||||
r=Path(t);(r/'KM_Game.pas').write_bytes(b'\xef\xbb\xbfunit KM_Game; interface implementation end.')
|
|
||||||
d=inventory(r);self.assertIn('km_game',d['units']);self.assertEqual(d['units']['km_game'],['KM_Game.pas'])
|
|
||||||
|
|
||||||
class DeviceValidationTests(unittest.TestCase):
|
|
||||||
def package(self):return {'build_id':'new','profile':'platform','cpu':'aarch64'}
|
|
||||||
def test_scoped_report_path(self):
|
|
||||||
self.assertEqual(own_report_paths('./files/kam-architecture-probe/runtime.json\n../secret\nother/runtime.json'),['files/kam-architecture-probe/runtime.json'])
|
|
||||||
def test_stale_report_rejected(self):
|
|
||||||
data={'schema':'kam-android-runtime/1','build_id':'old','profile':'platform','cpu_expected':'aarch64','automatic_smoke_status':'PASS','tests':[]}
|
|
||||||
self.assertTrue(any('stale' in e for e in validate_runtime(data,self.package())))
|
|
||||||
def test_empty_pass_is_not_runtime_success(self):
|
|
||||||
data={'schema':'kam-android-runtime/1','build_id':'new','profile':'platform','cpu_expected':'aarch64','automatic_smoke_status':'PASS','tests':[]}
|
|
||||||
self.assertIn('No runtime tests',validate_runtime(data,self.package()))
|
|
||||||
def test_missing_frame_evidence(self):
|
|
||||||
data={'tests':[{'id':'android.startup','status':'PASS'}],'session':{'frames':0}}
|
|
||||||
self.assertTrue(any('frames' in e for e in validate_runtime(data,self.package())))
|
|
||||||
|
|
||||||
def test_decision_callback_runtime_does_not_require_frames_or_upstream(self):
|
|
||||||
pkg={'build_id':'new','profile':'oq_host_callback','cpu':'aarch64'}
|
|
||||||
passed=['android.startup','abi.pointer','abi.scalar_layout','abi.endianness','files.native_utf8','files.seek','files.case_sensitive','fpc.zstream','fpc.thread','decision.host.callback']
|
|
||||||
observed=['decision.persistence.rename_replace','decision.persistence.append_reopen','decision.fixture.castle_stream','decision.fixture.materialized_native_path']
|
|
||||||
data={'schema':'kam-android-runtime/1','build_id':'new','profile':'oq_host_callback','cpu_expected':'aarch64','automatic_smoke_status':'PASS',
|
|
||||||
'tests':[{'id':x,'status':'PASS'} for x in passed]+[{'id':x,'status':'OBSERVED'} for x in observed]}
|
|
||||||
self.assertEqual(validate_runtime(data,pkg,require_frames=False),[])
|
|
||||||
def test_text_decision_runtime_accepts_observation_results(self):
|
|
||||||
pkg={'build_id':'new','profile':'platform','cpu':'aarch64'}
|
|
||||||
passed=['android.startup','abi.pointer','abi.scalar_layout','abi.endianness','files.native_utf8','files.seek','files.case_sensitive','fpc.zstream','fpc.thread','decision.text.cwstring_unit']
|
|
||||||
data={'schema':'kam-android-runtime/1','build_id':'new','profile':'platform','cpu_expected':'aarch64','automatic_smoke_status':'PASS',
|
|
||||||
'tests':[{'id':x,'status':'PASS'} for x in passed]+[
|
|
||||||
{'id':'decision.persistence.rename_replace','status':'OBSERVED'},{'id':'decision.persistence.append_reopen','status':'OBSERVED'},
|
|
||||||
{'id':'decision.text.cp1251_decode','status':'OBSERVED'},{'id':'decision.text.cp1251_encode','status':'OBSERVED'}]}
|
|
||||||
self.assertEqual(validate_runtime(data,pkg,require_frames=False),[])
|
|
||||||
|
|
||||||
if __name__=='__main__':unittest.main()
|
|
||||||
+6
-13
@@ -7,9 +7,14 @@ from datetime import datetime, timezone
|
|||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
COMMIT = '962fb850324b79bbe5480e1c2087d98f83a60917'
|
COMMIT = '962fb850324b79bbe5480e1c2087d98f83a60917'
|
||||||
REPOSITORY = 'reyandme/kam_remake'
|
REPOSITORY = 'reyandme/kam_remake'
|
||||||
VERSION = '0.4.0'
|
|
||||||
CPUS = {'aarch64': ('arm64-v8a', 183, 2, 8), 'arm': ('armeabi-v7a', 40, 1, 4),
|
CPUS = {'aarch64': ('arm64-v8a', 183, 2, 8), 'arm': ('armeabi-v7a', 40, 1, 4),
|
||||||
'x86_64': ('x86_64', 62, 2, 8)}
|
'x86_64': ('x86_64', 62, 2, 8)}
|
||||||
|
CURRENT_OUTPUT_DIR = ROOT / 'out/current'
|
||||||
|
CURRENT_BUILD_METADATA = CURRENT_OUTPUT_DIR / 'build.json'
|
||||||
|
CURRENT_PACKAGE_METADATA = CURRENT_OUTPUT_DIR / 'package.json'
|
||||||
|
CURRENT_APK = CURRENT_OUTPUT_DIR / 'kam-mobile.apk'
|
||||||
|
CURRENT_BUILD_SCHEMA = 'kam-android-build/2'
|
||||||
|
CURRENT_PACKAGE_SCHEMA = 'kam-android-package/1'
|
||||||
|
|
||||||
class ProbeError(RuntimeError):
|
class ProbeError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
@@ -66,15 +71,3 @@ def first_errors(log: str, limit: int = 12) -> list[str]:
|
|||||||
rows = [s.strip() for s in log.splitlines() if re.search(
|
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)]
|
r'\b(fatal|error|undefined reference|cannot find|can.t find|not found)\b', s, re.I)]
|
||||||
return rows[:limit]
|
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'
|
|
||||||
@@ -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())
|
|
||||||
Executable → Regular
+66
-115
@@ -1,10 +1,10 @@
|
|||||||
"""Install and collect a selected Android decision probe with diagnostic evidence."""
|
"""Install and launch the current Android application package."""
|
||||||
from __future__ import annotations
|
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 pathlib import Path
|
||||||
from common import ROOT, CPUS, ProbeError, safe_relative, sha256, stamp, write_json
|
from common import (ROOT, CPUS, ProbeError, sha256, CURRENT_BUILD_METADATA,
|
||||||
|
CURRENT_PACKAGE_METADATA, CURRENT_APK, CURRENT_BUILD_SCHEMA,
|
||||||
DECISION_PROFILES={'oq_host_view','oq_host_callback'}
|
CURRENT_PACKAGE_SCHEMA)
|
||||||
|
|
||||||
class Adb:
|
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 [])
|
||||||
@@ -14,121 +14,72 @@ class Adb:
|
|||||||
return p
|
return p
|
||||||
def text(self,*args:str,check:bool=True)->str: return self.run(*args,check=check).stdout.decode(errors='replace').strip()
|
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]:
|
def fail(message: str) -> None: raise ProbeError(message)
|
||||||
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 validate_runtime(data:dict,package:dict,require_frames:bool=True)->list[str]:
|
def load_json(path: Path, missing: str) -> dict:
|
||||||
errors=[]
|
try: value=json.loads(path.read_text())
|
||||||
for key,value in (('schema','kam-android-runtime/1'),('build_id',package['build_id']),('profile',package['profile']),('cpu_expected',package['cpu'])):
|
except FileNotFoundError: fail(missing)
|
||||||
if data.get(key)!=value: errors.append('Wrong/stale runtime '+key)
|
except (OSError, ValueError) as error: fail(f'Malformed {path.name}: {error}')
|
||||||
if data.get('automatic_smoke_status')!='PASS': errors.append('Automatic smoke did not pass')
|
if not isinstance(value,dict): fail(f'Malformed {path.name}')
|
||||||
tests=data.get('tests')
|
return value
|
||||||
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 read_runtime(adb:Adb,pkg:str)->tuple[dict|None,str|None]:
|
def manifest_identity() -> dict:
|
||||||
paths=own_report_paths(adb.text('shell','run-as',pkg,'find','.','-name','runtime.json',check=False))
|
try:
|
||||||
if len(paths)!=1: return None,None
|
root=ET.parse(ROOT/'app/CastleEngineManifest.xml').getroot(); version=root.find('version')
|
||||||
raw=adb.run('exec-out','run-as',pkg,'cat',paths[0],check=False).stdout
|
if root.tag!='project' or version is None: fail('Malformed permanent manifest')
|
||||||
try: return json.loads(raw),paths[0]
|
return {'name':root.attrib['name'],'package':root.attrib['qualified_name'],
|
||||||
except (ValueError,UnicodeDecodeError): return None,None
|
'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 wait_runtime(adb:Adb,pkg:str,predicate,timeout:int)->tuple[dict|None,str|None]:
|
def current_package() -> tuple[dict, str]:
|
||||||
end=time.monotonic()+timeout
|
if not CURRENT_BUILD_METADATA.is_file(): raise RuntimeError('Current build metadata is missing')
|
||||||
while time.monotonic()<end:
|
build=load_json(CURRENT_BUILD_METADATA,'Current build metadata is missing')
|
||||||
data,path=read_runtime(adb,pkg)
|
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 data is not None and predicate(data): return data,path
|
if build.get('status')!='PASS': raise RuntimeError('Current build is not PASS')
|
||||||
time.sleep(1)
|
app=build.get('application'); identity=manifest_identity()
|
||||||
return read_runtime(adb,pkg)
|
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')
|
||||||
def save_command(out:Path,name:str,p:subprocess.CompletedProcess)->None:
|
package=load_json(CURRENT_PACKAGE_METADATA,'Current package metadata is missing')
|
||||||
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')
|
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:
|
def main() -> int:
|
||||||
ap=argparse.ArgumentParser(description=__doc__)
|
ap=argparse.ArgumentParser(description=__doc__); ap.add_argument('--serial'); a=ap.parse_args()
|
||||||
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
|
|
||||||
try:
|
try:
|
||||||
index=json.loads(a.index.read_text()); matches=[p for p in index.get('packages',[]) if p.get('profile')==a.profile]
|
package,pkg=current_package()
|
||||||
if len(matches)!=1: raise ProbeError('No unique successfully-built APK for '+a.profile)
|
except ProbeError as error:
|
||||||
package=matches[0]; apk=ROOT/package['file']; pkg=package['package'];record.update(build_id=package['build_id'],profile=a.profile,package=package)
|
print('FAIL\n'+str(error)); return 1
|
||||||
if not apk.is_file() or sha256(apk)!=package['sha256']: raise ProbeError('APK missing/changed')
|
except RuntimeError as error:
|
||||||
if not re.fullmatch(r'org\.kammobile\.archprobe\.[a-z0-9]+',pkg): raise ProbeError('Refusing unrelated Android package')
|
print('BLOCKED\n'+str(error)); return 2
|
||||||
if not shutil.which('adb'): record['preflight']={'status':'BLOCKED','reason':'adb is not installed on host'};raise ProbeError(record['preflight']['reason'])
|
except (KeyError, TypeError) as error:
|
||||||
listed=Adb(None).text('devices').splitlines()[1:]; devices=[line.split()[:2] for line in listed if line.strip()]
|
print('FAIL\n'+str(error)); return 1
|
||||||
record['preflight']={'adb':'PASS','devices':devices}
|
if not shutil.which('adb'):
|
||||||
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'])
|
print('BLOCKED\nadb is not installed on host'); return 2
|
||||||
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'])
|
try:
|
||||||
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')}
|
devices=[line.split()[:2] for line in Adb(None).text('devices').splitlines()[1:] if line.strip()]
|
||||||
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'])
|
if len(devices)!=1 or len(devices[0])!=2 or devices[0][1]!='device': fail('Require exactly one authorized device')
|
||||||
record['preflight']['status']='PASS'
|
serial=devices[0][0]
|
||||||
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()}
|
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:
|
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()}
|
adb.run('uninstall',pkg,check=False); install=adb.run('install',str(CURRENT_APK),check=False)
|
||||||
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: fail('APK install failed')
|
||||||
if install.returncode: raise ProbeError('APK install failed; see install.log')
|
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()]
|
||||||
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 len(candidates)!=1 or not candidates[0].startswith(pkg+'/'): fail('Cannot resolve exactly one application launcher activity')
|
||||||
if run_as.returncode: record['infrastructure_blocker']={'stage':'run_as_preflight','reason':'run-as unavailable for '+pkg};raise ProbeError(record['infrastructure_blocker']['reason'])
|
if adb.run('shell','am','start','-W','-n',candidates[0],check=False).returncode: fail('Launch failed')
|
||||||
if a.stage_file:
|
except (ProbeError, OSError, subprocess.TimeoutExpired) as error:
|
||||||
if not a.stage_file.is_file(): raise ProbeError('Stage file not found: '+str(a.stage_file))
|
print('FAIL\n'+str(error)); return 1
|
||||||
rel='files/kam-architecture-probe/driver-staged-fixture.bin';adb.run('shell','run-as',pkg,'sh','-c','mkdir -p files/kam-architecture-probe')
|
print('PASS'); return 0
|
||||||
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
|
|
||||||
if __name__=='__main__': raise SystemExit(main())
|
if __name__=='__main__': raise SystemExit(main())
|
||||||
+73
-119
@@ -1,130 +1,84 @@
|
|||||||
"""Generate isolated Android CGE targets. No KaM stubs or rewritten game loaders."""
|
"""Generate isolated Android CGE targets. No KaM stubs or rewritten game loaders."""
|
||||||
from __future__ import annotations
|
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 pathlib import Path
|
||||||
from common import ROOT, COMMIT, VERSION, CPUS, ProbeError, safe_relative, sha256, write_json
|
from common import ROOT, ProbeError
|
||||||
from source_resolution import build_source_resolution
|
|
||||||
|
|
||||||
UPSTREAM_DEFINES=['DBG_SKIP_SECURE_AUTH'] # Existing upstream switch; private multiplayer authentication is out of scope.
|
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']
|
COMPILER_OPTIONS=['-B','-CX-','-XX-','-k--no-undefined','-k--no-gc-sections']
|
||||||
|
|
||||||
def imports_source(profile:dict)->tuple[str,list[str]]:
|
_PROJECT_ATTRIBUTES={
|
||||||
units=list(profile['units'])
|
'name':'kam_mobile', 'caption':'KaM Remake Mobile', 'author':'KaM Mobile',
|
||||||
points=profile['id'] in ('core_types','core_streams','integration')
|
'qualified_name':'org.kammobile.kamremake', 'game_units':'GameInitialize',
|
||||||
streams=profile['id'] in ('core_streams','integration')
|
'compiler':'fpc', 'screen_orientation':'landscape', 'fullscreen_immersive':'true',
|
||||||
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
|
|
||||||
|
|
||||||
def asset_inventory(root:Path|None, includes:list[str]|None=None)->list[dict]:
|
def _one_child(parent: ET.Element, tag: str) -> ET.Element:
|
||||||
if root is None:return []
|
children=parent.findall(tag)
|
||||||
root=root.resolve()
|
if len(children)!=1:raise ProbeError(f'Manifest requires exactly one <{tag}>')
|
||||||
if not root.is_dir():raise ProbeError('Asset directory does not exist: '+str(root))
|
return children[0]
|
||||||
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 make_target(project:Path,profile:dict,cpu:str,build_id:str,source:Path|None,
|
def _manifest_path(path: object, project: Path, kind: str) -> str:
|
||||||
validated:dict|None,assets_root:Path|None=None,assets:list[dict]|None=None)->dict:
|
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))
|
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)
|
project.mkdir(parents=True)
|
||||||
shutil.copytree(ROOT/'code',project/'code')
|
data_root=project/'data';data_root.mkdir()
|
||||||
text,units=imports_source(profile)
|
options=_one_child(root,'compiler_options')
|
||||||
(project/'code/ProbeImports.pas').write_text(text,encoding='utf-8')
|
for tag,paths,kind in (('search_paths',compiler_paths,'Source'),('library_paths',library_paths,'Native library')):
|
||||||
(project/'code/ProbeBuild.pas').write_text(
|
slot=_one_child(options,tag)
|
||||||
'unit ProbeBuild;\n{$mode objfpc}{$H+}\ninterface\nconst\n'+
|
for child in list(slot):slot.remove(child)
|
||||||
f" ProbeBuildId='{build_id}';\n ProbeProfile='{profile['id']}';\n ProbeCpu='{cpu}';\n"+
|
for path in paths:ET.SubElement(slot,'path',value=_manifest_path(path,project,kind))
|
||||||
f" ProbeUpstreamCommit='{COMMIT}';\n ProbePointerBytes={CPUS[cpu][3]};\nimplementation\nend.\n")
|
ET.indent(tree)
|
||||||
data=project/'data';data.mkdir()
|
tree.write(project/'CastleEngineManifest.xml',encoding='utf-8',xml_declaration=True)
|
||||||
entries=assets or []
|
version=_one_child(root,'version')
|
||||||
if entries and assets_root is None:raise ProbeError('Missing asset root')
|
return {'project_dir':project,'data_root':data_root,'name':root.get('name'),'caption':root.get('caption'),
|
||||||
for e in entries:
|
'package':root.get('qualified_name'),'version':version.get('value'),'version_code':int(version.get('code')),
|
||||||
dest=data/'game'/e['path'];dest.parent.mkdir(parents=True,exist_ok=True)
|
'game_unit':root.get('game_units'),'compiler_source_paths':compiler_paths,'library_paths':library_paths}
|
||||||
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
|
|
||||||
@@ -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)
|
||||||
+132
-226
@@ -1,25 +1,19 @@
|
|||||||
"""One-command Android-only compile, full native link, APK and evidence pipeline."""
|
"""One-command Android-only compile, full native link, APK and evidence pipeline."""
|
||||||
from __future__ import annotations
|
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 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_fetch import fetch, verify
|
||||||
from source_audit import inventory
|
from source_audit import inventory
|
||||||
from source_resolution import load_source_contract, validate_source_contract
|
from source_resolution import load_source_contract, validate_source_contract, build_source_resolution
|
||||||
from generate import make_target, asset_inventory
|
from generate import materialize_application_project
|
||||||
from artifacts import inspect_elf, inspect_apk
|
from artifacts import inspect_elf, inspect_apk
|
||||||
|
from resource_staging import stage_runtime_resources
|
||||||
|
|
||||||
IMAGE_TAG='kambi/castle-engine-cloud-builds-tools:cge-unstable'
|
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]:
|
def cge_command(cpu:str,action:str)->list[str]:
|
||||||
if cpu not in CPUS:raise ProbeError('Unsupported Android CPU')
|
if cpu not in CPUS:raise ProbeError('Unsupported Android CPU')
|
||||||
if action not in ('compile','package'):raise ProbeError('Not an Android build action')
|
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']
|
if action=='package':cmd+=['--assume-compiled','--package-format=android-apk']
|
||||||
return cmd+[action]
|
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:
|
def tool_versions(out:Path)->dict:
|
||||||
commands={'castle':['castle-engine','--version'],'fpc':['fpc','-iV'],'java':['java','-version'],
|
commands={'castle':['castle-engine','--version'],'fpc':['fpc','-iV'],'java':['java','-version'],
|
||||||
'adb':['adb','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'))
|
if os.environ.get(key):paths+=list(Path(os.environ[key]).glob('build-tools/*/apksigner'))
|
||||||
return str(sorted(paths)[-1]) if paths else None
|
return str(sorted(paths)[-1]) if paths else None
|
||||||
|
|
||||||
def build_local(a:argparse.Namespace,profiles:list[dict])->int:
|
def _application(descriptor:dict|None)->dict|None:
|
||||||
result,out=new_result(a.cpu,profiles)
|
if descriptor is None:return None
|
||||||
result['toolchain']=tool_versions(out)
|
return {key:descriptor[key] for key in ('name','caption','package','version','version_code','game_unit')}
|
||||||
|
|
||||||
|
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'):
|
if not shutil.which('castle-engine'):
|
||||||
for r in result['profiles']:r['compile_link']['reason']='castle-engine not installed; no Android compilation attempted'
|
return _finish(_record(build_id,a.cpu,'BLOCKED',app,len(resources),compile,package,error='castle-engine not installed'))
|
||||||
return finalize(result,out)
|
project=descriptor['project_dir']; compile_result=run_command(cge_command(a.cpu,'compile'),project,logs/'compile.log',a.timeout)
|
||||||
source=ROOT/'vendor/kam_remake';audit=None;validated=None;assets=[]
|
compile={'status':compile_result['status'],'exit_code':compile_result['exit_code']}
|
||||||
need_source=any(p['units'] for p in profiles)
|
if compile['status']!='PASS':
|
||||||
|
return _finish(_record(build_id,a.cpu,compile['status'],app,len(resources),compile,package,error=_error(logs/'compile.log')))
|
||||||
try:
|
try:
|
||||||
contract=load_source_contract()
|
candidates=list(project.rglob(f"lib{descriptor['name']}_android_{a.cpu}.so"))
|
||||||
assets=asset_inventory(Path(a.assets),a.asset_path) if a.assets else []
|
if len(candidates)!=1:raise ProbeError('Compiler exited 0 but expected exactly one native library')
|
||||||
result['asset_count']=len(assets)
|
library=candidates[0]; inspect_elf(library.read_bytes(),a.cpu)
|
||||||
if need_source:
|
except (ProbeError,OSError,ValueError) as error:
|
||||||
if (source/'SOURCE_RECEIPT.json').is_file():receipt=verify(source)
|
compile['status']='FAIL'
|
||||||
elif a.no_fetch:raise ProbeError('Pinned source tree absent and --no-fetch requested')
|
return _finish(_record(build_id,a.cpu,'FAIL',app,len(resources),compile,package,error=str(error)))
|
||||||
else:receipt=fetch(source)
|
package_result=run_command(cge_command(a.cpu,'package'),project,logs/'package.log',a.timeout)
|
||||||
write_json(out/'source-receipt.json',receipt)
|
package={'status':package_result['status'],'exit_code':package_result['exit_code']}
|
||||||
audit=inventory(source);write_json(out/'source-inventory.json',audit)
|
if package['status']!='PASS':
|
||||||
validated=validate_source_contract(contract,source,receipt,audit)
|
return _finish(_record(build_id,a.cpu,package['status'],app,len(resources),compile,package,error=_error(logs/'package.log')))
|
||||||
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:
|
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'))
|
candidates=list(project.rglob('*.apk'))
|
||||||
if not candidates:raise ProbeError('Packaging exited 0 but no new APK')
|
if not candidates:raise ProbeError('Packaging exited 0 but no APK')
|
||||||
apk_hashes={sha256(p) for p in candidates}
|
if len({sha256(path) for path in candidates})!=1:raise ProbeError('Multiple different APK outputs')
|
||||||
if len(apk_hashes)!=1:raise ProbeError('Multiple different APK outputs; refusing arbitrary selection')
|
apk=candidates[0]; apk_info=inspect_apk(apk,a.cpu,library.name,sha256(library),library.read_bytes())
|
||||||
apk=candidates[0]
|
|
||||||
apk_info=inspect_apk(apk,a.cpu,name,row['native_library']['sha256'],library.read_bytes())
|
|
||||||
signer=signature_tool()
|
signer=signature_tool()
|
||||||
if signer:
|
if signer:
|
||||||
sign=run_command([signer,'verify','--verbose',str(apk)],project,evidence/'signature.log',120)
|
signed=run_command([signer,'verify','--verbose',str(apk)],project,logs/'signature.log',a.timeout)
|
||||||
row['signature']=sign
|
if signed['status']!='PASS':raise ProbeError(_error(logs/'signature.log'))
|
||||||
if sign['status']!='PASS':row['package']['status']='FAIL';continue
|
except (ProbeError,OSError,ValueError,KeyError,zipfile.BadZipFile) as error:
|
||||||
else:row['signature']={'status':'NOT_RUN','reason':'apksigner not found'}
|
package['status']='FAIL'
|
||||||
final=dist/(description['name']+'-'+a.cpu+'-debug.apk');shutil.copy2(apk,final)
|
return _finish(_record(build_id,a.cpu,'FAIL',app,len(resources),compile,package,error=str(error)))
|
||||||
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 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)
|
|
||||||
try:
|
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'
|
lock=ROOT/'config/toolchain.lock.json'
|
||||||
if lock.is_file():
|
if lock.is_file():
|
||||||
image=json.loads(lock.read_text())['image']
|
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')
|
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 subprocess.run(['docker','image','inspect',image],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode:
|
||||||
if p.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')
|
||||||
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')
|
|
||||||
else:
|
else:
|
||||||
r=run_command(['docker','pull','--platform=linux/amd64',IMAGE_TAG],ROOT,out/'docker-pull.log',a.timeout)
|
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')
|
||||||
if r['status']!='PASS':raise ProbeError('Docker image pull failed; see docker-pull.log')
|
info=json.loads(subprocess.check_output(['docker','image','inspect',IMAGE_TAG],text=True))[0]
|
||||||
raw=subprocess.check_output(['docker','image','inspect',IMAGE_TAG],text=True)
|
image=next((value for value in info.get('RepoDigests',[]) if 'castle-engine-cloud-builds-tools@sha256:' in value),None)
|
||||||
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 image is None:raise ProbeError('Image has no immutable RepoDigest')
|
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(),
|
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.'})
|
||||||
'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',
|
||||||
cache=ROOT/'.cache/home';cache.mkdir(parents=True,exist_ok=True)
|
'-e','KAM_BUILD_ID='+build_id,'-v',str(ROOT)+':/work','-w','/work']
|
||||||
cmd=['docker','run','--rm','--platform=linux/amd64','--user','0:0',
|
|
||||||
'-e','HOME=/work/.cache/home','-v',str(ROOT)+':/work','-w','/work']
|
|
||||||
if os.environ.get('GITHUB_TOKEN'):cmd+=['-e','GITHUB_TOKEN']
|
if os.environ.get('GITHUB_TOKEN'):cmd+=['-e','GITHUB_TOKEN']
|
||||||
if a.assets:
|
cmd+=['--entrypoint','python3',image,'tools/runner.py','--local','--cpu',a.cpu,'--timeout',str(a.timeout)]
|
||||||
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)]
|
|
||||||
if a.no_fetch:cmd+=['--no-fetch']
|
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)
|
code=subprocess.call(cmd,cwd=ROOT)
|
||||||
after=latest.read_bytes() if latest.exists() else None
|
if code not in (0,1,2):raise ProbeError('Docker exit code '+str(code))
|
||||||
if code in (0,1,2) and after is not None and after!=before:
|
record=json.loads(CURRENT_BUILD_METADATA.read_text())
|
||||||
fresh=json.loads(after)
|
if not _valid_docker_handoff(record,build_id,code):raise ProbeError('Container current build metadata is missing, stale, or inconsistent')
|
||||||
if (ROOT/fresh['directory']/'result.json').is_file():return code
|
return code
|
||||||
raise ProbeError('Container could not finish the report; Docker exit code '+str(code))
|
except (ProbeError,OSError,ValueError,KeyError,subprocess.CalledProcessError) as error:
|
||||||
except (ProbeError,OSError,ValueError,KeyError,subprocess.CalledProcessError) as e:
|
return _finish(_record(build_id,a.cpu,'BLOCKED',None,None,compile,package,error=str(error)))
|
||||||
for r in result['profiles']:r['compile_link']['reason']=str(e)
|
|
||||||
return finalize(result,out)
|
|
||||||
|
|
||||||
def main()->int:
|
def main()->int:
|
||||||
ap=argparse.ArgumentParser(description=__doc__)
|
ap=argparse.ArgumentParser(description=__doc__)
|
||||||
ap.add_argument('--local',action='store_true',help='Use installed toolchain; still compile Android only')
|
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('--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('--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')
|
ap.add_argument('--timeout',type=int,default=1800,help='Per-stage safety timeout in seconds')
|
||||||
a=ap.parse_args()
|
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')
|
if a.timeout<1:ap.error('--timeout must be positive')
|
||||||
try:profiles=selected_profiles(a.profiles)
|
inherited=os.environ.get('KAM_BUILD_ID')
|
||||||
except ProbeError as e:ap.error(str(e))
|
if inherited and not re.fullmatch(r'\d{8}T\d{6}Z-[0-9a-f]{8}',inherited):ap.error('Invalid KAM_BUILD_ID')
|
||||||
return build_local(a,profiles) if a.local else docker_build(a,profiles)
|
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())
|
if __name__=='__main__':raise SystemExit(main())
|
||||||
Reference in new issue
Block a user