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

+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())