From 19ee83798600c50c9df2fe2215aaf2f6de36f9ca Mon Sep 17 00:00:00 2001 From: admin_d Date: Mon, 14 Sep 2026 23:49:46 +0500 Subject: [PATCH] Support empty resources and project Android services --- README.md | 2 ++ config/runtime-resources.json | 5 +++- tools/generate.py | 43 ++++++++++++++++++++++++++++++++++- tools/resource_staging.py | 4 ++-- tools/runner.py | 29 +++++++++++++++++++++-- 5 files changed, 77 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b9ef421..1d4253a 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ Private runtime-resource contents live under `private-assets/`. `config/runtime- The permanent application definition is `app/CastleEngineManifest.xml`: `kam_mobile`, `org.kammobile.kamremake`, and `GameInitialize`. +Project-owned Android service templates, when needed, belong in `app/android_services//`. They are copied into the transient application and exposed to Castle only by the normal disposable-container build; use `./build-android.sh`, not `--local`, when they are present. + ## Optional device helper After a successful current build, optionally run: diff --git a/config/runtime-resources.json b/config/runtime-resources.json index 0967ef4..67bd0c9 100644 --- a/config/runtime-resources.json +++ b/config/runtime-resources.json @@ -1 +1,4 @@ -{} +{ + "schema": "kam-runtime-resources/1", + "entries": [] +} diff --git a/tools/generate.py b/tools/generate.py index d8a894b..a3d26ee 100755 --- a/tools/generate.py +++ b/tools/generate.py @@ -1,11 +1,12 @@ """Generate isolated Android CGE targets. No KaM stubs or rewritten game loaders.""" from __future__ import annotations -import hashlib, os, xml.etree.ElementTree as ET +import hashlib, os, re, shutil, xml.etree.ElementTree as ET from pathlib import Path 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'] +_ANDROID_SERVICE_NAME=re.compile(r'[A-Za-z][A-Za-z0-9_]*\Z') _PROJECT_ATTRIBUTES={ 'name':'kam_mobile', 'caption':'KaM Remake Mobile', 'author':'KaM Mobile', @@ -60,6 +61,33 @@ def _permanent_manifest() -> tuple[ET.ElementTree, ET.Element]: raise ProbeError('Invalid permanent compiler options') return tree,root +def android_service_roots(root: Path) -> tuple[Path,...]: + """Return validated immediate project-owned Android service directories.""" + try: + if not root.exists(): return () + if root.is_symlink() or not root.is_dir(): + raise ProbeError('Android services root must be a real directory: '+str(root)) + resolved_root=root.resolve() + services=[] + for service in sorted(root.iterdir(),key=lambda path:path.name): + if service.is_symlink(): + raise ProbeError('Android service must not be a symlink: '+str(service)) + if not service.is_dir(): continue + if not _ANDROID_SERVICE_NAME.fullmatch(service.name): + raise ProbeError('Unsafe Android service name: '+repr(service.name)) + resolved_service=service.resolve() + if not resolved_service.is_relative_to(resolved_root): + raise ProbeError('Android service escapes its root: '+str(service)) + for path in service.rglob('*'): + if path.is_symlink(): + raise ProbeError('Android service contains a symlink: '+str(path)) + services.append(service) + return tuple(services) + except ProbeError: + raise + except (OSError, ValueError, RuntimeError) as error: + raise ProbeError(f'Cannot inspect Android services: {error}') from error + 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)) @@ -78,6 +106,19 @@ def materialize_application_project(project: Path, source_resolution: dict) -> d 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) + source_services=ROOT/'app/android_services' + services=android_service_roots(source_services) + if services: + destination_root=project/'android_services' + destination_root.mkdir() + for service in services: + destination=destination_root/service.name + if destination.exists() or destination.is_symlink(): + raise ProbeError('Android service destination already exists: '+str(destination)) + try: + shutil.copytree(service,destination) + except OSError as error: + raise ProbeError(f'Cannot copy Android service {service.name}: {error}') from error 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')), diff --git a/tools/resource_staging.py b/tools/resource_staging.py index 81c88c8..bd35984 100644 --- a/tools/resource_staging.py +++ b/tools/resource_staging.py @@ -27,8 +27,8 @@ def _load_runtime_resource_manifest() -> tuple[dict[str, str], ...]: 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') + if not isinstance(value['entries'], list): + raise ProbeError('Runtime-resource entries must be an array') seen_paths: set[str] = set() entries = [] diff --git a/tools/runner.py b/tools/runner.py index e15dcb0..7e377bd 100755 --- a/tools/runner.py +++ b/tools/runner.py @@ -9,7 +9,7 @@ from common import (ROOT, COMMIT, CPUS, ProbeError, stamp, sha256, write_json, r from source_fetch import fetch, verify from source_audit import inventory from source_resolution import load_source_contract, validate_source_contract, build_source_resolution -from generate import materialize_application_project +from generate import android_service_roots, materialize_application_project from artifacts import inspect_elf, inspect_apk from resource_staging import stage_runtime_resources @@ -108,6 +108,30 @@ def _prepare(build_id:str, diagnostics:bool=True)->tuple[Path,Path]: if diagnostics:tool_versions(logs) return work,logs +def stage_android_services(project:Path, logs:Path)->None: + services=android_service_roots(project/'android_services') + if not services:return + if os.environ.get('KAM_CONTAINER_BUILD')!='1': + raise ProbeError('Project-owned Android services require the normal disposable-container build: ./build-android.sh') + castle_path=os.environ.get('CASTLE_ENGINE_PATH') + if not castle_path: + raise ProbeError('Cannot stage project-owned Android services: CASTLE_ENGINE_PATH is not set') + service_root=Path(castle_path)/'tools/build-tool/data/android/services' + if not service_root.is_dir(): + raise ProbeError('Cannot stage project-owned Android services: Castle service root is missing: '+str(service_root)) + try: + with (logs/'android-services.log').open('w',encoding='utf-8') as log: + for service in services: + destination=service_root/service.name + if destination.exists() or destination.is_symlink(): + raise ProbeError('Project-owned Android service collides with Castle built-in service: '+service.name) + shutil.copytree(service,destination) + log.write(f'project service: {service.name}\nsource: {service}\ncastle destination: {destination}\nstatus: staged\n') + except ProbeError: + raise + except OSError as error: + raise ProbeError(f'Cannot stage project-owned Android services: {error}') from error + 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} @@ -119,6 +143,7 @@ def build_local(a:argparse.Namespace,build_id:str)->int: 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']) + stage_android_services(descriptor['project_dir'],logs) 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'): @@ -183,7 +208,7 @@ def docker_build(a:argparse.Namespace,build_id:str)->int: 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.'}) 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'] + '-e','KAM_BUILD_ID='+build_id,'-e','KAM_CONTAINER_BUILD=1','-v',str(ROOT)+':/work','-w','/work'] if os.environ.get('GITHUB_TOKEN'):cmd+=['-e','GITHUB_TOKEN'] cmd+=['--entrypoint','python3',image,'tools/runner.py','--local','--cpu',a.cpu,'--timeout',str(a.timeout)] if a.no_fetch:cmd+=['--no-fetch']