Combine Workshop addon and fix prison startup
Merge owned systems into one addon while preserving resource identities. Gate player admission on the replicated prison scene, restore the original framed door, and hold enemy AI until escape. Correct scenario branding and author metadata, include publishing assets, and retain regression and native verification evidence.
This commit is contained in:
+7
-2
@@ -3,15 +3,20 @@
|
||||
from pathlib import Path
|
||||
import json,re,subprocess,sys
|
||||
from external_addons import policy, ALLOWED, direct_for, resource_allowed
|
||||
from prefab_identity import check_prefabs
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
BASE='58D0FB3206B6F859'
|
||||
def check():
|
||||
print(f'PASS: {check_prefabs((ROOT/"addons").rglob("*.et"))} prefabs have unique saved root entity IDs')
|
||||
subprocess.run([sys.executable,str(ROOT/'tests/test_prefab_identity.py')],check=True)
|
||||
subprocess.run([sys.executable,str(ROOT/'tests/test_spawn_scene.py')],check=True)
|
||||
subprocess.run([sys.executable,str(ROOT/'tests/test_prison_ai.py')],check=True)
|
||||
ids={};graph={};resources={};paths={};owners={}
|
||||
for p in sorted((ROOT/'addons').glob('*/addon.gproj')):
|
||||
text=p.read_text();g=re.search(r'GUID "([A-F0-9]{16})"',text)[1]
|
||||
assert g not in ids,f'Duplicate addon GUID: {p}'
|
||||
ids[g]=p.parent.name;graph[g]=re.findall(r'"([A-F0-9]{16})"',re.search(r'\bDependencies\s*\{([^{}]*)\}',text)[1])
|
||||
assert len(ids)==5,'Expected five addon projects'
|
||||
assert ids == {'2528466DE220D91C': 'core'}, 'Expected one combined addon project'
|
||||
external=policy(ROOT)
|
||||
for guid,name in ids.items():
|
||||
assert (set(graph[guid]) & set(ALLOWED)) == direct_for(name), f'Unreviewed direct external dependency scope: {name}'
|
||||
@@ -37,7 +42,7 @@ def check():
|
||||
if dep!=BASE:result.update(visible(dep))
|
||||
return result
|
||||
visibility={ids[g]:visible(g) for g in ids}
|
||||
assert not (visibility['core'] & set(ALLOWED)), 'Core must remain vanilla-only'
|
||||
assert set(ALLOWED) <= visibility['core'], 'Combined addon requires the authorized external closure'
|
||||
vanilla=json.loads((ROOT/'docs/vanilla-resources.json').read_text())
|
||||
for p in (ROOT/'addons').rglob('*'):
|
||||
if not p.is_file() or p.suffix not in ['.c','.et','.conf','.ent','.layer','.layout','.emat']:continue
|
||||
|
||||
@@ -50,15 +50,10 @@ def sandbox(runtime, source, run):
|
||||
|
||||
PROJECTS = {
|
||||
'core': ('2528466DE220D91C', '{AD62322D5213C440}Missions/ERR_Everon_Base.conf'),
|
||||
'chat': ('FA804F411BDDF2C6', '{C1E20667DCE324ED}Missions/ERR_Everon.conf'),
|
||||
}
|
||||
ADDON_GUIDS = {
|
||||
'core': PROJECTS['core'][0],
|
||||
'ai': '5192138CB9D5B911',
|
||||
'intel': 'D31039C7C53E348A',
|
||||
'map': '9AAEE9041D79809E',
|
||||
'chat': PROJECTS['chat'][0],
|
||||
'chat': ('2528466DE220D91C', '{C1E20667DCE324ED}Missions/ERR_Everon.conf'),
|
||||
}
|
||||
ADDON_GUIDS = {'core': PROJECTS['core'][0]}
|
||||
|
||||
WORLD = '{B6C516F86302C679}Worlds/ERR_Everon.ent'
|
||||
SYSTEMS = '{C50579A2EC48E8EF}Configs/Systems/MissionSystems.conf'
|
||||
|
||||
@@ -119,7 +114,7 @@ def main():
|
||||
parser.add_argument('--archive', type=Path, required=True)
|
||||
parser.add_argument('--route', choices=['header', 'diag-world', 'vanilla-everon'], default='header')
|
||||
parser.add_argument('--project', choices=['core', 'chat'], default='core',
|
||||
help='Core base (default) or Chat with all five declared successor addons')
|
||||
help='Diagnostic scenario header; both select the combined addon')
|
||||
parser.add_argument('--seconds', type=int, default=90)
|
||||
args = parser.parse_args()
|
||||
if args.route == 'vanilla-everon' and args.project != 'core':
|
||||
|
||||
@@ -25,7 +25,7 @@ def digest(path):
|
||||
|
||||
|
||||
def selected_scenario(source, project):
|
||||
addon = source / 'addons' / project
|
||||
addon = source / 'addons' / 'core'
|
||||
text = (addon / 'addon.gproj').read_text()
|
||||
guid = re.search(r'\bGUID "([A-F0-9]{16})"', text)
|
||||
if not guid:
|
||||
@@ -85,7 +85,7 @@ def build_plan(source, runtime, project='core', port=2001):
|
||||
config = {
|
||||
'bindAddress': '127.0.0.1', 'bindPort': port,
|
||||
'publicAddress': '127.0.0.1', 'publicPort': port,
|
||||
'game': {'name': 'Rereintroduced private validation', 'scenarioId': scenario['header'],
|
||||
'game': {'name': 'Reintroduced private validation', 'scenarioId': scenario['header'],
|
||||
'maxPlayers': 2, 'visible': False,
|
||||
'gameProperties': {'fastValidation': True, 'battlEye': True}, 'mods': []},
|
||||
'operating': {'disableCrashReporter': True},
|
||||
|
||||
@@ -35,6 +35,6 @@ workspace.windowAdded.connect(function(w) {
|
||||
var windows = workspace.windowList();
|
||||
// Existing user editors are deliberately left untouched.
|
||||
for (var i = 0; i < windows.length; i++) {
|
||||
if (String(windows[i].caption).indexOf("Rereintroduced") >= 0) place(windows[i]);
|
||||
if (String(windows[i].caption).indexOf("Reintroduced") >= 0) place(windows[i]);
|
||||
}
|
||||
print("ERRI_DISPLAY guard active for " + targetName);
|
||||
|
||||
@@ -34,7 +34,7 @@ def policy(root):
|
||||
require(entry['addon'] in ALLOWED and re.fullmatch(r'[A-F0-9]{16}',entry['guid']) is not None,'Unknown resource owner/identity')
|
||||
return value
|
||||
|
||||
def direct_for(project):return set(DIRECT) if project=='ai' else set()
|
||||
def direct_for(project):return set(DIRECT) if project=='core' else set()
|
||||
|
||||
def resource_allowed(value,visible,guid,path):
|
||||
entry=value['resources'].get(path)
|
||||
|
||||
+13
-12
@@ -129,7 +129,7 @@ def packed_request(a):
|
||||
|
||||
def prepare_packed(packed):
|
||||
"""Called only while holding the ordinary runner's existing prefix lock."""
|
||||
if packed.get('schema_version') == 3:
|
||||
if packed.get('schema_version') == 4:
|
||||
from packed_external import prepare
|
||||
return prepare(packed)
|
||||
packed['session'].mkdir(exist_ok=False)
|
||||
@@ -148,7 +148,7 @@ def prepare_packed(packed):
|
||||
|
||||
|
||||
def packed_snapshot(packed, command, profile):
|
||||
if packed.get('schema_version') == 3:
|
||||
if packed.get('schema_version') == 4:
|
||||
from packed_external import snapshot
|
||||
return snapshot(packed, command, profile)
|
||||
project = packed.get('project', 'core')
|
||||
@@ -169,7 +169,7 @@ def packed_snapshot(packed, command, profile):
|
||||
|
||||
def packed_seal(packed, raw, session):
|
||||
"""Fail closed on changed/deleted inputs, retaining a useful failure record."""
|
||||
if packed.get('schema_version') == 3:
|
||||
if packed.get('schema_version') == 4:
|
||||
from packed_external import seal
|
||||
return seal(packed, raw, session)
|
||||
metadata = json.loads(raw)
|
||||
@@ -201,7 +201,7 @@ def native_path(value, game_root):
|
||||
|
||||
|
||||
def packed_evidence(content, packed, steam, profile):
|
||||
if packed.get('schema_version') == 3:
|
||||
if packed.get('schema_version') == 4:
|
||||
from packed_external import evidence
|
||||
return evidence(content, packed, steam, profile, sys.modules[__name__])
|
||||
project = packed.get('project', 'core')
|
||||
@@ -379,9 +379,9 @@ def command_for(root, steam, compat, profile, project, packed=None, capture_pris
|
||||
if project != packed.get('project', 'core') or project not in {'core', 'chat'}:
|
||||
refuse('Packed input project does not match requested ordinary scenario')
|
||||
addon_root = packed['input'] if project == 'chat' else packed['input'].parent
|
||||
command += ['-addonsDir', 'Z:'+str(addon_root), '-addons', PACKED_PROJECTS[project][1]]
|
||||
command += ['-addonsDir', 'Z:'+str(addon_root), '-addons', ('2528466DE220D91C' if packed.get('schema_version') == 4 else PACKED_PROJECTS[project][1])]
|
||||
elif project != CONTROL:
|
||||
project_text = (root/'addons'/project/'addon.gproj').read_text()
|
||||
project_text = (root/'addons/core/addon.gproj').read_text()
|
||||
guid = re.search(r'\bGUID "([A-F0-9]{16})"', project_text)[1]
|
||||
command += ['-addonsDir', 'Z:'+str(root/'addons'), '-addons', guid]
|
||||
command += ['-profile', 'Z:'+str(profile)]
|
||||
@@ -411,7 +411,7 @@ def vanilla_evidence(content):
|
||||
allowed = {'5614BBCCBB55ED1C', '58D0FB3206B6F859'}
|
||||
wrong_addons = bool(set(guids) - allowed)
|
||||
base_only = bool(blocks) and set(guids) == allowed and not wrong_addons
|
||||
successor = any(token in content for token in ('ERR ORDINARY:', 'ERR SMOKE ', 'Rereintroduced [mission]'))
|
||||
successor = any(token in content for token in ('ERR ORDINARY:', 'ERR SMOKE ')) or bool(re.search(r'Re(?:re)?introduced \[mission\]', content))
|
||||
menu = "Entities load 'worlds/MainMenuWorld/MainMenuWorld.ent'" in content and 'Entered main menu.' in content
|
||||
failed = bool(report['engine_errors'] or report['script_or_runtime_failures']) or bad_defines or wrong_addons or successor
|
||||
return {**report, 'control_only': True, 'loaded_addon_guids': guids, 'vanilla_addons_only': base_only,
|
||||
@@ -433,10 +433,11 @@ def game_evidence(content, project=None):
|
||||
header_verified = bool(headers) and (project is None or project in headers)
|
||||
participants = re.findall(r'ERR ORDINARY: local participant verified project=(core|chat) phase=prison', content)
|
||||
participant_verified = bool(participants) and (project is None or project in participants)
|
||||
ready = header_verified and participant_verified and 'Rereintroduced [mission]: phase 0 -> 1' in content
|
||||
# Accept the historical spelling when reviewing immutable older evidence.
|
||||
ready = header_verified and participant_verified and bool(re.search(r'Re(?:re)?introduced \[mission\]: phase 0 -> 1', content))
|
||||
unexpected_fixture = 'ERR SMOKE ' in content
|
||||
failed = (bool(report['engine_errors'] or report['script_or_runtime_failures'])
|
||||
or 'Rereintroduced: scenario preparation failed' in content
|
||||
or bool(re.search(r'Re(?:re)?introduced: scenario preparation failed', content))
|
||||
or 'Can\'t compile "Game"' in content or unexpected_fixture
|
||||
or any({'WORKBENCH', 'ENF_WB'}.intersection(value.split(',')) for value in defines))
|
||||
return {**report, 'local_participant_verified': participant_verified, 'scenario_header_verified': header_verified, 'verified_header_projects': headers, 'game_module_defines': defines, 'workbench_undefined': non_workbench,
|
||||
@@ -1297,7 +1298,7 @@ def run_locked(a, steam, compat, profile, packed=None):
|
||||
link = addons/'ArmaReforger'
|
||||
if not link.exists() and not link.is_symlink():
|
||||
link.symlink_to(vanilla, target_is_directory=True)
|
||||
if packed and packed.get('schema_version') == 3:
|
||||
if packed and packed.get('schema_version') == 4:
|
||||
from packed_external import prepare_profile
|
||||
prepare_profile(packed, steam, profile)
|
||||
elif link.resolve() != vanilla.resolve() or any(x.name not in {'ArmaReforger', 'saves'} for x in addons.iterdir()) or any((addons/'saves').rglob('*.gproj')):
|
||||
@@ -1469,8 +1470,8 @@ def main():
|
||||
parser.add_argument('--project', choices=['core', 'chat', CONTROL], default='core')
|
||||
packed_options = parser.add_mutually_exclusive_group()
|
||||
packed_options.add_argument('--packed-core', type=Path, help='Unqualified Core candidate directory containing exactly addon.gproj, data.pak and resourceDatabase.rdb')
|
||||
packed_options.add_argument('--packed-addons', type=Path, help='Unqualified complete core/ai/intel/map/chat packed directories; requires --project chat')
|
||||
parser.add_argument('--external-addons', type=Path, help='Explicit DEVELOPMENT packed Chat v3 only: audited installed RHS/WCS/BMP root; six private links, no broad workshop mount')
|
||||
packed_options.add_argument('--packed-addons', type=Path, help='Packed addon root; with --external-addons expects the single combined core folder (v4), otherwise historical five-addon v2; requires --project chat')
|
||||
parser.add_argument('--external-addons', type=Path, help='Explicit DEVELOPMENT combined packed mod v4 only: audited installed RHS/WCS/BMP root; six private links, no broad workshop mount')
|
||||
parser.add_argument('--packed-session', type=Path, help='New /tmp/erri-packed-core-<name> or erri-packed-chat-<name> session; owns a fresh input copy and profile')
|
||||
parser.add_argument('--inspect-processes', action='store_true', help='Read-only process preflight report; never launch, lock, write a profile or configure audio')
|
||||
parser.add_argument('--inspect-headless-display', action='store_true', help='Read-only ownership inspection within the hidden display; no input sent, loose Core/Chat only')
|
||||
|
||||
@@ -25,7 +25,7 @@ p.add_argument('--flight-action-inputs', action='store_true', help='Experimental
|
||||
p.add_argument('--flight-input-diagnostics', action='store_true', help='Opt-in read-only powered-flight input/application and native rotor signal observations')
|
||||
p.add_argument('--rounds', type=int, default=2)
|
||||
p.add_argument('--timeout', type=int, default=360)
|
||||
p.add_argument('--project', choices=['core', 'chat'], default='chat', help='Core-only base or full companion integration')
|
||||
p.add_argument('--project', choices=['core', 'chat'], default='chat', help='Diagnostic fixture family (both load the combined mod)')
|
||||
a = p.parse_args()
|
||||
steam, compat, profile = a.steam.resolve(), a.compat.resolve(), a.profile.resolve()
|
||||
if not 1 <= a.rounds <= 10 or not 60 <= a.timeout <= 1800:
|
||||
@@ -61,9 +61,8 @@ if a.smoke == 'environment-restart':
|
||||
if any((profile/name).exists() or (profile/name).is_symlink() for name in fixture_names): p.error('environment-restart fixture paths must not preexist; preserve or move prior evidence before retrying')
|
||||
if compat.is_relative_to(steam) or not (compat/'pfx/system.reg').is_file():
|
||||
p.error('--compat must be a disposable cloned prefix outside the Steam installation')
|
||||
if a.external_addons and a.project != 'chat': p.error('External AI dependencies are not permitted in Core-only tests')
|
||||
if a.external_addons and profile == Path('/tmp/erri-workbench-profile'): p.error('Use a separate external-dependency profile; preserve the vanilla test profile')
|
||||
if a.project == 'chat' and not a.external_addons: p.error('Full Chat now requires --external-addons pointing to installed authorized RHS/WCS/BMP dependencies')
|
||||
if not a.external_addons: p.error('The combined mod requires --external-addons pointing to installed authorized RHS/WCS/BMP dependencies')
|
||||
prefix_lock = (compat/'.erri-workbench.lock').open('a')
|
||||
try: fcntl.flock(prefix_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError: p.error('Another test runner owns this disposable Wine prefix')
|
||||
@@ -103,7 +102,7 @@ for proc in Path('/proc').iterdir():
|
||||
if profile_arg.encode() in args and any(x.endswith(b'ArmaReforgerWorkbenchSteamDiag.exe') for x in args):
|
||||
p.error('Another Workbench already uses this test profile')
|
||||
except (FileNotFoundError, PermissionError, ProcessLookupError): pass
|
||||
command = ['gamescope', '--backend', 'headless', '-W', '1280', '-H', '720', '-w', '1280', '-h', '720', '-r', '30', '--', str(proton), 'run', str(wb/'ArmaReforgerWorkbenchSteamDiag.exe'), '-gproj', 'Z:'+str(ROOT/'addons'/a.project/'addon.gproj'), '-addonsDir', 'Z:'+str(ROOT/'addons'), '-profile', profile_arg, '-logconsole', '-wbModule=WorldEditor', '-load', 'Worlds/ERR_Everon.ent', '-plugin=ERR_ValidateWorldPlugin', '-errPlay', '1', '-errSmoke', '1', '-errSmokeOutcome', a.smoke, '-noThrow', '-disableCrashReporter']
|
||||
command = ['gamescope', '--backend', 'headless', '-W', '1280', '-H', '720', '-w', '1280', '-h', '720', '-r', '30', '--', str(proton), 'run', str(wb/'ArmaReforgerWorkbenchSteamDiag.exe'), '-gproj', 'Z:'+str(ROOT/'addons/core/addon.gproj'), '-addonsDir', 'Z:'+str(ROOT/'addons'), '-profile', profile_arg, '-logconsole', '-wbModule=WorldEditor', '-load', 'Worlds/ERR_Everon.ent', '-plugin=ERR_ValidateWorldPlugin', '-errPlay', '1', '-errSmoke', '1', '-errSmokeOutcome', a.smoke, '-noThrow', '-disableCrashReporter']
|
||||
if a.convoy_cover: command += ['-errConvoyCover', '1']
|
||||
if a.field_service_cancel: command += ['-errSmokeFieldServiceCancel', '1']
|
||||
if a.field_terrain_audit: command += ['-errSmokeFieldTerrainAudit', '1']
|
||||
@@ -179,7 +178,7 @@ with (profile/'headless-launch.log').open('w') as output:
|
||||
recorded_sessions.add(session)
|
||||
console = session/'console.log'
|
||||
content = console.read_text(errors='replace') if console.exists() else ''
|
||||
if ('Rereintroduced: scenario preparation failed' in content and a.smoke not in {'startup-failure', 'extraction-startup-failure', 'extraction-boundary-failure'}) or 'ERR SMOKE FAIL:' in content or 'Virtual Machine Exception' in content or 'Can\'t compile "Game"' in content:
|
||||
if ('Reintroduced: scenario preparation failed' in content and a.smoke not in {'startup-failure', 'extraction-startup-failure', 'extraction-boundary-failure'}) or 'ERR SMOKE FAIL:' in content or 'Virtual Machine Exception' in content or 'Can\'t compile "Game"' in content:
|
||||
result = 'test-failed'; break
|
||||
if content.count('ERR SMOKE COMPLETE:') >= a.rounds:
|
||||
if complete_at is None: complete_at = time.time()
|
||||
|
||||
@@ -18,7 +18,7 @@ if a.execute and a.mode=='workbench' and not a.visible_output:
|
||||
p.error('Workbench opens windows. Select/configure a spare display and supply --visible-output first.')
|
||||
command=[str(a.exe),'-addonsDir',str(ROOT/'addons'),'-profile',str(a.profile),'-logconsole']
|
||||
if a.mode=='workbench':
|
||||
command+=['-gproj',str(ROOT/'addons'/a.project/'addon.gproj'),'-wbModule=WorldEditor','-load','Worlds/ERR_Everon.ent']
|
||||
command+=['-gproj',str(ROOT/'addons/core/addon.gproj'),'-wbModule=WorldEditor','-load','Worlds/ERR_Everon.ent']
|
||||
if a.smoke:command+=['-plugin','ERR_ValidateWorldPlugin','-errPlay','1','-errSmoke','1','-errSmokeOutcome',a.smoke]
|
||||
if a.project=='core' and a.smoke:
|
||||
if a.smoke not in {'victory', 'cache-loss', 'startup-failure', 'site-survey', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'environment', 'patrols'}:p.error('Unsupported Core-only fixture')
|
||||
@@ -26,7 +26,7 @@ if a.mode=='workbench':
|
||||
command[command.index('-errSmoke')]='-errBaseSmoke'
|
||||
else:
|
||||
if 'server' not in a.exe.name.lower():p.error('Use an actual dedicated server binary for background tests.')
|
||||
command+=['-addons',{'core':'ERR_Core','chat':'ERR_Chat'}[a.project],'-server','Worlds/ERR_Everon.ent','-maxFPS','60','-logStats','1000']
|
||||
command+=['-addons','2528466DE220D91C','-server','Worlds/ERR_Everon.ent','-maxFPS','60','-logStats','1000']
|
||||
print(json.dumps(command,indent=2))
|
||||
if a.execute:
|
||||
if not a.exe.is_file():p.error('Executable not found')
|
||||
|
||||
+10
-10
@@ -59,11 +59,11 @@ def project_order(project):
|
||||
|
||||
|
||||
def external_request(project, source, root):
|
||||
needs_external = project in {'ai', 'chat'}
|
||||
needs_external = project in {'core'}
|
||||
if needs_external and source is None:
|
||||
raise ValueError('AI and Chat builds require --external-addons with the audited installed RHS/WCS/BMP closure')
|
||||
raise ValueError('Combined mod builds require --external-addons with the audited installed RHS/WCS/BMP closure')
|
||||
if source is not None and not needs_external:
|
||||
raise ValueError('Core, Intel and Map builds must use vanilla-only profiles; --external-addons is not permitted')
|
||||
raise ValueError('Unknown project; only the combined core addon is supported')
|
||||
if source is None:
|
||||
return None
|
||||
source = Path(source).resolve()
|
||||
@@ -127,9 +127,9 @@ def verify_package_profile(profile, steam, records):
|
||||
|
||||
def expose_external(project, profile, steam, context):
|
||||
records = {}
|
||||
if project in {'ai', 'chat'}:
|
||||
if project in {'core'}:
|
||||
if context is None:
|
||||
raise ValueError('AI and Chat native packaging require audited external inputs')
|
||||
raise ValueError('Combined mod native packaging require audited external inputs')
|
||||
verify_external(context)
|
||||
records = prepare_profile(profile/'addons', context['source'], context['policy'], context['cache_path'])
|
||||
if records != context['records']:
|
||||
@@ -300,8 +300,8 @@ def quiet_streams(application_id, sink, compat):
|
||||
|
||||
|
||||
def run_project(steam, compat, session, project, timeout, authored, silent_workbench=False, exit_after_init=False, *, external=None):
|
||||
if (project in {'ai', 'chat'}) != (external is not None):
|
||||
raise ValueError('Only AI/Chat package profiles require and permit audited external inputs')
|
||||
if (project in {'core'}) != (external is not None):
|
||||
raise ValueError('The combined addon requires audited external inputs')
|
||||
command = command_for(steam, session, project, silent_workbench, exit_after_init)
|
||||
proton = Path(command[command.index('--')+1])
|
||||
executable = Path(command[command.index('--')+3])
|
||||
@@ -506,7 +506,7 @@ def run(a, root=ROOT):
|
||||
continue_on_failure = getattr(a, 'continue_on_failure', False)
|
||||
stopped_reason = None
|
||||
for project in order:
|
||||
selected_external = external if project in {'ai', 'chat'} else None
|
||||
selected_external = external if project in {'core'} else None
|
||||
report = run_project(steam, compat, session, project, a.timeout, authored, silent_workbench, exit_after_init, external=selected_external)
|
||||
reports.append(report)
|
||||
# Diagnostic continuation never grants authority to reuse an unsafe
|
||||
@@ -588,12 +588,12 @@ def run(a, root=ROOT):
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--project', choices=PROJECTS, default='core', help='Build selected addon and its successor dependencies in order')
|
||||
parser.add_argument('--project', choices=PROJECTS, default='core', help='Build the single combined addon')
|
||||
parser.add_argument('--session', type=Path, required=True, help='New direct child of /tmp; never overwritten')
|
||||
parser.add_argument('--compat', type=Path, required=True, help='Idle disposable cloned Tools prefix under /tmp')
|
||||
parser.add_argument('--steam', type=Path, default=Path.home()/'.local/share/Steam')
|
||||
parser.add_argument('--timeout', type=int, default=300, help='30..1800 seconds per addon')
|
||||
parser.add_argument('--external-addons', type=Path, help='Required for AI/Chat: installed audited RHS/WCS/BMP root; only allowlisted links enter private profiles')
|
||||
parser.add_argument('--external-addons', type=Path, help='Required for the combined mod: installed audited RHS/WCS/BMP root; only allowlisted links enter private profiles')
|
||||
lifecycle = parser.add_mutually_exclusive_group()
|
||||
lifecycle.add_argument('--silent-workbench', action='store_true', help='Opt-in documented -wbSilent experiment; packing compatibility still requires native evidence')
|
||||
lifecycle.add_argument('--exit-after-init', action='store_true', help='Opt-in documented -exitAfterInit experiment; natural exit and all native error gates remain mandatory')
|
||||
|
||||
+15
-20
@@ -1,4 +1,4 @@
|
||||
"""Explicit v3 DEVELOPMENT packed Chat consumption; v1/v2 remain unchanged.
|
||||
"""Explicit v4 DEVELOPMENT combined-mod consumption; v1/v2 remain unchanged.
|
||||
Only the audited installed external closure is linked, never copied or published.
|
||||
"""
|
||||
import hashlib
|
||||
@@ -11,13 +11,8 @@ import uuid
|
||||
import external_addons as external
|
||||
from project_schema import parse_project
|
||||
|
||||
PROJECTS = {
|
||||
'core': ('ERR_Core', '2528466DE220D91C', ()),
|
||||
'ai': ('ERR_Ai', '5192138CB9D5B911', ('core',)),
|
||||
'intel': ('ERR_Intel', 'D31039C7C53E348A', ('core',)),
|
||||
'map': ('ERR_Map', '9AAEE9041D79809E', ('core',)),
|
||||
'chat': ('ERR_Chat', 'FA804F411BDDF2C6', ('core','ai','intel','map')),
|
||||
}
|
||||
from source_bundle import PROJECTS
|
||||
|
||||
FILES = {'addon.gproj','data.pak','resourceDatabase.rdb'}
|
||||
MANIFEST = 'packed-input-provenance.json'
|
||||
|
||||
@@ -35,8 +30,8 @@ def digest(path):
|
||||
|
||||
def inventory(folder):
|
||||
folder=Path(folder)
|
||||
require(folder.is_dir() and not folder.is_symlink() and folder==folder.resolve(),'Packed v3 closure must be a canonical real directory')
|
||||
require({p.name for p in folder.iterdir()}==set(PROJECTS),'Packed v3 requires exactly five successor addon folders')
|
||||
require(folder.is_dir() and not folder.is_symlink() and folder==folder.resolve(),'Packed v4 closure must be a canonical real directory')
|
||||
require({p.name for p in folder.iterdir()}==set(PROJECTS),'Packed v4 requires exactly the combined core addon folder')
|
||||
records={}
|
||||
for key,(name,guid,deps) in PROJECTS.items():
|
||||
addon=folder/key
|
||||
@@ -51,16 +46,16 @@ def inventory(folder):
|
||||
parsed=parse_project(text,allow_core_menu=key=='core')
|
||||
actual=re.findall(r'"([A-F0-9]{16})"',parsed[4]) if parsed else []
|
||||
expected={'58D0FB3206B6F859',*(PROJECTS[d][1] for d in deps),*external.direct_for(key)}
|
||||
require(parsed and parsed[1]==name and parsed[2]==guid and set(actual)==expected and len(actual)==len(expected),'Packed v3 exact project identity/dependency mismatch: '+key)
|
||||
require(parsed and parsed[1]==name and parsed[2]==guid and set(actual)==expected and len(actual)==len(expected),'Packed v4 exact project identity/dependency mismatch: '+key)
|
||||
return records
|
||||
|
||||
def request(args,root):
|
||||
require(args.project=='chat' and getattr(args,'packed_addons',None) is not None and getattr(args,'packed_core',None) is None,'--external-addons requires --project chat --packed-addons (explicit packed v3)')
|
||||
require(args.profile==Path('/tmp/erri-game-profile') and args.settle==15,'Packed v3 owns a new private profile and requires 15-second settling')
|
||||
require(args.project=='chat' and getattr(args,'packed_addons',None) is not None and getattr(args,'packed_core',None) is None,'--external-addons requires --project chat --packed-addons (explicit packed v4)')
|
||||
require(args.profile==Path('/tmp/erri-game-profile') and args.settle==15,'Packed v4 owns a new private profile and requires 15-second settling')
|
||||
session=getattr(args,'packed_session',None)
|
||||
require(session is not None,'Packed v3 requires --packed-session')
|
||||
require(session is not None,'Packed v4 requires --packed-session')
|
||||
session=Path(session)
|
||||
require(session.is_absolute() and session.parent==Path('/tmp') and session==session.resolve() and re.fullmatch(r'erri-packed-chat-[A-Za-z0-9_-]+',session.name) and not session.exists() and not session.is_symlink(),'Packed v3 requires a new canonical /tmp/erri-packed-chat-<name> session')
|
||||
require(session.is_absolute() and session.parent==Path('/tmp') and session==session.resolve() and re.fullmatch(r'erri-packed-chat-[A-Za-z0-9_-]+',session.name) and not session.exists() and not session.is_symlink(),'Packed v4 requires a new canonical /tmp/erri-packed-chat-<name> session')
|
||||
candidate=Path(args.packed_addons);artifacts=inventory(candidate)
|
||||
policy_path=Path(root)/'docs/external-addons.json';policy_sha=digest(policy_path);rules=external.policy(root)
|
||||
require(digest(policy_path)==policy_sha,'External policy changed during parsing')
|
||||
@@ -72,7 +67,7 @@ def request(args,root):
|
||||
match=re.search(r'\bID\s+"([A-Za-z0-9_-]+)"',data.decode())
|
||||
require(match and match[1] not in {'core','ArmaReforger',*(p[0] for p in PROJECTS.values()),*names.values()},'Invalid/duplicate external project mount name')
|
||||
names[guid]=match[1]
|
||||
return {'schema_version':3,'project':'chat','candidate':candidate,'session':session,'input':session/'input/addons','manifest':artifacts,'external_source':source,'policy_path':policy_path,'policy_sha256':policy_sha,'policy':rules,'external_records':records,'external_names':names,'external_cache':cache}
|
||||
return {'schema_version':4,'project':'chat','candidate':candidate,'session':session,'input':session/'input/addons','manifest':artifacts,'external_source':source,'policy_path':policy_path,'policy_sha256':policy_sha,'policy':rules,'external_records':records,'external_names':names,'external_cache':cache}
|
||||
|
||||
def verify_external(packed,profile=None):
|
||||
require(digest(packed['policy_path'])==packed['policy_sha256'],'External policy changed')
|
||||
@@ -121,14 +116,14 @@ def prepare_profile(packed,steam,profile):
|
||||
def snapshot(packed,command,profile):
|
||||
verify_external(packed,profile)
|
||||
require(inventory(packed['input'])==packed['manifest'] and inventory(packed['candidate'])==packed['manifest'],'Packed inputs changed before launch')
|
||||
value={'schema_version':3,'kind':'packed-chat-external-runtime-input','project':'chat','addon_order':list(PROJECTS),'launch_id':str(uuid.uuid4()),'captured_unix':time.time(),'candidate_directory':str(packed['candidate']),'runtime_directory':str(packed['input']),'profile':str(profile),'command':list(command),'artifacts':packed['manifest'],'external_policy_sha256':packed['policy_sha256'],'external_policy':packed['policy'],'external_addons':packed['external_records'],'external_project_names':packed['external_names'],'build_qualification':'DEVELOPMENT unqualified candidate; original packaging verdict unchanged'}
|
||||
value={'schema_version':4,'kind':'packed-chat-external-runtime-input','project':'chat','addon_order':list(PROJECTS),'launch_id':str(uuid.uuid4()),'captured_unix':time.time(),'candidate_directory':str(packed['candidate']),'runtime_directory':str(packed['input']),'profile':str(profile),'command':list(command),'artifacts':packed['manifest'],'external_policy_sha256':packed['policy_sha256'],'external_policy':packed['policy'],'external_addons':packed['external_records'],'external_project_names':packed['external_names'],'build_qualification':'DEVELOPMENT unqualified candidate; original packaging verdict unchanged'}
|
||||
raw=(json.dumps(value,indent=2)+'\n').encode();(packed['session']/MANIFEST).write_bytes(raw)
|
||||
return raw
|
||||
|
||||
def seal(packed,raw,session):
|
||||
value=json.loads(raw);unchanged=False;error=None
|
||||
try:
|
||||
require(value['schema_version']==3 and value['kind']=='packed-chat-external-runtime-input','Wrong packed v3 provenance schema')
|
||||
require(value['schema_version']==4 and value['kind']=='packed-chat-external-runtime-input','Wrong packed v4 provenance schema')
|
||||
require(value['external_addons']==packed['external_records'] and value['external_policy_sha256']==packed['policy_sha256'] and value['external_policy']==packed['policy'] and value['external_project_names']==packed['external_names'],'External provenance mismatch')
|
||||
verify_external(packed,packed['profile'])
|
||||
require(inventory(packed['input'])==value['artifacts'] and inventory(packed['candidate'])==value['artifacts'],'Packed inputs changed after launch')
|
||||
@@ -137,7 +132,7 @@ def seal(packed,raw,session):
|
||||
unchanged=True
|
||||
except (OSError,ValueError,KeyError) as caught:error=str(caught)
|
||||
console=session/'console.log' if session else None
|
||||
return {'packed_only':True,'packed_schema_version':3,'packed_input_provenance':MANIFEST,'packed_input_provenance_sha256':hashlib.sha256(raw).hexdigest(),'packed_launch_id':value['launch_id'],'stopped_unix':time.time(),'packed_inputs_unchanged_after_run':unchanged,'packed_external_inputs_unchanged_after_run':unchanged,'packed_input_error':error,'external_addon_count':len(packed['external_records']),'console_sha256':digest(console) if console and console.is_file() else None,'build_qualified':False,'release_qualified':False}
|
||||
return {'packed_only':True,'packed_schema_version':4,'packed_input_provenance':MANIFEST,'packed_input_provenance_sha256':hashlib.sha256(raw).hexdigest(),'packed_launch_id':value['launch_id'],'stopped_unix':time.time(),'packed_inputs_unchanged_after_run':unchanged,'packed_external_inputs_unchanged_after_run':unchanged,'packed_input_error':error,'external_addon_count':len(packed['external_records']),'console_sha256':digest(console) if console and console.is_file() else None,'build_qualified':False,'release_qualified':False}
|
||||
|
||||
def evidence(content,packed,steam,profile,legacy):
|
||||
report=legacy.game_evidence(content,'chat');game=steam/'steamapps/common/Arma Reforger'
|
||||
@@ -188,4 +183,4 @@ def evidence(content,packed,steam,profile,legacy):
|
||||
if any({'WORKBENCH','ENF_WB'}.intersection(flags.split(',')) for _,flags in modules):violations.append('Workbench defines in packed ordinary runtime')
|
||||
path_proof=complete and mounted==set(mounts) and loaded_databases==set(databases) and bool(dirs)
|
||||
failed=report['failed'] or bool(violations)
|
||||
return {**report,'packed_only':True,'packed_schema_version':3,'external_addon_count':len(packed['external_records']),'packed_loaded_paths_verified':path_proof,'packed_input_violations':violations,'packed_normal_modules':normal,'failed':failed,'ready':report['ready'] and path_proof and normal and not failed,'build_qualified':False,'release_qualified':False,'scope':'DEVELOPMENT packed Chat plus exact audited external closure; ordinary local startup only, no clean-build, human escape, multiplayer, FPS or release qualification'}
|
||||
return {**report,'packed_only':True,'packed_schema_version':4,'external_addon_count':len(packed['external_records']),'packed_loaded_paths_verified':path_proof,'packed_input_violations':violations,'packed_normal_modules':normal,'failed':failed,'ready':report['ready'] and path_proof and normal and not failed,'build_qualified':False,'release_qualified':False,'scope':'DEVELOPMENT packed combined mod plus exact audited external closure; ordinary local startup only, no clean-build, human escape, multiplayer, FPS or release qualification'}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Check serialized root entity IDs required for reproducible prefab replication."""
|
||||
import re
|
||||
|
||||
|
||||
def root_id(text):
|
||||
# Braces in resource strings and comments must not change object depth.
|
||||
tokens = re.findall(r'//[^\n]*|/\*[\s\S]*?\*/|"(?:\\.|[^"\\])*"|[{}]|\b\w+\b', text)
|
||||
tokens = [token for token in tokens if not token.startswith(('//', '/*'))]
|
||||
depth = 0
|
||||
identities = []
|
||||
for index, token in enumerate(tokens):
|
||||
if token == '{':
|
||||
depth += 1
|
||||
elif token == '}':
|
||||
depth -= 1
|
||||
elif token == 'ID' and depth == 1:
|
||||
identities.append(tokens[index + 1] if index + 1 < len(tokens) else '')
|
||||
if len(identities) != 1 or not re.fullmatch(r'"[A-F0-9]{16}"', identities[0]):
|
||||
raise ValueError('Prefab needs exactly one saved root entity ID')
|
||||
identity = identities[0][1:-1]
|
||||
if identity == '0000000000000000':
|
||||
raise ValueError('Prefab root entity ID must not be zero')
|
||||
return identity
|
||||
|
||||
|
||||
def check_prefabs(paths):
|
||||
seen = {}
|
||||
for path in sorted(paths):
|
||||
try:
|
||||
identity = root_id(path.read_text())
|
||||
except ValueError as error:
|
||||
raise ValueError(f'{path}: {error}') from error
|
||||
if identity in seen:
|
||||
raise ValueError(f'Duplicate prefab root ID {identity}: {seen[identity]} and {path}')
|
||||
seen[identity] = path
|
||||
return len(seen)
|
||||
+16
-24
@@ -21,37 +21,29 @@ from release_check import audit, read_json, evidence_path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BASE = '58D0FB3206B6F859'
|
||||
PROJECTS = {
|
||||
'core': ('ERR_Core', '2528466DE220D91C', ()),
|
||||
'ai': ('ERR_Ai', '5192138CB9D5B911', ('core',)),
|
||||
'intel': ('ERR_Intel', 'D31039C7C53E348A', ('core',)),
|
||||
'map': ('ERR_Map', '9AAEE9041D79809E', ('core',)),
|
||||
'chat': ('ERR_Chat', 'FA804F411BDDF2C6', ('core', 'ai', 'intel', 'map')),
|
||||
}
|
||||
PROJECTS = {'core': ('ERR_Core', '2528466DE220D91C', ())}
|
||||
|
||||
DIRECTORIES = {'Scripts', 'Prefabs', 'Configs', 'Missions', 'Worlds', 'UI', 'Materials'}
|
||||
SUFFIXES = {'.c', '.et', '.conf', '.ent', '.layer', '.layout', '.emat', '.meta', '.gproj'}
|
||||
EXCLUDED = {'.git', '.build', '.local', '__pycache__', 'profiles', 'profile', 'cache',
|
||||
'runtime', 'evidence', 'vendor', 'vanilla', 'original', 'node_modules', '.agents', '.codex'}
|
||||
README = '''# DEVELOPMENT SOURCE BUNDLE — Escapists Rereintroduced
|
||||
README = '''# DEVELOPMENT SOURCE BUNDLE — Escapists Reintroduced
|
||||
|
||||
This ZIP contains authored source and editable resources for all five successor
|
||||
addon projects. It is NOT a native .pak, a qualified release, or a published mod.
|
||||
No original Escapists, installed vanilla or third-party assets are redistributed. Vanilla asset
|
||||
references require a separately installed, compatible Arma Reforger and Workbench.
|
||||
Author: Jakub Jim Žáček.
|
||||
|
||||
Extract into a NEW development folder, never over an existing mission/project.
|
||||
Configure Workbench's addon search directory to the extracted addons directory.
|
||||
Open addons/core/addon.gproj for the independent Core project, or
|
||||
addons/chat/addon.gproj for the complete companion dependency set. Every project
|
||||
also declares vanilla Arma Reforger (58D0FB3206B6F859): core; ai -> core;
|
||||
intel -> core; map -> core; chat -> core, ai, intel, map.
|
||||
This ZIP contains the single Escapists Reintroduced addon, with Core, AI,
|
||||
Intel, Map and Chat script folders. It is NOT a native .pak or a published mod.
|
||||
Third-party assets are not redistributed.
|
||||
|
||||
Core scenario: {AD62322D5213C440}Missions/ERR_Everon_Base.conf
|
||||
Companion scenario: {C1E20667DCE324ED}Missions/ERR_Everon.conf
|
||||
Extract into a NEW development folder and register addons/core/addon.gproj in
|
||||
Workbench. Install and register the RHS/WCS dependencies in EXTERNAL-ADDONS.json.
|
||||
Open addons/core/addon.gproj and use Missions/ERR_Everon.conf.
|
||||
|
||||
Scenario: {C1E20667DCE324ED}Missions/ERR_Everon.conf
|
||||
Use the installed engine's normal project/scenario workflow. This archive has not
|
||||
been compiled by its bundler; no native caches or binaries are included.
|
||||
|
||||
Target: Everon, up to 12 players; Core is vanilla-only and full AI requires the listed external addons. This remains development source.
|
||||
Target: Everon, up to 12 players. The mod requires the listed external addons. This remains development source.
|
||||
Manual prison/scavenging/radio/extraction play, remote joins/reconnects and
|
||||
ownership, twelve connected players, aircraft support, repeated companion cleanup,
|
||||
matched-workload FPS improvement and release qualification require separate
|
||||
@@ -124,7 +116,7 @@ def validate(payload, root):
|
||||
external = external_policy(root)
|
||||
own_ids = read_json(evidence_path(root, 'docs/addons.json'))
|
||||
if own_ids != {name: item[1] for name, item in PROJECTS.items()}:
|
||||
raise ValueError('successor identity catalog differs from the five owned projects')
|
||||
raise ValueError('successor identity catalog differs from the single owned project')
|
||||
for name, (identity, guid, deps) in PROJECTS.items():
|
||||
text = payload.get(f'addons/{name}/addon.gproj', b'').decode('utf-8')
|
||||
match = parse_project(text, allow_core_menu=name == 'core')
|
||||
@@ -158,7 +150,7 @@ def validate(payload, root):
|
||||
raise ValueError(f'resource lacks successor metadata: {name}')
|
||||
owner = name.split('/')[1]
|
||||
visible = {owner, *PROJECTS[owner][2]}
|
||||
if 'ai' in visible: visible.update(ALLOWED)
|
||||
if 'core' in visible: visible.update(ALLOWED)
|
||||
for guid, resource in re.findall(r'\{([A-F0-9]{16})\}([^"\n]+)', text):
|
||||
if guid in resources:
|
||||
path, target = resources[guid]
|
||||
@@ -188,7 +180,7 @@ def build(root=ROOT, mode='development'):
|
||||
raise ValueError('Not release-qualified after collection:\n - ' + '\n - '.join(blockers))
|
||||
if inventory(root) != before:
|
||||
raise ValueError('addon tree changed during collection')
|
||||
payload['BUNDLE-README.md'] = (README + '\nThe AI/full scenario requires the separately installed authorized addons listed in EXTERNAL-ADDONS.json. Their assets are not bundled. Core remains vanilla-only.\n').encode('utf-8')
|
||||
payload['BUNDLE-README.md'] = (README + '\nThe combined scenario requires the separately installed authorized addons listed in EXTERNAL-ADDONS.json. Their assets are not bundled.\n').encode('utf-8')
|
||||
payload['EXTERNAL-ADDONS.json'] = json.dumps(external_policy(root),indent=2,sort_keys=True).encode('utf-8')
|
||||
manifest = {'schema_version': 1, 'kind': 'DEVELOPMENT SOURCE BUNDLE',
|
||||
'native_build': False, 'release_package': False, 'projects': projects,
|
||||
|
||||
Reference in New Issue
Block a user