Complete BigTask
Sync changed files to Google Drive / sync (push) Successful in 1m34s

This commit is contained in:
admin_d committed 2026-09-14 20:47:20 +05:00
1 parent 15a968a2f7
commit b52921e171
38 files changed
+564 -2956

No files matched your search

+1
View File
@@ -1,4 +1,5 @@
/build/
/out/
__pycache__/
*.py[cod]
/private-assets/**
-38
View File
@@ -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
+38 -198
View File
@@ -1,219 +1,59 @@
# KaM r16020 — Android Architecture Probe 0.3.1
# KaM Remake Mobile
**Кодовый тест архитектуры: KaM core + Free Pascal + Castle Game Engine → Android.**
This repository is the Android development baseline for the KaM Remake r16020 port. Its upstream authority is KaM Remake r16020, commit `962fb850324b79bbe5480e1c2087d98f83a60917`. It is the permanent single-application build, source, and resource baseline for later port work; it is not yet a complete playable game.
Здесь нет desktop target, готовой игры, игровых бинарных ресурсов или собственного заменителя KaM core. Тест не требует `data/` для проверки компиляции и линковки. Он проверяет, какие **настоящие исходные модули r16020** можно включить в Android-библиотеку вместе с Castle и где возникают конкретные препятствия.
## Build
**Архив содержит код стенда и автоматизацию, не заранее собранный APK.** Во время подготовки проверены Python-скрипты и их контрольные сценарии. Настоящая компиляция Pascal/Android здесь не выполнялась: FPC, Castle, Docker и ADB в среде подготовки отсутствовали. Подробнее: `reports/preparation/VALIDATION.md`.
## 1. Запуск на сервере — без ресурсов
Требуются Linux x86_64 или Linux-среда WSL2, Bash, Python **3.10+**, работающий Docker и доступ в интернет. Папка проекта должна быть доступна на запись. На сервере ARM64 Docker-образ Linux/amd64 потребует отдельно настроенной эмуляции; это не основной путь этого теста.
The one supported entrypoint is:
```bash
unzip kam-android-architecture-probe-r16020.zip
cd kam-android-architecture-probe-r16020
chmod +x build-android.sh test-harness.sh
./build-android.sh
```
Команда выполняет весь **Android ARM64** цикл, а не сборку Linux-версии игры:
Supported options are `--local`, `--cpu {aarch64,arm,x86_64}`, `--no-fetch`, and `--timeout <positive seconds>`.
1. Получает официальный Docker-образ инструментов CGE. При первом запуске фиксирует его неизменяемый digest в `config/toolchain.lock.json`; при следующих использует его же.
2. Загружает **только текстовые исходники** KaM по commit `962fb850324b79bbe5480e1c2087d98f83a60917` и исходники PascalScript по gitlink из этого commit. Полный checkout, история Git, release-архивы и игровые ресурсы не скачиваются.
3. Создаёт 15 изолированных Android-проектов: компилирует и полностью линкует каждый, продолжая остальные проверки после ошибки одной подсистемы.
4. По умолчанию упаковывает три ключевых проекта в APK, когда их линковка успешна: `platform`, `core_types`, `integration`.
5. Сохраняет результаты, конкретные ошибки, версии инструментов, идентификаторы исходников и архив журналов без содержимого ресурсов.
- `--local` uses host tooling while keeping the Android target unchanged.
- `--cpu` selects an ABI for the same application identity.
- `--no-fetch` requires the pinned vendor source to already be present and verified.
- `--timeout` is the per-external-tool-invocation timeout.
**Системные инструменты сборки — бинарные программы и будут скачаны Docker на сервере. Запрет на скачивание игровых бинарных ресурсов соблюдён; полностью автономный комплект SDK/NDK/FPC в этот ZIP не вложен.** Полный оригинальный код KaM также не вложен: его автоматически получает серверный загрузчик по фиксированному commit. В архиве нет ссылок на изменяемый `master` KaM.
Each build is one permanent Castle application transaction. Read `out/current/build.json` for its finalized state. On `PASS`, the current package is described by `out/current/package.json` and is at `out/current/kam-mobile.apk`.
`config/toolchain.lock.json` появляется при первом реальном получении Docker-образа, а не содержит выдуманный digest. Для одинакового окружения на другом сервере перенесите этот lock вместе с проектом. Сам первый выбор CGE-образа зависит от даты запуска; KaM всегда зафиксирован на r16020.
`build/<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
| Проба | Реальные модули / граница | По умолчанию |
|---|---|---|
| `platform` | CGE + FPC: Android shell, системные потоки, файловые пути, zlib, изображение, input, audio API | Compile + native link + APK |
| `core_types` | `KM_CommonTypes`, `KM_Points`; настоящие функции координат | Compile + native link + APK |
| `core_streams` | `KM_CommonClasses`; настоящая сериализация и зависимости | Compile + native link |
| `mission_tokens` | `KM_MissionScript` | Compile + native link |
| `mission_info` | `KM_MissionScript_Info` | Compile + native link |
| `terrain_io` | `KM_TerrainUtils` | Compile + native link |
| `sprite_loader` | `KM_ResSprites` и его реальные зависимости | Compile + native link |
| `terrain_pathfinding` | `KM_Terrain` и его реальные зависимости | Compile + native link |
| `mission_world` | `KM_MissionScript_Standard` | Compile + native link |
| `simulation` | `KM_Game` как исходный модуль, **не desktop executable** | Compile + native link |
| `script_vm` | `uPSCompiler`, `uPSRuntime` из закреплённого PascalScript | Compile + native link |
| `scripting` | `KM_ScriptingEvents` | Compile + native link |
| `legacy_render` | `KM_Render`, диагностика прежнего backend | Compile + native link; необязательная проба |
| `legacy_audio` | `KM_Sound`, диагностика прежнего backend | Compile + native link; необязательная проба |
| `integration` | Основные корни KaM **вместе с Castle в одной Android-библиотеке** | Compile + native link + APK |
Pinned vendor source is at `vendor/kam_remake`. Project-owned source adaptation and registration are centralized, in order, in `config/extra-paths.json` and `tools/source_resolution.py`. Do not edit generated working manifests for source paths. `code/` is the permanent project-owned application source root; Pascal units enter through normal dependency closure from `GameInitialize`.
Никаких `test_shims`, пустых `KM_*.pas` вместо оригиналов или самостоятельно переписанных MAP/RXX readers в сборочных проектах нет. Модули с транзитивными зависимостями компилируются как есть; их ошибка не скрывается.
Private runtime-resource contents live under `private-assets/`. `config/runtime-resources.json` owns selection and `tools/resource_staging.py` owns staging. Selected files preserve their exact relative paths into Castle data. Proprietary game contents are not committed to public Git, and this baseline neither converts nor repacks them.
Используется один уже существующий upstream define: **`DBG_SKIP_SECURE_AUTH`**. Он отключает необязательный приватный модуль аутентификации multiplayer, недоступный в обычном исходном комплекте. Это локальный Android-проект, не тест сетевого сервиса. Define записывается в каждый соответствующий `target.json`. Остальные игровые подсистемы не отключаются для получения зелёного отчёта.
The permanent application definition is `app/CastleEngineManifest.xml`: `kam_mobile`, `org.kammobile.kamremake`, and `GameInitialize`.
Линковка не отключена через `-Cn`. Стенд запрашивает пересборку (`-B`), отключает smart linking/удаление неиспользуемых секций и включает проверку undefined symbols. Это намеренно более строгая проверка зависимостей, чем пустой вызов одной функции с выброшенным остальным кодом. Проверяются свежие корневые `.ppu`, ELF нужного ABI, отсутствие `DT_TEXTREL`, структура APK и его native dependencies. Если упаковщик удалил только debug symbols, библиотека сопоставляется по неизменным allocated sections и program headers, а не ошибочно отклоняется из-за другой полной SHA-256. Подпись APK проверяется `apksigner`, когда он найден; иначе проверка подписи помечена `NOT_RUN`.
## Optional device helper
## 3. Результаты и коды возврата
After a successful current build, optionally run:
Начинайте с:
```bash
python3 tools/device.py [--serial <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
reports/LATEST-SUMMARY.md
reports/LATEST.json
app/ permanent application definition
code/ permanent project-owned application source
config/ source and runtime-resource owners
tools/ build, source-resolution, resource-staging, and device tools
private-assets/ private runtime-resource contents
vendor/ pinned vendor source
build/ generated transient working state
out/ generated current finalized state
reports/ frozen Task-1 history
dist/ frozen Task-1 history
```
Полный результат конкретного запуска:
```text
reports/runs/<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
View File
@@ -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:
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.
README.md describes the source, resource, and application owners.
+10
View File
@@ -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>
-22
View File
@@ -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.
-22
View File
@@ -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.
-27
View File
@@ -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.
+2 -3
View File
@@ -1,13 +1,12 @@
unit GameInitialize;
{$mode objfpc}{$H+}
{$ifndef ANDROID}{$fatal Android-only architecture probe; there is no desktop target}{$endif}
{$ifndef ANDROID}{$fatal Android-only KaM Remake Mobile application; there is no desktop target}{$endif}
interface
implementation
uses CastleWindow, GameViewMain;
uses CastleWindow;
var Window: TCastleWindow;
procedure Initialize;
begin
Window.Container.View := TProbeView.Create(Application);
end;
initialization
Application.OnInitialize := @Initialize;
-241
View File
@@ -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.
-55
View File
@@ -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.
-33
View File
@@ -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.
-120
View File
@@ -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.
-131
View File
@@ -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.
-78
View File
@@ -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.
-89
View File
@@ -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.
-34
View File
@@ -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.
-19
View File
@@ -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"}
]
+10
View File
@@ -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"}
]
}
-39
View File
@@ -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».
-8
View File
@@ -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
View File
@@ -1,27 +1,24 @@
# Первичные источники
# Primary sources
Проверено при подготовке: 8 сентября 2026 года. Это ссылки для воспроизведения API/сборочных решений, не утверждение, что Android APK был собран в среде подготовки.
## Pinned KaM authority
## Зафиксированный KaM
- Repository: [reyandme/kam_remake](https://github.com/reyandme/kam_remake)
- Release/tag: r16020
- Commit: [962fb850324b79bbe5480e1c2087d98f83a60917](https://github.com/reyandme/kam_remake/tree/962fb850324b79bbe5480e1c2087d98f83a60917)
- [KaM_Remake.inc](https://github.com/reyandme/kam_remake/blob/962fb850324b79bbe5480e1c2087d98f83a60917/KaM_Remake.inc) and [PascalScript gitlink](https://github.com/reyandme/kam_remake/blob/962fb850324b79bbe5480e1c2087d98f83a60917/.gitmodules)
- База и исходники: https://github.com/reyandme/kam_remake/tree/962fb850324b79bbe5480e1c2087d98f83a60917
- Переключатели FPC/существующий `DBG_SKIP_SECURE_AUTH`: https://github.com/reyandme/kam_remake/blob/962fb850324b79bbe5480e1c2087d98f83a60917/KaM_Remake.inc
- Подмодуль PascalScript: https://github.com/reyandme/kam_remake/blob/962fb850324b79bbe5480e1c2087d98f83a60917/.gitmodules
- Оригинальные координаты: https://github.com/reyandme/kam_remake/blob/962fb850324b79bbe5480e1c2087d98f83a60917/src/common/KM_Points.pas
- Оригинальные потоки: https://github.com/reyandme/kam_remake/blob/962fb850324b79bbe5480e1c2087d98f83a60917/src/common/KM_CommonClasses.pas
Upstream revision, blob SHA-1 и SHA-256 для всех реально используемых файлов записывает `tools/source_fetch.py`. Используется SHA gitlink подмодуля из закреплённого дерева, не его текущая ветка.
Source authority is, in order: pinned upstream r16020 semantics; project-owned registered adaptations; and Castle Android platform/tooling documentation. `vendor/kam_remake` is the pinned source location, with adaptation registration owned by `config/extra-paths.json` and `tools/source_resolution.py`.
## Castle / Android
- Build tool, Android `--os/--cpu`, `--assume-compiled`, debug APK: https://castle-engine.io/build_tool
- Формат manifest, game_units, search_paths, defines, custom_options: https://castle-engine.io/project_manifest
- Официальный toolchain Docker image и его состав: https://castle-engine.io/docker
- Android: https://castle-engine.io/android
- Android assets: https://castle-engine.io/android_faq
- Lifecycle callbacks: https://castle-engine.io/apidoc/html/CastleApplicationProperties.TCastleApplicationProperties.html
- Файлы и config URLs: https://castle-engine.io/apidoc/html/CastleUriUtils.html
- Android ABI: https://developer.android.com/ndk/guides/abis
- Проверка реального устройства: https://developer.android.com/studio/run/device
- [Castle build tool](https://castle-engine.io/build_tool)
- [Castle project manifest](https://castle-engine.io/project_manifest)
- [Castle Docker toolchain](https://castle-engine.io/docker)
- [Castle Android documentation](https://castle-engine.io/android)
- [Castle Android assets](https://castle-engine.io/android_faq)
- [Castle lifecycle callbacks](https://castle-engine.io/apidoc/html/CastleApplicationProperties.TCastleApplicationProperties.html)
- [Castle files and config URLs](https://castle-engine.io/apidoc/html/CastleUriUtils.html)
- [Android ABI guidance](https://developer.android.com/ndk/guides/abis)
- [Android device deployment](https://developer.android.com/studio/run/device)
CGE API развивается. Именно поэтому первый реальный запуск сохраняет digest toolchain, а ошибка платформенного API в `platform` не называется ошибкой KaM. Default SDK/NDK берутся из выбранного CGE, а не из неподтверждённой собственной таблицы версий.
These references support the pinned baseline; mutable upstream `master` is not implementation authority.
+13 -33
View File
@@ -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` это обнаружит.
## 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>/`; сначала проверьте, нет ли в журналах нежелательных личных путей/данных.
`reports/**` and `dist/**` are frozen Task-1 historical evidence, not inputs to current diagnosis.
@@ -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."
]
}
-126
View File
@@ -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"
]
}
]
}
-106
View File
@@ -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()
-272
View File
@@ -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())
-102
View File
@@ -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."
-6
View File
@@ -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
-48
View File
@@ -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)
-103
View File
@@ -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()
-331
View File
@@ -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
View File
@@ -7,9 +7,14 @@ from datetime import datetime, timezone
ROOT = Path(__file__).resolve().parents[1]
COMMIT = '962fb850324b79bbe5480e1c2087d98f83a60917'
REPOSITORY = 'reyandme/kam_remake'
VERSION = '0.4.0'
CPUS = {'aarch64': ('arm64-v8a', 183, 2, 8), 'arm': ('armeabi-v7a', 40, 1, 4),
'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):
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(
r'\b(fatal|error|undefined reference|cannot find|can.t find|not found)\b', s, re.I)]
return rows[:limit]
def classify(log: str) -> str:
"""Triage hint, never an automatic decision to rewrite a subsystem."""
if re.search(r'cannot find.*(ppcross|ppca64|ppcarm)|compiler.*not found|SDK.*not found', log, re.I):
return 'TOOLCHAIN'
if re.search(r"can.t find unit (Windows|Forms|Controls|Graphics|LCL\w*|Dialogs|ShellAPI)", log, re.I):
return 'PLATFORM_BOUNDARY'
if re.search(r'undefined reference|cannot find -l|error while linking', log, re.I):
return 'NATIVE_LINK_DEPENDENCY'
if re.search(r"can.t find unit|cannot open include file", log, re.I):
return 'SOURCE_OR_PACKAGE_DEPENDENCY'
return 'SOURCE_OR_TOOLCHAIN_REVIEW'
-84
View File
@@ -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
+70 -119
View File
@@ -1,134 +1,85 @@
"""Install and collect a selected Android decision probe with diagnostic evidence."""
"""Install and launch the current Android application package."""
from __future__ import annotations
import argparse, json, re, shutil, subprocess, time
import argparse, json, shutil, subprocess, xml.etree.ElementTree as ET
from pathlib import Path
from common import ROOT, CPUS, ProbeError, safe_relative, sha256, stamp, write_json
DECISION_PROFILES={'oq_host_view','oq_host_callback'}
from common import (ROOT, CPUS, ProbeError, sha256, CURRENT_BUILD_METADATA,
CURRENT_PACKAGE_METADATA, CURRENT_APK, CURRENT_BUILD_SCHEMA,
CURRENT_PACKAGE_SCHEMA)
class Adb:
def __init__(self,serial:str|None): self.base=['adb']+(['-s',serial] if serial else [])
def __init__(self, serial: str | None): self.base=['adb']+(['-s',serial] if serial else [])
def run(self,*args:str,check:bool=True,timeout:int=120)->subprocess.CompletedProcess:
p=subprocess.run(self.base+list(args),capture_output=True,timeout=timeout)
if check and p.returncode: raise ProbeError('ADB command failed: '+' '.join(args)+'\n'+p.stderr.decode(errors='replace'))
return p
def text(self,*args:str,check:bool=True)->str: return self.run(*args,check=check).stdout.decode(errors='replace').strip()
def own_report_paths(text:str)->list[str]:
paths=[]
for line in text.splitlines():
name=line.strip()[2:] if line.strip().startswith('./') else line.strip()
try: safe_relative(name)
except ProbeError: continue
if name.endswith('kam-architecture-probe/runtime.json'): paths.append(name)
return paths
def fail(message: str) -> None: raise ProbeError(message)
def validate_runtime(data:dict,package:dict,require_frames:bool=True)->list[str]:
errors=[]
for key,value in (('schema','kam-android-runtime/1'),('build_id',package['build_id']),('profile',package['profile']),('cpu_expected',package['cpu'])):
if data.get(key)!=value: errors.append('Wrong/stale runtime '+key)
if data.get('automatic_smoke_status')!='PASS': errors.append('Automatic smoke did not pass')
tests=data.get('tests')
if not isinstance(tests,list) or not tests: return errors+['No runtime tests']
by_id={x.get('id'):x for x in tests if isinstance(x,dict)}; profile=package['profile']; required=['android.startup']; observed=[]
if profile not in DECISION_PROFILES:
required += ['abi.pointer','abi.scalar_layout','abi.endianness','files.native_utf8','files.seek','files.case_sensitive','fpc.zstream','fpc.thread']
observed += ['decision.persistence.rename_replace','decision.persistence.append_reopen']
if profile=='oq_host_view': required += ['decision.host.view']
elif profile=='oq_host_callback': required += ['decision.host.callback']
if profile=='platform': required += ['decision.text.cwstring_unit']; observed += ['decision.text.cp1251_decode','decision.text.cp1251_encode']
if require_frames: required += ['render.frame_callbacks','render.context']
for name in required:
if by_id.get(name,{}).get('status')!='PASS': errors.append('Required runtime check did not pass: '+name)
for name in observed:
if name not in by_id: errors.append('Decision observation missing: '+name)
if any(t.get('status')=='FAIL' for t in tests if isinstance(t,dict)): errors.append('One or more runtime checks failed')
if require_frames and data.get('session',{}).get('frames',0)<30: errors.append('Fewer than 30 frames')
return errors
def load_json(path: Path, missing: str) -> dict:
try: value=json.loads(path.read_text())
except FileNotFoundError: fail(missing)
except (OSError, ValueError) as error: fail(f'Malformed {path.name}: {error}')
if not isinstance(value,dict): fail(f'Malformed {path.name}')
return value
def read_runtime(adb:Adb,pkg:str)->tuple[dict|None,str|None]:
paths=own_report_paths(adb.text('shell','run-as',pkg,'find','.','-name','runtime.json',check=False))
if len(paths)!=1: return None,None
raw=adb.run('exec-out','run-as',pkg,'cat',paths[0],check=False).stdout
try: return json.loads(raw),paths[0]
except (ValueError,UnicodeDecodeError): return None,None
def wait_runtime(adb:Adb,pkg:str,predicate,timeout:int)->tuple[dict|None,str|None]:
end=time.monotonic()+timeout
while time.monotonic()<end:
data,path=read_runtime(adb,pkg)
if data is not None and predicate(data): return data,path
time.sleep(1)
return read_runtime(adb,pkg)
def save_command(out:Path,name:str,p:subprocess.CompletedProcess)->None:
out.joinpath(name).write_text('exit='+str(p.returncode)+'\nstdout:\n'+p.stdout.decode(errors='replace')+'\nstderr:\n'+p.stderr.decode(errors='replace'),encoding='utf-8')
def main()->int:
ap=argparse.ArgumentParser(description=__doc__)
ap.add_argument('--profile',default='platform');ap.add_argument('--serial');ap.add_argument('--index',type=Path,default=ROOT/'dist/LATEST.json')
ap.add_argument('--seconds',type=int,default=30);ap.add_argument('--exercise-lifecycle',action='store_true');ap.add_argument('--stage-file',type=Path)
a=ap.parse_args()
if a.seconds<5: ap.error('--seconds must be at least 5')
out=ROOT/'reports/device'/stamp(); n=1
while out.exists(): out=ROOT/'reports/device'/(stamp()+'-'+str(n));n+=1
out.mkdir(parents=True)
record={'schema':'kam-android-device/2','status':'BLOCKED','errors':[],'preflight':{'status':'NOT_RUN'},'manual_visual_touch_audio':'NOT_VERIFIED','world_simulation':'NOT_RUN','command_environment':{'host_adb':shutil.which('adb'),'timeout_seconds':a.seconds}}
adb=None
def manifest_identity() -> dict:
try:
index=json.loads(a.index.read_text()); matches=[p for p in index.get('packages',[]) if p.get('profile')==a.profile]
if len(matches)!=1: raise ProbeError('No unique successfully-built APK for '+a.profile)
package=matches[0]; apk=ROOT/package['file']; pkg=package['package'];record.update(build_id=package['build_id'],profile=a.profile,package=package)
if not apk.is_file() or sha256(apk)!=package['sha256']: raise ProbeError('APK missing/changed')
if not re.fullmatch(r'org\.kammobile\.archprobe\.[a-z0-9]+',pkg): raise ProbeError('Refusing unrelated Android package')
if not shutil.which('adb'): record['preflight']={'status':'BLOCKED','reason':'adb is not installed on host'};raise ProbeError(record['preflight']['reason'])
listed=Adb(None).text('devices').splitlines()[1:]; devices=[line.split()[:2] for line in listed if line.strip()]
record['preflight']={'adb':'PASS','devices':devices}
if len(devices)!=1 or len(devices[0])!=2 or devices[0][1]!='device': record['preflight'].update(status='BLOCKED',reason='Require exactly one authorized device; found '+repr(devices));raise ProbeError(record['preflight']['reason'])
if a.serial and a.serial!=devices[0][0]: record['preflight'].update(status='BLOCKED',reason='Requested serial is not the sole authorized device');raise ProbeError(record['preflight']['reason'])
adb=Adb(devices[0][0]); record['device']={'serial':devices[0][0],'abi':adb.text('shell','getprop','ro.product.cpu.abilist'),'android':adb.text('shell','getprop','ro.build.version.release'),'sdk':adb.text('shell','getprop','ro.build.version.sdk'),'model':adb.text('shell','getprop','ro.product.model')}
if CPUS[package['cpu']][0] not in record['device']['abi'].split(','): record['preflight'].update(status='BLOCKED',reason='Device ABI does not support arm64-v8a');raise ProbeError(record['preflight']['reason'])
record['preflight']['status']='PASS'
install=adb.run('install','-r',str(apk),check=False);save_command(out,'install.log',install);record['install']={'package_id':pkg,'initial_exit':install.returncode,'result':install.stdout.decode(errors='replace').strip()}
root=ET.parse(ROOT/'app/CastleEngineManifest.xml').getroot(); version=root.find('version')
if root.tag!='project' or version is None: fail('Malformed permanent manifest')
return {'name':root.attrib['name'],'package':root.attrib['qualified_name'],
'version':version.attrib['value'],'version_code':int(version.attrib['code'])}
except (OSError, ET.ParseError, KeyError, ValueError) as error: fail(f'Malformed permanent manifest: {error}')
def current_package() -> tuple[dict, str]:
if not CURRENT_BUILD_METADATA.is_file(): raise RuntimeError('Current build metadata is missing')
build=load_json(CURRENT_BUILD_METADATA,'Current build metadata is missing')
if set(build)!={'schema','build_id','status','cpu','abi','application','upstream_commit','resource_count','compile','package','package_metadata','error'} or build.get('schema')!=CURRENT_BUILD_SCHEMA: fail('Invalid current build schema')
if build.get('status')!='PASS': raise RuntimeError('Current build is not PASS')
app=build.get('application'); identity=manifest_identity()
if not isinstance(app,dict) or any(app.get(key)!=value for key,value in identity.items()): fail('Build application identity mismatch')
if build.get('package_metadata')!='out/current/package.json': fail('Build package metadata locator mismatch')
package=load_json(CURRENT_PACKAGE_METADATA,'Current package metadata is missing')
if set(package)!={'schema','build_id','application','cpu','abi','apk'} or package.get('schema')!=CURRENT_PACKAGE_SCHEMA: fail('Invalid current package schema')
if package.get('build_id')!=build.get('build_id') or package.get('application')!=identity: fail('Package metadata identity mismatch')
if package.get('cpu')!=build.get('cpu') or package.get('abi')!=build.get('abi'): fail('Package build mismatch')
apk=package.get('apk')
if not isinstance(apk,dict) or set(apk)!={'file','sha256','size','native_library_path_in_apk'} or apk.get('file')!='out/current/kam-mobile.apk': fail('Package APK locator mismatch')
if package['cpu'] not in CPUS or package['abi']!=CPUS[package['cpu']][0]: fail('Invalid package ABI')
if not CURRENT_APK.is_file() or apk.get('sha256')!=sha256(CURRENT_APK): fail('Current APK missing or changed')
return package, identity['package']
def main() -> int:
ap=argparse.ArgumentParser(description=__doc__); ap.add_argument('--serial'); a=ap.parse_args()
try:
package,pkg=current_package()
except ProbeError as error:
print('FAIL\n'+str(error)); return 1
except RuntimeError as error:
print('BLOCKED\n'+str(error)); return 2
except (KeyError, TypeError) as error:
print('FAIL\n'+str(error)); return 1
if not shutil.which('adb'):
print('BLOCKED\nadb is not installed on host'); return 2
try:
devices=[line.split()[:2] for line in Adb(None).text('devices').splitlines()[1:] if line.strip()]
if len(devices)!=1 or len(devices[0])!=2 or devices[0][1]!='device': fail('Require exactly one authorized device')
serial=devices[0][0]
if a.serial and a.serial!=serial: fail('Requested serial is not the sole authorized device')
adb=Adb(serial)
if package['abi'] not in adb.text('shell','getprop','ro.product.cpu.abilist').split(','): fail('Device ABI does not support package ABI')
except (ProbeError, OSError, subprocess.TimeoutExpired) as error:
print('BLOCKED\n'+str(error)); return 2
try:
install=adb.run('install','-r',str(CURRENT_APK),check=False)
if install.returncode and b'INSTALL_FAILED_UPDATE_INCOMPATIBLE' in install.stdout+install.stderr:
uninstall=adb.run('uninstall',pkg,check=False);save_command(out,'uninstall.log',uninstall);record['install']['uninstall_for_ephemeral_key']={'exit':uninstall.returncode,'result':uninstall.stdout.decode(errors='replace').strip()}
install=adb.run('install',str(apk),check=False);save_command(out,'install-retry.log',install);record['install'].update(retry_exit=install.returncode,retry_result=install.stdout.decode(errors='replace').strip())
if install.returncode: raise ProbeError('APK install failed; see install.log')
run_as=adb.run('shell','run-as',pkg,'id',check=False);save_command(out,'run-as.log',run_as);record['run_as']={'status':'PASS' if run_as.returncode==0 else 'BLOCKED','output':run_as.stdout.decode(errors='replace').strip(),'error':run_as.stderr.decode(errors='replace').strip()}
if run_as.returncode: record['infrastructure_blocker']={'stage':'run_as_preflight','reason':'run-as unavailable for '+pkg};raise ProbeError(record['infrastructure_blocker']['reason'])
if a.stage_file:
if not a.stage_file.is_file(): raise ProbeError('Stage file not found: '+str(a.stage_file))
rel='files/kam-architecture-probe/driver-staged-fixture.bin';adb.run('shell','run-as',pkg,'sh','-c','mkdir -p files/kam-architecture-probe')
p=subprocess.run(adb.base+['exec-in','run-as',pkg,'sh','-c','cat > '+rel],input=a.stage_file.read_bytes(),stdout=subprocess.PIPE,stderr=subprocess.PIPE);save_command(out,'stage.log',p)
size=adb.text('shell','run-as',pkg,'stat','-c','%s',rel,check=False);record['fixture_staging_driver']={'status':'PASS' if p.returncode==0 and size==str(a.stage_file.stat().st_size) else 'FAIL','source':str(a.stage_file),'bytes':a.stage_file.stat().st_size,'device_bytes':size}
adb.run('shell','am','force-stop',pkg); listing=adb.text('shell','run-as',pkg,'find','.','-name','runtime.json',check=False)
for name in own_report_paths(listing): adb.run('shell','run-as',pkg,'rm','--',name)
adb.run('logcat','-c');record['launch_marker_epoch_ms']=int(time.time()*1000)
component=adb.text('shell','cmd','package','resolve-activity','--brief','-a','android.intent.action.MAIN','-c','android.intent.category.LAUNCHER',pkg); candidates=[x.strip() for x in component.splitlines() if x.strip().startswith(pkg+'/')]
if len(candidates)!=1 or not re.fullmatch(r'[\w.$/]+',candidates[0]): raise ProbeError('Cannot resolve probe launch Activity')
launch=adb.run('shell','am','start','-W','-n',candidates[0],check=False);save_command(out,'launch.log',launch);record['launch']={'activity':candidates[0],'exit':launch.returncode,'result':launch.stdout.decode(errors='replace').strip()}
if launch.returncode: raise ProbeError('Launch failed; see launch.log')
data,path=wait_runtime(adb,pkg,lambda d:any(x.get('id')=='android.startup' and x.get('status')=='PASS' for x in d.get('tests',[])),a.seconds)
if data is None: raise ProbeError('android.startup was not observed before timeout; see logcat.log and process.log')
if a.exercise_lifecycle:
adb.run('shell','input','keyevent','KEYCODE_HOME');time.sleep(1);adb.run('shell','am','start','-W','-n',candidates[0])
expected=[]
if a.exercise_lifecycle and a.profile=='platform': expected=['decision.schedule.callback_progress','decision.schedule.worker_progress','decision.schedule.lifecycle']
if a.profile=='platform': expected += ['render.frame_callbacks','decision.text.cwstring_unit','decision.text.cp1251_decode','decision.text.cp1251_encode']
if expected: data,path=wait_runtime(adb,pkg,lambda d: all(any(x.get('id')==name for x in d.get('tests',[])) for name in expected),a.seconds)
if data is None: raise ProbeError('Runtime report disappeared; see logcat.log and process.log')
out.joinpath('runtime.json').write_text(json.dumps(data,ensure_ascii=False,indent=2)+'\n',encoding='utf-8');record['runtime_path']=path
record['errors']+=validate_runtime(data,package,require_frames=package['profile'] not in DECISION_PROFILES)
if expected and not all(any(x.get('id')==name for x in data.get('tests',[])) for name in expected): record['errors'].append('Timed out waiting for observations: '+', '.join(expected))
if a.stage_file and record.get('fixture_staging_driver',{}).get('status')!='PASS': record['errors'].append('Existing server fixture staging size mismatch')
record['status']='FAIL' if record['errors'] else 'AUTOMATIC_SMOKE_PASS'
except (ProbeError,OSError,ValueError,KeyError,subprocess.TimeoutExpired) as e:
record['errors'].append(str(e))
if record['preflight'].get('status')=='BLOCKED': record['infrastructure_blocker']={'stage':'device_preflight','reason':record['preflight'].get('reason',str(e))}
finally:
if adb:
p=adb.run('shell','pidof',record.get('package',{}).get('package',''),check=False);save_command(out,'process.log',p);record['process_after_launch']=p.stdout.decode(errors='replace').strip()
p=adb.run('logcat','-d','-v','threadtime',check=False);save_command(out,'logcat.log',p)
write_json(out/'device-result.json',record);print(record['status']+'\n'+str(out));[print(x) for x in record['errors']]
return 0 if record['status']=='AUTOMATIC_SMOKE_PASS' else 1 if record['status']=='FAIL' else 2
adb.run('uninstall',pkg,check=False); install=adb.run('install',str(CURRENT_APK),check=False)
if install.returncode: fail('APK install failed')
candidates=[line.strip() for line in adb.text('shell','cmd','package','resolve-activity','--brief','-a','android.intent.action.MAIN','-c','android.intent.category.LAUNCHER',pkg).splitlines() if line.strip()]
if len(candidates)!=1 or not candidates[0].startswith(pkg+'/'): fail('Cannot resolve exactly one application launcher activity')
if adb.run('shell','am','start','-W','-n',candidates[0],check=False).returncode: fail('Launch failed')
except (ProbeError, OSError, subprocess.TimeoutExpired) as error:
print('FAIL\n'+str(error)); return 1
print('PASS'); return 0
if __name__=='__main__': raise SystemExit(main())
+73 -119
View File
@@ -1,130 +1,84 @@
"""Generate isolated Android CGE targets. No KaM stubs or rewritten game loaders."""
from __future__ import annotations
import json, os, shutil, urllib.parse, xml.etree.ElementTree as ET
import hashlib, os, xml.etree.ElementTree as ET
from pathlib import Path
from common import ROOT, COMMIT, VERSION, CPUS, ProbeError, safe_relative, sha256, write_json
from source_resolution import build_source_resolution
from common import ROOT, ProbeError
UPSTREAM_DEFINES=['DBG_SKIP_SECURE_AUTH'] # Existing upstream switch; private multiplayer authentication is out of scope.
COMPILER_OPTIONS=['-B','-CX-','-XX-','-k--no-undefined','-k--no-gc-sections']
def imports_source(profile:dict)->tuple[str,list[str]]:
units=list(profile['units'])
points=profile['id'] in ('core_types','core_streams','integration')
streams=profile['id'] in ('core_streams','integration')
if points and 'KM_Points' not in units:units.append('KM_Points')
head='unit ProbeImports;\n{$mode delphi}{$H+}\ninterface\nuses ProbeReport;\nprocedure RunUpstreamChecks(R:TProbeReport);\nimplementation\n'
head+='uses SysUtils'+(' ,'+', '.join(units) if units else '')+';\n'
head+='procedure RunUpstreamChecks(R:TProbeReport);\n'
if points:
head+='var P,Q:TKMPoint;\n'
if streams:head+=' S:TKMemoryStreamBinary; I:Integer; U:UnicodeString;\n'
head+='begin\n'
if not units:
head+=" R.Add('upstream.imports','NOT_RUN','Platform-only APK; no KaM modules imported');\n"
else:
head+=" R.Add('upstream.imports','PASS','Real imported units initialized: "+', '.join(units)+"; this does not execute a game world');\n"
if points:
head+=''' try
P:=KMPoint(17,-3); Q:=KMPointRight(P);
R.Check('core.points',(P.X=17) and (P.Y=-3) and (Q.X=18) and (Q.Y=-3),
'Calls original KMPoint / KMPointRight, not a local replacement');
R.Check('core.point_layout',(SizeOf(TKMPoint)=8) and (SizeOf(TKMPointF)=8),
Format('TKMPoint=%d TKMPointF=%d',[SizeOf(TKMPoint),SizeOf(TKMPointF)]));
except on E:Exception do R.Add('core.points','FAIL',E.ClassName+': '+E.Message);end;
'''
if streams:
head+=''' try
S:=TKMemoryStreamBinary.Create;
try
S.Write(Integer(123456)); S.Write(P); S.WriteW(UnicodeString('KaM Android'));
S.Position:=0; S.Read(I); S.Read(Q); S.ReadW(U);
R.Check('core.stream_roundtrip',(I=123456) and (Q.X=P.X) and (Q.Y=P.Y)
and (U=UnicodeString('KaM Android')) and (S.Position=S.Size),
'Calls original TKMemoryStreamBinary Read/Write/ReadW/WriteW');
finally S.Free;end;
except on E:Exception do R.Add('core.stream_roundtrip','FAIL',E.ClassName+': '+E.Message);end;
'''
head+='end;\nend.\n'
return head,units
_PROJECT_ATTRIBUTES={
'name':'kam_mobile', 'caption':'KaM Remake Mobile', 'author':'KaM Mobile',
'qualified_name':'org.kammobile.kamremake', 'game_units':'GameInitialize',
'compiler':'fpc', 'screen_orientation':'landscape', 'fullscreen_immersive':'true',
}
def asset_inventory(root:Path|None, includes:list[str]|None=None)->list[dict]:
if root is None:return []
root=root.resolve()
if not root.is_dir():raise ProbeError('Asset directory does not exist: '+str(root))
if includes:
result=[]
seen=set()
for raw in includes:
rel=safe_relative(raw.replace('\\','/'))
if rel in seen:continue
seen.add(rel);p=root/rel
if p.is_symlink():raise ProbeError('Asset symlinks are not allowed: '+str(p))
if not p.is_file():raise ProbeError('Requested asset path not found under asset root: '+rel)
result.append({'path':rel,'size':p.stat().st_size,'sha256':sha256(p),
'url':'castle-data:/game/'+urllib.parse.quote(rel,safe='/')})
return result
result=[]
for p in sorted(root.rglob('*')):
if p.is_symlink():raise ProbeError('Asset symlinks are not allowed: '+str(p))
if p.is_dir():continue
if not p.is_file():raise ProbeError('Not a regular asset: '+str(p))
rel=safe_relative(p.relative_to(root).as_posix())
if rel=='README.md' or '.git' in p.parts:continue
result.append({'path':rel,'size':p.stat().st_size,'sha256':sha256(p),
'url':'castle-data:/game/'+urllib.parse.quote(rel,safe='/')})
return result
def _one_child(parent: ET.Element, tag: str) -> ET.Element:
children=parent.findall(tag)
if len(children)!=1:raise ProbeError(f'Manifest requires exactly one <{tag}>')
return children[0]
def make_target(project:Path,profile:dict,cpu:str,build_id:str,source:Path|None,
validated:dict|None,assets_root:Path|None=None,assets:list[dict]|None=None)->dict:
def _manifest_path(path: object, project: Path, kind: str) -> str:
try:
resolved=Path(path).resolve()
if not resolved.is_dir():raise ProbeError(f'{kind} path missing: {resolved}')
relative=os.path.relpath(resolved,project).replace(os.sep,'/')
if not any(char.isspace() for char in relative):return relative
alias=project/'.source-paths'/hashlib.sha256(str(resolved).encode()).hexdigest()
alias.parent.mkdir(exist_ok=True)
if alias.exists() or alias.is_symlink():
if not alias.is_symlink() or alias.resolve()!=resolved:raise ProbeError(f'{kind} path alias collision: {alias}')
else:alias.symlink_to(resolved,target_is_directory=True)
return os.path.relpath(alias,project).replace(os.sep,'/')
except ProbeError:
raise
except (OSError, TypeError, ValueError, RuntimeError) as error:
raise ProbeError(f'Cannot represent {kind} path {path!r}: {error}') from error
def _permanent_manifest() -> tuple[ET.ElementTree, ET.Element]:
path=ROOT/'app/CastleEngineManifest.xml'
try:
tree=ET.parse(path)
except (OSError, ET.ParseError) as error:
raise ProbeError(f'Cannot read permanent manifest: {error}') from error
root=tree.getroot()
if root.tag!='project' or root.attrib!=_PROJECT_ATTRIBUTES:
raise ProbeError('Invalid permanent application identity')
version=_one_child(root,'version')
if version.get('value')!='0.4.0' or version.get('code')!='30':
raise ProbeError('Invalid permanent application version')
options=_one_child(root,'compiler_options')
if [child.tag for child in options] != ['search_paths','defines','custom_options','library_paths']:
raise ProbeError('Invalid permanent compiler policy order')
search_paths,defines,custom_options,library_paths=(
_one_child(options,tag) for tag in ('search_paths','defines','custom_options','library_paths'))
if search_paths.findall('path') or library_paths.findall('path'):
raise ProbeError('Permanent manifest path slots must be empty')
if [item.text for item in defines.findall('define')]!=UPSTREAM_DEFINES:
raise ProbeError('Invalid permanent compiler defines')
if [item.text for item in custom_options.findall('option')]!=COMPILER_OPTIONS:
raise ProbeError('Invalid permanent compiler options')
return tree,root
def materialize_application_project(project: Path, source_resolution: dict) -> dict:
"""Create one fresh Castle project from the permanent manifest and RP01 paths."""
if project.exists():raise ProbeError('Target directory must be fresh: '+str(project))
try:
compiler_paths=tuple(source_resolution['compiler_source_paths'])
library_paths=tuple(source_resolution['library_paths'])
except (KeyError, TypeError) as error:
raise ProbeError(f'Invalid source resolution: {error}') from error
tree,root=_permanent_manifest()
project.mkdir(parents=True)
shutil.copytree(ROOT/'code',project/'code')
text,units=imports_source(profile)
(project/'code/ProbeImports.pas').write_text(text,encoding='utf-8')
(project/'code/ProbeBuild.pas').write_text(
'unit ProbeBuild;\n{$mode objfpc}{$H+}\ninterface\nconst\n'+
f" ProbeBuildId='{build_id}';\n ProbeProfile='{profile['id']}';\n ProbeCpu='{cpu}';\n"+
f" ProbeUpstreamCommit='{COMMIT}';\n ProbePointerBytes={CPUS[cpu][3]};\nimplementation\nend.\n")
data=project/'data';data.mkdir()
entries=assets or []
if entries and assets_root is None:raise ProbeError('Missing asset root')
for e in entries:
dest=data/'game'/e['path'];dest.parent.mkdir(parents=True,exist_ok=True)
shutil.copyfile(assets_root/e['path'],dest)
if sha256(dest)!=e['sha256']:raise ProbeError('Asset changed while staging: '+e['path'])
write_json(data/'assets-manifest.json',{'schema':'kam-probe-assets/1','files':entries})
name='kam_probe_'+profile['id']
root=ET.Element('project',name=name,caption='KaM Android: '+profile['id'],
author='KaM Mobile',qualified_name='org.kammobile.archprobe.'+profile['id'].replace('_',''),
game_units=profile.get('game_unit','GameInitialize'),compiler='fpc',screen_orientation='landscape',fullscreen_immersive='true')
ET.SubElement(root,'version',value=VERSION,code='30')
options=ET.SubElement(root,'compiler_options');paths=ET.SubElement(options,'search_paths')
if units:
if source is None or validated is None:raise ProbeError('KaM source resolution is required for '+profile['id'])
resolution=build_source_resolution(validated,source,project/'code')
compiler_source_paths=resolution['compiler_source_paths']
library_paths=resolution['library_paths']
else:
compiler_source_paths=(project/'code',)
library_paths=()
for p in compiler_source_paths:
if not p.is_dir():raise ProbeError('Source path missing: '+str(p))
ET.SubElement(paths,'path',value=os.path.relpath(p,project).replace(os.sep,'/'))
defines=ET.SubElement(options,'defines')
for define in (UPSTREAM_DEFINES if units else []):ET.SubElement(defines,'define').text=define
custom=ET.SubElement(options,'custom_options')
for flag in COMPILER_OPTIONS:ET.SubElement(custom,'option').text=flag
libraries=ET.SubElement(options,'library_paths')
for p in library_paths:
if not p.is_dir():raise ProbeError('Native library path missing: '+str(p))
ET.SubElement(libraries,'path',value=os.path.relpath(p,project).replace(os.sep,'/'))
# Android SDK versions deliberately inherited from the locked CGE image.
ET.indent(root)
ET.ElementTree(root).write(project/'CastleEngineManifest.xml',encoding='utf-8',xml_declaration=True)
description={'profile':profile['id'],'name':name,'package':'org.kammobile.archprobe.'+profile['id'].replace('_',''),
'cpu':cpu,'units':units,'game_unit':profile.get('game_unit','GameInitialize'),'source_paths':[str(p) for p in compiler_source_paths],
'compiler_options':COMPILER_OPTIONS,'upstream_defines':UPSTREAM_DEFINES if units else [],
'assets':entries,'shims':False}
write_json(project/'target.json',description)
return description
data_root=project/'data';data_root.mkdir()
options=_one_child(root,'compiler_options')
for tag,paths,kind in (('search_paths',compiler_paths,'Source'),('library_paths',library_paths,'Native library')):
slot=_one_child(options,tag)
for child in list(slot):slot.remove(child)
for path in paths:ET.SubElement(slot,'path',value=_manifest_path(path,project,kind))
ET.indent(tree)
tree.write(project/'CastleEngineManifest.xml',encoding='utf-8',xml_declaration=True)
version=_one_child(root,'version')
return {'project_dir':project,'data_root':data_root,'name':root.get('name'),'caption':root.get('caption'),
'package':root.get('qualified_name'),'version':version.get('value'),'version_code':int(version.get('code')),
'game_unit':root.get('game_units'),'compiler_source_paths':compiler_paths,'library_paths':library_paths}
+179
View File
@@ -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)
+137 -231
View File
@@ -1,25 +1,19 @@
"""One-command Android-only compile, full native link, APK and evidence pipeline."""
from __future__ import annotations
import argparse, json, os, re, shutil, subprocess, sys, uuid, zipfile
import argparse, json, os, re, shutil, subprocess, uuid, zipfile
from pathlib import Path
from common import ROOT, COMMIT, CPUS, ProbeError, stamp, sha256, write_json, run_command, first_errors, classify
from common import (ROOT, COMMIT, CPUS, ProbeError, stamp, sha256, write_json, run_command,
first_errors, CURRENT_OUTPUT_DIR, CURRENT_BUILD_METADATA,
CURRENT_PACKAGE_METADATA, CURRENT_APK, CURRENT_BUILD_SCHEMA,
CURRENT_PACKAGE_SCHEMA)
from source_fetch import fetch, verify
from source_audit import inventory
from source_resolution import load_source_contract, validate_source_contract
from generate import make_target, asset_inventory
from source_resolution import load_source_contract, validate_source_contract, build_source_resolution
from generate import materialize_application_project
from artifacts import inspect_elf, inspect_apk
from resource_staging import stage_runtime_resources
IMAGE_TAG='kambi/castle-engine-cloud-builds-tools:cge-unstable'
PROFILES=json.loads((ROOT/'config/profiles.json').read_text())
def selected_profiles(text:str)->list[dict]:
if text=='all':return PROFILES
names=[s.strip() for s in text.split(',') if s.strip()]
available={p['id'] for p in PROFILES}
if not names or set(names)-available:raise ProbeError('Unknown/empty profiles: '+text)
names=set(names)|{'platform'} # Always establish the same-toolchain platform baseline.
return [p for p in PROFILES if p['id'] in names]
def cge_command(cpu:str,action:str)->list[str]:
if cpu not in CPUS:raise ProbeError('Unsupported Android CPU')
if action not in ('compile','package'):raise ProbeError('Not an Android build action')
@@ -27,73 +21,6 @@ def cge_command(cpu:str,action:str)->list[str]:
if action=='package':cmd+=['--assume-compiled','--package-format=android-apk']
return cmd+[action]
def blocked_row(profile:dict,reason:str)->dict:
return {'id':profile['id'],'required':profile['required'],'purpose':profile['purpose'],
'compile_link':{'status':'BLOCKED','reason':reason},'package':{'status':'NOT_RUN'},
'runtime':{'status':'NOT_RUN'},'errors':[]}
def gate(rows:list[dict])->str:
by_id={r['id']:r for r in rows}
required=[p for p in PROFILES if p['required']]
if any(r['compile_link']['status']=='FAIL' or r['package']['status']=='FAIL' for r in rows if r['required']):
return 'NOT_BUILDABLE_AS_IS'
if any(r['compile_link']['status']=='BLOCKED' for r in rows if r['required']):return 'BLOCKED'
if any(p['id'] not in by_id for p in required):return 'PARTIAL_SELECTION'
for p in required:
r=by_id[p['id']]
if r['compile_link']['status']!='PASS':return 'BLOCKED'
if p['package'] and r['package']['status']!='PASS':return 'BLOCKED'
return 'ANDROID_BUILD_GATE_PASS'
def render_summary(result:dict,out:Path)->None:
title='# KaM r16020 — Android architecture probe\n\n'
title+=f"Build ID: `{result['run_id']}` · CPU: `{result['cpu']}` · Gate: **{result['gate']}**\n\n"
title+='Нет desktop target. Успех означает сборку выбранных Android-корней, не игровую совместимость.\n\n'
title+='| Проба | Compile + native link | APK | Ошибка / следующий шаг |\n|---|---|---|---|\n'
for r in result['profiles']:
error=(r.get('errors') or [r['compile_link'].get('reason','')])[0]
error=error.replace('|','/').replace('\n',' ')[:260]
title+=f"| {r['id']} | {r['compile_link']['status']} | {r['package']['status']} | {error} |\n"
title+='\n## Что установлено\n\n'
title+='`PASS` compile_link = FPC завершился успешно, найден свежий ELF нужного ABI, проверены корневые PPU. Линковка не отключена; требуются определённые символы. '
title+='`PASS` APK = упаковка завершилась, проверены ZIP/DEX, правильный ABI, совпадение native library и DT_NEEDED. '
title+='Подпись проверяется отдельно, если доступен apksigner. `runtime=NOT_RUN` до реального запуска на Android.\n\n'
title+='`NOT_BUILDABLE_AS_IS` — найдено препятствие в текущем коде/окружении, а не доказательство, что core надо переписать. '
title+='Ошибки legacy_render/legacy_audio диагностические и не входят в обязательный gate, но их транзитивное попадание в core всё равно видно.\n\n'
title+='## Как принимать решение\n\n'
title+='| Свидетельство | Разумное решение |\n|---|---|\n'
title+='| Корень собирается с оригинальными исходниками | Кандидат на reuse; поведение ещё проверяется отдельно |\n'
title+='| Активная зависимость Windows/LCL/desktop GL | Исследовать минимальное отделение платформенной части |\n'
title+='| Отсутствует source unit / package | Проверить комплектность исходников, условные defines и пакет для Android |\n'
title+='| Undefined reference / отсутствует native library | Сборка/подключение Android-библиотеки либо адаптер Castle |\n'
title+='| Один и тот же блокер у многих модулей | Исправить общий корень и повторить пробы; не считать это множеством отдельных переписываний |\n'
title+='| Срабатывает exception при запуске | Снять logcat; проверить unit initialization/lifecycle/доступ к данным |\n'
title+='\nНи один автоматический результат здесь не выносит решение «написать подсистему с нуля».\n'
(out/'SUMMARY.md').write_text(title,encoding='utf-8')
def finalize(result:dict,out:Path)->int:
result['gate']=gate(result['profiles'])
write_json(out/'result.json',result);render_summary(result,out)
write_json(ROOT/'reports/LATEST.json',{'run_id':result['run_id'],'directory':str(out.relative_to(ROOT)),
'gate':result['gate'],'summary':str((out/'SUMMARY.md').relative_to(ROOT))})
shutil.copyfile(out/'SUMMARY.md',ROOT/'reports/LATEST-SUMMARY.md')
# Evidence intentionally excludes APK/.so, user game assets, caches and source trees.
with zipfile.ZipFile(out/'evidence.zip','w',zipfile.ZIP_DEFLATED) as z:
for p in sorted(out.rglob('*')):
if p.is_file() and p.suffix in ('.json','.log','.md','.txt','.xml','.pas'):
z.write(p,p.relative_to(out))
print('\n'+result['gate'])
print('Report: '+str(out/'SUMMARY.md'))
print('Evidence (no game assets): '+str(out/'evidence.zip'))
return 0 if result['gate']=='ANDROID_BUILD_GATE_PASS' else 1 if result['gate']=='NOT_BUILDABLE_AS_IS' else 2
def new_result(cpu:str,profiles:list[dict],reason:str='Not started')->tuple[dict,Path]:
rid=stamp()+'-'+uuid.uuid4().hex[:8]
out=ROOT/'reports/runs'/rid;out.mkdir(parents=True)
return ({'schema':'kam-android-build/1','run_id':rid,'cpu':cpu,'commit':COMMIT,
'desktop_target':False,'shims':False,'runtime':'NOT_RUN',
'profiles':[blocked_row(p,reason) for p in profiles]},out)
def tool_versions(out:Path)->dict:
commands={'castle':['castle-engine','--version'],'fpc':['fpc','-iV'],'java':['java','-version'],
'adb':['adb','version']}
@@ -122,175 +49,154 @@ def signature_tool()->str|None:
if os.environ.get(key):paths+=list(Path(os.environ[key]).glob('build-tools/*/apksigner'))
return str(sorted(paths)[-1]) if paths else None
def build_local(a:argparse.Namespace,profiles:list[dict])->int:
result,out=new_result(a.cpu,profiles)
result['toolchain']=tool_versions(out)
if not shutil.which('castle-engine'):
for r in result['profiles']:r['compile_link']['reason']='castle-engine not installed; no Android compilation attempted'
return finalize(result,out)
source=ROOT/'vendor/kam_remake';audit=None;validated=None;assets=[]
need_source=any(p['units'] for p in profiles)
try:
contract=load_source_contract()
assets=asset_inventory(Path(a.assets),a.asset_path) if a.assets else []
result['asset_count']=len(assets)
if need_source:
if (source/'SOURCE_RECEIPT.json').is_file():receipt=verify(source)
elif a.no_fetch:raise ProbeError('Pinned source tree absent and --no-fetch requested')
else:receipt=fetch(source)
write_json(out/'source-receipt.json',receipt)
audit=inventory(source);write_json(out/'source-inventory.json',audit)
validated=validate_source_contract(contract,source,receipt,audit)
except (ProbeError,OSError,ValueError,KeyError) as e:
result['source_or_inputs_error']=str(e)
return finalize(result,out)
work=ROOT/'build'/result['run_id'];work.mkdir(parents=True)
dist=ROOT/'dist'/result['run_id'];dist.mkdir(parents=True)
package_index=[]
for idx,profile in enumerate(profiles):
row=result['profiles'][idx];ident=profile['id'];project=work/ident
print(f"[{idx+1}/{len(profiles)}] Android {a.cpu}: {ident}",flush=True)
try:
if profile['units']:
missing=[u for u in profile['units'] if u.casefold() not in audit['units']]
if missing:raise ProbeError('Missing required original source units: '+', '.join(missing))
# Resources only enter APKs when explicitly requested. The platform baseline remains independent.
pack=profile['package'] or a.package_all
use_assets=bool(a.assets) and pack and ident=='platform'
description=make_target(project,profile,a.cpu,result['run_id'],source if profile['units'] else None,
validated if profile['units'] else None,
Path(a.assets) if use_assets else None,assets if use_assets else [])
row['target']=description
evidence=out/ident;evidence.mkdir()
for file in ('CastleEngineManifest.xml','target.json'):
shutil.copyfile(project/file,evidence/file)
shutil.copyfile(project/'code/ProbeImports.pas',evidence/'ProbeImports.pas')
row['compile_link']=run_command(cge_command(a.cpu,'compile'),project,evidence/'compile-link.log',a.timeout)
text=(evidence/'compile-link.log').read_text(errors='replace')
if row['compile_link']['status']!='PASS':
row['errors']=first_errors(text) or [text[-1500:]];row['triage']=classify(text);continue
name='lib'+description['name']+'.so'
outputs=list(project.rglob('lib'+description['name']+'*.so'))
if not outputs:raise ProbeError('Compiler exited 0 but no expected native library matching: '+name)
hashes={sha256(p) for p in outputs}
if len(hashes)!=1:raise ProbeError('Ambiguous different native outputs')
library=outputs[0];name=library.name;elf=inspect_elf(library.read_bytes(),a.cpu)
row['native_library']={'file':str(library.relative_to(ROOT)),'sha256':sha256(library),'elf':elf}
ppu_files={p.stem.casefold():p for p in project.rglob('*.ppu')}
evidence_units=[]
for unit in description['units']:
p=ppu_files.get(unit.casefold())
if p is None or p.read_bytes()[:3]!=b'PPU':
raise ProbeError('No fresh Pascal unit artifact found for '+unit)
evidence_units.append({'unit':unit,'ppu':str(p.relative_to(ROOT)),'sha256':sha256(p),
'source_candidates':audit['units'].get(unit.casefold(),[])})
row['compiled_roots']=evidence_units
if not pack:continue
row['package']=run_command(cge_command(a.cpu,'package'),project,evidence/'package.log',a.timeout)
if row['package']['status']!='PASS':
row['errors']+=first_errors((evidence/'package.log').read_text(errors='replace'));continue
candidates=list(project.rglob('*.apk'))
if not candidates:raise ProbeError('Packaging exited 0 but no new APK')
apk_hashes={sha256(p) for p in candidates}
if len(apk_hashes)!=1:raise ProbeError('Multiple different APK outputs; refusing arbitrary selection')
apk=candidates[0]
apk_info=inspect_apk(apk,a.cpu,name,row['native_library']['sha256'],library.read_bytes())
signer=signature_tool()
if signer:
sign=run_command([signer,'verify','--verbose',str(apk)],project,evidence/'signature.log',120)
row['signature']=sign
if sign['status']!='PASS':row['package']['status']='FAIL';continue
else:row['signature']={'status':'NOT_RUN','reason':'apksigner not found'}
final=dist/(description['name']+'-'+a.cpu+'-debug.apk');shutil.copy2(apk,final)
row['apk']={'file':str(final.relative_to(ROOT)),**apk_info}
package_index.append({'profile':ident,'package':description['package'],'file':str(final.relative_to(ROOT)),
'sha256':sha256(final),'cpu':a.cpu,'build_id':result['run_id'],
'native_library_path_in_apk':apk_info['main_library_path']})
except (ProbeError,OSError,ValueError,KeyError,zipfile.BadZipFile) as e:
row['errors'].append(str(e))
# A supposedly successful compiler/packager without valid output is a real test failure.
if row['package']['status']=='PASS':row['package']['status']='FAIL'
elif row['compile_link']['status']=='PASS':row['compile_link']['status']='FAIL'
else:row['compile_link']={'status':'BLOCKED','reason':str(e)}
finally:
write_json(out/'result-in-progress.json',result)
if need_source:
try:verify(source)
except ProbeError as e:
result['source_modified_during_build']=str(e)
for r in result['profiles']:
if r['id']!='platform':r['compile_link']['status']='FAIL';r['errors'].append(str(e))
write_json(dist/'apks.json',{'build_id':result['run_id'],'packages':package_index})
write_json(ROOT/'dist/LATEST.json',{'build_id':result['run_id'],'directory':str(dist.relative_to(ROOT)),
'packages':package_index})
return finalize(result,out)
def _application(descriptor:dict|None)->dict|None:
if descriptor is None:return None
return {key:descriptor[key] for key in ('name','caption','package','version','version_code','game_unit')}
def docker_build(a:argparse.Namespace,profiles:list[dict])->int:
result,out=new_result(a.cpu,profiles,'Docker bootstrap not completed')
if not shutil.which('docker'):
for r in result['profiles']:r['compile_link']['reason']='Docker not installed. Use --local only with a configured CGE/FPC Android toolchain.'
return finalize(result,out)
def _record(build_id:str, cpu:str, status:str, application:dict|None=None,
resource_count:int|None=None, compile:dict|None=None, package:dict|None=None,
package_metadata:str|None=None, error:str|None=None)->dict:
return {'schema':CURRENT_BUILD_SCHEMA,'build_id':build_id,'status':status,'cpu':cpu,'abi':CPUS[cpu][0],
'application':application,'upstream_commit':COMMIT,'resource_count':resource_count,
'compile':compile or {'status':'NOT_RUN','exit_code':None},
'package':package or {'status':'NOT_RUN','exit_code':None},
'package_metadata':package_metadata,'error':error}
def _finish(record:dict)->int:
if record['status']!='PASS':
CURRENT_OUTPUT_DIR.mkdir(parents=True,exist_ok=True)
for path in (CURRENT_PACKAGE_METADATA,CURRENT_APK):
path.unlink(missing_ok=True)
record['package_metadata']=None
write_json(CURRENT_BUILD_METADATA,record)
return {'PASS':0,'FAIL':1,'BLOCKED':2}[record['status']]
def _valid_docker_handoff(record:object,build_id:str,code:int)->bool:
fields={'schema','build_id','status','cpu','abi','application','upstream_commit','resource_count','compile','package','package_metadata','error'}
statuses={'PASS':0,'FAIL':1,'BLOCKED':2};stage_statuses=set(statuses)|{'NOT_RUN'}
if not isinstance(record,dict) or set(record)!=fields or record.get('schema')!=CURRENT_BUILD_SCHEMA or record.get('build_id')!=build_id or statuses.get(record.get('status'))!=code:return False
cpu=record.get('cpu')
if cpu not in CPUS or record.get('abi')!=CPUS[cpu][0] or record.get('upstream_commit')!=COMMIT:return False
if record['resource_count'] is not None and (type(record['resource_count']) is not int or record['resource_count']<0):return False
app=record['application']
if app is not None and (not isinstance(app,dict) or set(app)!={'name','caption','package','version','version_code','game_unit'} or any(not isinstance(app[key],str) or not app[key] for key in ('name','caption','package','version','game_unit')) or type(app['version_code']) is not int):return False
for stage in ('compile','package'):
value=record[stage]
if not isinstance(value,dict) or set(value)!={'status','exit_code'} or value.get('status') not in stage_statuses:return False
if (value['status']=='NOT_RUN') != (value['exit_code'] is None) or value['exit_code'] is not None and type(value['exit_code']) is not int:return False
if record['status']=='PASS':
return app is not None and record['resource_count'] is not None and record['compile']['status']==record['package']['status']=='PASS' and record['package_metadata']=='out/current/package.json' and record['error'] is None
return record['package_metadata'] is None and isinstance(record['error'],str) and bool(record['error'])
def _error(log:Path)->str:
text=log.read_text(errors='replace') if log.exists() else ''
return (first_errors(text) or [text[-1500:] or 'Build command failed'])[0]
def _prepare(build_id:str, diagnostics:bool=True)->tuple[Path,Path]:
work=ROOT/'build'/build_id; logs=work/'logs'
logs.mkdir(parents=True,exist_ok=True)
if diagnostics:tool_versions(logs)
return work,logs
def build_local(a:argparse.Namespace,build_id:str)->int:
work,logs=_prepare(build_id, not bool(os.environ.get('KAM_BUILD_ID')))
app=None; resources=None; compile={'status':'NOT_RUN','exit_code':None}; package={'status':'NOT_RUN','exit_code':None}
try:
vendor_root=ROOT/'vendor/kam_remake'
receipt=verify(vendor_root) if (vendor_root/'SOURCE_RECEIPT.json').is_file() else (fetch(vendor_root) if not a.no_fetch else (_ for _ in ()).throw(ProbeError('Pinned source tree absent and --no-fetch requested')))
audited=inventory(vendor_root)
validated=validate_source_contract(load_source_contract(),vendor_root,receipt,audited)
resolution=build_source_resolution(validated,vendor_root,ROOT/'code')
descriptor=materialize_application_project(work/'application',resolution);app=_application(descriptor)
resources=stage_runtime_resources(descriptor['data_root'])
except (ProbeError,OSError,ValueError,KeyError) as error:
return _finish(_record(build_id,a.cpu,'BLOCKED',app,None,compile,package,error=str(error)))
if not shutil.which('castle-engine'):
return _finish(_record(build_id,a.cpu,'BLOCKED',app,len(resources),compile,package,error='castle-engine not installed'))
project=descriptor['project_dir']; compile_result=run_command(cge_command(a.cpu,'compile'),project,logs/'compile.log',a.timeout)
compile={'status':compile_result['status'],'exit_code':compile_result['exit_code']}
if compile['status']!='PASS':
return _finish(_record(build_id,a.cpu,compile['status'],app,len(resources),compile,package,error=_error(logs/'compile.log')))
try:
candidates=list(project.rglob(f"lib{descriptor['name']}_android_{a.cpu}.so"))
if len(candidates)!=1:raise ProbeError('Compiler exited 0 but expected exactly one native library')
library=candidates[0]; inspect_elf(library.read_bytes(),a.cpu)
except (ProbeError,OSError,ValueError) as error:
compile['status']='FAIL'
return _finish(_record(build_id,a.cpu,'FAIL',app,len(resources),compile,package,error=str(error)))
package_result=run_command(cge_command(a.cpu,'package'),project,logs/'package.log',a.timeout)
package={'status':package_result['status'],'exit_code':package_result['exit_code']}
if package['status']!='PASS':
return _finish(_record(build_id,a.cpu,package['status'],app,len(resources),compile,package,error=_error(logs/'package.log')))
try:
candidates=list(project.rglob('*.apk'))
if not candidates:raise ProbeError('Packaging exited 0 but no APK')
if len({sha256(path) for path in candidates})!=1:raise ProbeError('Multiple different APK outputs')
apk=candidates[0]; apk_info=inspect_apk(apk,a.cpu,library.name,sha256(library),library.read_bytes())
signer=signature_tool()
if signer:
signed=run_command([signer,'verify','--verbose',str(apk)],project,logs/'signature.log',a.timeout)
if signed['status']!='PASS':raise ProbeError(_error(logs/'signature.log'))
except (ProbeError,OSError,ValueError,KeyError,zipfile.BadZipFile) as error:
package['status']='FAIL'
return _finish(_record(build_id,a.cpu,'FAIL',app,len(resources),compile,package,error=str(error)))
try:
CURRENT_OUTPUT_DIR.mkdir(parents=True,exist_ok=True)
temporary=CURRENT_APK.with_name(CURRENT_APK.name+'.tmp')
shutil.copyfile(apk,temporary);os.replace(temporary,CURRENT_APK)
package_data={'schema':CURRENT_PACKAGE_SCHEMA,'build_id':build_id,
'application':{key:app[key] for key in ('name','package','version','version_code')},
'cpu':a.cpu,'abi':CPUS[a.cpu][0],
'apk':{'file':'out/current/kam-mobile.apk','sha256':sha256(CURRENT_APK),'size':CURRENT_APK.stat().st_size,
'native_library_path_in_apk':apk_info['main_library_path']}}
write_json(CURRENT_PACKAGE_METADATA,package_data)
return _finish(_record(build_id,a.cpu,'PASS',app,len(resources),compile,package,
'out/current/package.json',None))
except (OSError,ValueError,KeyError) as error:
package['status']='FAIL'
return _finish(_record(build_id,a.cpu,'FAIL',app,len(resources),compile,package,error=str(error)))
def docker_build(a:argparse.Namespace,build_id:str)->int:
compile={'status':'NOT_RUN','exit_code':None};package={'status':'NOT_RUN','exit_code':None}
try:
work,logs=_prepare(build_id)
if not shutil.which('docker'):raise ProbeError('Docker not installed')
lock=ROOT/'config/toolchain.lock.json'
if lock.is_file():
image=json.loads(lock.read_text())['image']
if not re.fullmatch(r'[\w./-]+@sha256:[a-f0-9]{64}',image):raise ProbeError('Invalid toolchain digest lock')
p=subprocess.run(['docker','image','inspect',image],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
if p.returncode:
r=run_command(['docker','pull','--platform=linux/amd64',image],ROOT,out/'docker-pull.log',a.timeout)
if r['status']!='PASS':raise ProbeError('Cannot obtain locked Docker image; see docker-pull.log')
if subprocess.run(['docker','image','inspect',image],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode:
if run_command(['docker','pull','--platform=linux/amd64',image],ROOT,logs/'docker-pull.log',a.timeout)['status']!='PASS':raise ProbeError('Cannot obtain locked Docker image')
else:
r=run_command(['docker','pull','--platform=linux/amd64',IMAGE_TAG],ROOT,out/'docker-pull.log',a.timeout)
if r['status']!='PASS':raise ProbeError('Docker image pull failed; see docker-pull.log')
raw=subprocess.check_output(['docker','image','inspect',IMAGE_TAG],text=True)
info=json.loads(raw)[0];digests=info.get('RepoDigests',[])
image=next((d for d in digests if 'castle-engine-cloud-builds-tools@sha256:' in d),None)
if run_command(['docker','pull','--platform=linux/amd64',IMAGE_TAG],ROOT,logs/'docker-pull.log',a.timeout)['status']!='PASS':raise ProbeError('Docker image pull failed')
info=json.loads(subprocess.check_output(['docker','image','inspect',IMAGE_TAG],text=True))[0]
image=next((value for value in info.get('RepoDigests',[]) if 'castle-engine-cloud-builds-tools@sha256:' in value),None)
if image is None:raise ProbeError('Image has no immutable RepoDigest')
write_json(lock,{'image':image,'image_id':info['Id'],'resolved_from':IMAGE_TAG,'locked_at':stamp(),
'note':'Subsequent runs use this digest, never silently advance the tag.'})
cache=ROOT/'.cache/home';cache.mkdir(parents=True,exist_ok=True)
cmd=['docker','run','--rm','--platform=linux/amd64','--user','0:0',
'-e','HOME=/work/.cache/home','-v',str(ROOT)+':/work','-w','/work']
write_json(lock,{'image':image,'image_id':info['Id'],'resolved_from':IMAGE_TAG,'locked_at':stamp(),'note':'Subsequent runs use this digest, never silently advance the tag.'})
cmd=['docker','run','--rm','--platform=linux/amd64','--user','0:0','-e','HOME=/work/.cache/home',
'-e','KAM_BUILD_ID='+build_id,'-v',str(ROOT)+':/work','-w','/work']
if os.environ.get('GITHUB_TOKEN'):cmd+=['-e','GITHUB_TOKEN']
if a.assets:
resource=Path(a.assets).resolve()
if not resource.is_dir():raise ProbeError('Asset directory not found: '+str(resource))
cmd+=['-v',str(resource)+':/probe-assets:ro']
cmd+=['--entrypoint','python3',image,'tools/runner.py','--local','--cpu',a.cpu,
'--profiles',a.profiles,'--timeout',str(a.timeout)]
cmd+=['--entrypoint','python3',image,'tools/runner.py','--local','--cpu',a.cpu,'--timeout',str(a.timeout)]
if a.no_fetch:cmd+=['--no-fetch']
if a.package_all:cmd+=['--package-all']
if a.assets:cmd+=['--assets','/probe-assets']
for rel in a.asset_path or []:cmd+=['--asset-path',rel]
write_json(out/'docker-command.json',{'command':cmd,'image':image})
# Stream the user's real build directly. No background processes/promises.
latest=ROOT/'reports/LATEST.json'
before=latest.read_bytes() if latest.exists() else None
code=subprocess.call(cmd,cwd=ROOT)
after=latest.read_bytes() if latest.exists() else None
if code in (0,1,2) and after is not None and after!=before:
fresh=json.loads(after)
if (ROOT/fresh['directory']/'result.json').is_file():return code
raise ProbeError('Container could not finish the report; Docker exit code '+str(code))
except (ProbeError,OSError,ValueError,KeyError,subprocess.CalledProcessError) as e:
for r in result['profiles']:r['compile_link']['reason']=str(e)
return finalize(result,out)
if code not in (0,1,2):raise ProbeError('Docker exit code '+str(code))
record=json.loads(CURRENT_BUILD_METADATA.read_text())
if not _valid_docker_handoff(record,build_id,code):raise ProbeError('Container current build metadata is missing, stale, or inconsistent')
return code
except (ProbeError,OSError,ValueError,KeyError,subprocess.CalledProcessError) as error:
return _finish(_record(build_id,a.cpu,'BLOCKED',None,None,compile,package,error=str(error)))
def main()->int:
ap=argparse.ArgumentParser(description=__doc__)
ap.add_argument('--local',action='store_true',help='Use installed toolchain; still compile Android only')
ap.add_argument('--cpu',choices=CPUS,default='aarch64')
ap.add_argument('--profiles',default='all',help='all or comma-separated IDs from config/profiles.json')
ap.add_argument('--no-fetch',action='store_true',help='Require previously fetched/verified source tree')
ap.add_argument('--assets',help='Optional private KaM resource root; no resources are downloaded')
ap.add_argument('--asset-path',action='append',help='Relative path under --assets to stage. Repeat for a small selected fixture set.')
ap.add_argument('--package-all',action='store_true',help='Package every successfully linked probe for device startup checks')
ap.add_argument('--timeout',type=int,default=1800,help='Per-stage safety timeout in seconds')
a=ap.parse_args()
if a.asset_path and not a.assets:ap.error('--asset-path requires --assets')
if a.timeout<1:ap.error('--timeout must be positive')
try:profiles=selected_profiles(a.profiles)
except ProbeError as e:ap.error(str(e))
return build_local(a,profiles) if a.local else docker_build(a,profiles)
inherited=os.environ.get('KAM_BUILD_ID')
if inherited and not re.fullmatch(r'\d{8}T\d{6}Z-[0-9a-f]{8}',inherited):ap.error('Invalid KAM_BUILD_ID')
build_id=inherited or stamp()+'-'+uuid.uuid4().hex[:8]
return build_local(a,build_id) if a.local else docker_build(a,build_id)
if __name__=='__main__':raise SystemExit(main())