2026-09-11 18:18:40 +05:00
|
|
|
"""Generate isolated Android CGE targets. No KaM stubs or rewritten game loaders."""
|
|
|
|
|
from __future__ import annotations
|
2026-09-14 23:49:46 +05:00
|
|
|
import hashlib, os, re, shutil, xml.etree.ElementTree as ET
|
2026-09-11 18:18:40 +05:00
|
|
|
from pathlib import Path
|
2026-09-14 20:47:20 +05:00
|
|
|
from common import ROOT, ProbeError
|
2026-09-11 18:18:40 +05:00
|
|
|
|
|
|
|
|
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']
|
2026-09-14 23:49:46 +05:00
|
|
|
_ANDROID_SERVICE_NAME=re.compile(r'[A-Za-z][A-Za-z0-9_]*\Z')
|
2026-09-11 18:18:40 +05:00
|
|
|
|
2026-09-14 20:47:20 +05:00
|
|
|
_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',
|
|
|
|
|
}
|
2026-09-11 18:18:40 +05:00
|
|
|
|
2026-09-14 20:47:20 +05:00
|
|
|
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]
|
2026-09-11 18:18:40 +05:00
|
|
|
|
2026-09-14 20:47:20 +05:00
|
|
|
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
|
|
|
|
|
|
2026-09-14 23:49:46 +05:00
|
|
|
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
|
|
|
|
|
|
2026-09-14 20:47:20 +05:00
|
|
|
def materialize_application_project(project: Path, source_resolution: dict) -> dict:
|
|
|
|
|
"""Create one fresh Castle project from the permanent manifest and RP01 paths."""
|
2026-09-11 18:18:40 +05:00
|
|
|
if project.exists():raise ProbeError('Target directory must be fresh: '+str(project))
|
2026-09-14 20:47:20 +05:00
|
|
|
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()
|
2026-09-11 18:18:40 +05:00
|
|
|
project.mkdir(parents=True)
|
2026-09-14 20:47:20 +05:00
|
|
|
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)
|
2026-09-14 23:49:46 +05:00
|
|
|
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
|
2026-09-14 20:47:20 +05:00
|
|
|
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}
|