From af474d3fa33a021497ee08b9b88b92e2a4a7a555 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Mon, 2 Nov 2026 13:59:38 +0000 Subject: [PATCH 1/3] Update Lootable Bodies for Forge 1.18.2 --- .github/release118.json | 15 ++ .github/scripts/test_release_workflow.py | 109 +++++++++++- .github/workflows/artifact-audit-1.12.yml | 41 ++++- .github/workflows/artifact-audit-1.18.yml | 48 +++++ .github/workflows/build.yml | 1 - .github/workflows/codeql-analysis.yml | 21 +-- .github/workflows/deploy-release.yml | 72 +++++--- .github/workflows/migration-matrix.yml | 1 - .github/workflows/native118.yml | 78 ++++++++ .github/workflows/release-workflow-checks.yml | 5 +- CHANGELOG.txt | 4 +- README.md | 6 +- RELEASING.md | 10 +- TESTING.md | 53 ++++++ build.gradle | 52 +++--- gradle.properties | 14 +- gradle/assets.gradle | 17 +- gradle/audit.gradle | 14 +- gradle/ide.gradle | 19 +- gradle/native117-writer.init.gradle | 26 +++ gradle/release.gradle | 6 +- gradle/runtime.gradle | 15 +- settings.gradle | 18 ++ .../java/buildcontrol/ForgeControl.java | 12 +- .../java/buildcontrol/ResourceControl.java | 27 +-- src/controlTest/resources/META-INF/mods.toml | 2 +- src/controlTest/resources/pack.mcmeta | 2 +- .../lootable/buildtest/ClientChecks.java | 4 +- .../buildtest/ConfigClientChecks.java | 6 +- .../lootable/buildtest/EffectHurtChecks.java | 7 +- .../cyano/lootable/buildtest/FixtureApi.java | 23 +-- .../lootable/buildtest/FixtureItems.java | 13 +- .../buildtest/InventorySafetyChecks.java | 10 +- .../lootable/buildtest/MultiplayerChecks.java | 6 +- .../buildtest/MultiplayerClientChecks.java | 4 +- .../buildtest/Native118BiomeChecks.java | 40 +++++ .../buildtest/NativePreflightChecks.java | 6 +- .../lootable/buildtest/NbtClientChecks.java | 18 +- .../lootable/buildtest/NbtWorldChecks.java | 13 +- .../buildtest/PortBaselineChecks.java | 2 +- .../buildtest/PositionMotionChecks.java | 15 ++ .../buildtest/ReportedIssueChecks.java | 4 +- .../buildtest/RespawnPacketChecks.java | 8 +- .../buildtest/RespawnPacketClientChecks.java | 6 +- .../buildtest/RespawnPacketTrace.java | 13 +- .../lootable/buildtest/RuntimeChecks.java | 15 +- .../lootable/buildtest/VisibilityChecks.java | 8 +- .../buildtest/VisibilityClientChecks.java | 33 +++- .../buildtest/VisibilitySkinChecks.java | 6 +- .../buildtest/WorldUpgradeChecks.java | 4 +- .../resources/META-INF/mods.toml | 4 +- src/integrationTest/resources/pack.mcmeta | 2 +- src/main/java/cyano/lootable/ClientProxy.java | 2 +- .../java/cyano/lootable/LootableBodies.java | 12 +- .../lootable/entities/EntityLootableBody.java | 6 +- .../entities/SavedEffectIdentity.java | 31 ++++ .../cyano/lootable/graphics/GUIHandler.java | 2 +- .../upgrade/EarlyWorldPreservation.java | 58 ++++-- .../upgrade/LegacyProfessionRegistry.java | 14 +- .../upgrade/LegacyRegistryMappings.java | 18 +- .../lootable/upgrade/NativeSafeFixer.java | 66 +++++-- .../lootable/upgrade/TerrainChunkData.java | 54 ++++++ .../lootable/upgrade/UpgradeJournal.java | 21 ++- .../lootable/upgrade/UpgradePreservation.java | 6 +- src/main/resources/META-INF/coremods.json | 3 + src/main/resources/META-INF/mods.toml | 6 +- .../coremods/early-world-preservation.js | 72 ++++++++ src/main/resources/pack.mcmeta | 6 +- .../lootable/buildtest/FixtureItems.java | 31 ++++ .../fixture/NativeWorldSeeder117.java | 121 +++++++++++++ .../resources/META-INF/mods.toml | 13 ++ src/nativeSeed117/resources/pack.mcmeta | 1 + .../buildtest/HistoricalVersionsTest.java | 3 + .../buildtest/Native115PacketTest.java | 6 +- .../buildtest/Native117ForgeConfigTest.java | 13 +- .../Native117ProbeIsolationTest.java | 8 +- .../buildtest/Native117TestEnvironment.java | 5 + .../buildtest/Native118FixtureGenerator.java | 43 +++++ .../buildtest/Native118RespawnArenaTest.java | 30 ++++ .../lootable/contracts/BuildContractTest.java | 4 +- .../contracts/NativeUpgradeBoundaryTest.java | 2 +- .../contracts/ReleaseContractTest.java | 4 +- .../contracts/UpgradeJournalTest.java | 2 +- .../entities/Native118EffectIdentityTest.java | 36 ++++ .../upgrade/EarlyWorldPreservationTest.java | 15 +- .../upgrade/HookRegistrationOrderTest.java | 50 +++--- .../upgrade/ItemNbtConversionTest.java | 2 +- .../upgrade/Native114UpgradeTest.java | 12 +- .../upgrade/Native115UpgradeTest.java | 8 +- .../upgrade/Native116DamagedStampTest.java | 17 +- .../upgrade/Native116UpgradeTest.java | 4 +- .../upgrade/Native117EntityChunkTest.java | 8 +- .../upgrade/Native117EntityRegionTest.java | 4 +- .../upgrade/Native117ExternalSidecarTest.java | 4 +- .../upgrade/Native118BiomeRegistryTest.java | 45 +++++ .../upgrade/Native118CoremodEngineTest.java | 102 +++++++++++ .../upgrade/Native118EarlyHookTest.java | 79 +++++++++ .../upgrade/Native118EntityBoundaryTest.java | 59 +++++++ ...t.java => Native118SavedPositionTest.java} | 28 +-- ...rgetTest.java => Native118TargetTest.java} | 39 +++- .../upgrade/Native118TerrainBoundaryTest.java | 109 ++++++++++++ .../upgrade/Native118WorldgenTest.java | 35 ++++ .../lootable/buildtest/FixtureArena.java | 19 ++ .../buildtest/HistoricalVersions.java | 1 + .../upgrade/Native117RegionFixtures.java | 2 +- .../upgrade/Native118UnitLifecycle.java | 13 ++ tools/fixtures/forge40-server-default.toml | 12 ++ tools/fixtures/native118-flat-level.dat | Bin 0 -> 1289 bytes .../native118-flat-level.provenance.json | 12 ++ tools/hosted_policy.py | 88 ++++++++++ tools/local_case_reuse.py | 82 +++++++++ tools/migration_ci.py | 4 +- tools/native117_ci.py | 2 + tools/native118_build.py | 24 +++ tools/native118_client.py | 166 ++++++++++++++++++ tools/native118_generated_sdk.json | 14 ++ tools/native118_persistence.py | 40 +++++ tools/native118_server_inputs.json | 97 ++++++++++ tools/native118_worker_budget.json | 29 +++ tools/native_evidence.py | 33 ++-- tools/native_runtime.py | 107 ++++++----- tools/owned_process.py | 40 ++++- tools/prepare_native117_tools.py | 41 +++++ tools/prepared_worlds.py | 4 +- tools/qualification-policy.json | 69 ++++++++ tools/runtime_diagnostics.py | 26 +++ tools/test_graphical_split.py | 43 +++++ tools/test_historical_runtime_budget.py | 10 +- tools/test_hosted_policy.py | 45 +++++ tools/test_local_case_reuse.py | 25 +++ tools/test_logging_inputs.py | 44 +++++ tools/test_migration_ci.py | 9 +- tools/test_native116_infrastructure.py | 26 +-- tools/test_native117_client.py | 35 ++-- tools/test_native117_flat_header.py | 12 +- tools/test_native117_launch.py | 36 ++-- tools/test_native118_build.py | 46 +++++ tools/test_native118_persistence.py | 66 +++++++ tools/test_native37_platform_config.py | 14 +- tools/test_native_evidence.py | 55 +++++- tools/test_native_server_preparation.py | 56 +++--- tools/test_owned_process.py | 67 ++++++- tools/test_prepared_worlds.py | 4 +- tools/test_runtime_diagnostics.py | 61 ++++--- tools/verify_multiplayer.py | 2 +- tools/verify_native_respawn_control.py | 10 +- tools/verify_nbt_recovery.py | 6 +- tools/verify_normal_server.py | 4 +- tools/verify_packaged.py | 2 +- tools/verify_packaged_client.py | 19 +- tools/verify_upgrade_abort.py | 47 ++++- tools/verify_visibility.py | 6 +- tools/verify_world_upgrades.py | 56 ++++-- 153 files changed, 3393 insertions(+), 615 deletions(-) create mode 100644 .github/release118.json create mode 100644 .github/workflows/artifact-audit-1.18.yml create mode 100644 .github/workflows/native118.yml create mode 100644 TESTING.md create mode 100644 gradle/native117-writer.init.gradle create mode 100644 src/integrationTest/java/cyano/lootable/buildtest/Native118BiomeChecks.java create mode 100644 src/main/java/cyano/lootable/entities/SavedEffectIdentity.java create mode 100644 src/main/java/cyano/lootable/upgrade/TerrainChunkData.java create mode 100644 src/main/resources/META-INF/coremods.json create mode 100644 src/main/resources/coremods/early-world-preservation.js create mode 100644 src/nativeSeed117/java/cyano/lootable/buildtest/FixtureItems.java create mode 100644 src/nativeSeed117/java/cyano/lootable/fixture/NativeWorldSeeder117.java create mode 100644 src/nativeSeed117/resources/META-INF/mods.toml create mode 100644 src/nativeSeed117/resources/pack.mcmeta create mode 100644 src/test/java/cyano/lootable/buildtest/Native118FixtureGenerator.java create mode 100644 src/test/java/cyano/lootable/buildtest/Native118RespawnArenaTest.java create mode 100644 src/test/java/cyano/lootable/entities/Native118EffectIdentityTest.java create mode 100644 src/test/java/cyano/lootable/upgrade/Native118BiomeRegistryTest.java create mode 100644 src/test/java/cyano/lootable/upgrade/Native118CoremodEngineTest.java create mode 100644 src/test/java/cyano/lootable/upgrade/Native118EarlyHookTest.java create mode 100644 src/test/java/cyano/lootable/upgrade/Native118EntityBoundaryTest.java rename src/test/java/cyano/lootable/upgrade/{Native117SavedPositionTest.java => Native118SavedPositionTest.java} (83%) rename src/test/java/cyano/lootable/upgrade/{Native117TargetTest.java => Native118TargetTest.java} (56%) create mode 100644 src/test/java/cyano/lootable/upgrade/Native118TerrainBoundaryTest.java create mode 100644 src/test/java/cyano/lootable/upgrade/Native118WorldgenTest.java create mode 100644 src/testSupport/java/cyano/lootable/buildtest/FixtureArena.java create mode 100644 src/testSupport/java/cyano/lootable/upgrade/Native118UnitLifecycle.java create mode 100644 tools/fixtures/forge40-server-default.toml create mode 100644 tools/fixtures/native118-flat-level.dat create mode 100644 tools/fixtures/native118-flat-level.provenance.json create mode 100644 tools/hosted_policy.py create mode 100644 tools/local_case_reuse.py create mode 100644 tools/native118_build.py create mode 100644 tools/native118_client.py create mode 100644 tools/native118_generated_sdk.json create mode 100644 tools/native118_persistence.py create mode 100644 tools/native118_server_inputs.json create mode 100644 tools/native118_worker_budget.json create mode 100644 tools/prepare_native117_tools.py create mode 100644 tools/qualification-policy.json create mode 100644 tools/test_hosted_policy.py create mode 100644 tools/test_local_case_reuse.py create mode 100644 tools/test_logging_inputs.py create mode 100644 tools/test_native118_build.py create mode 100644 tools/test_native118_persistence.py diff --git a/.github/release118.json b/.github/release118.json new file mode 100644 index 0000000..1f92c1f --- /dev/null +++ b/.github/release118.json @@ -0,0 +1,15 @@ +{ + "version": "2.4.9.118021", + "minecraft": "1.18.2", + "forge": "40.3.12", + "local_qualification_complete": true, + "files": { + "CHANGELOG.txt": "272160F79F5B4B58206FB258CF6C33C062E950DE8784E29B59A1B10DD2B8F319", + "LootableBodies-2.4.9.118021-javadoc.jar": "C902CC4E0BE65BA4732406855A32A12CFB8A8BE1D855B22905E3203880C6C12D", + "LootableBodies-2.4.9.118021-sources.jar": "712A96FF6BAE8421EC836BEAA07BE44926DE2DEA2FA3EAA6221AC89282018A84", + "LootableBodies-2.4.9.118021.jar": "34EF41EFCE4666A684EA202DAF8712162A1C403A90120BA11EF0260533FB690F", + "LootableBodies-2.4.9.118021.pom": "051FD00F47E53B7F7B6B30CC907B1AA662864D8911FDB0B0C8326987E8F88988", + "SHA256SUMS": "E1E0833F632C6BF794CB13940A34F411355191C903ACAD37C424289BDAE0CFC8" + }, + "qualification_integrity_status": "verified" +} diff --git a/.github/scripts/test_release_workflow.py b/.github/scripts/test_release_workflow.py index e5faaa2..53f1b1d 100644 --- a/.github/scripts/test_release_workflow.py +++ b/.github/scripts/test_release_workflow.py @@ -4,6 +4,8 @@ import os from pathlib import Path import shutil +import shlex +import sys import subprocess import tempfile import textwrap @@ -26,6 +28,7 @@ for stamps in ['normal', 'stamps']] MATRIX115 = ['World upgrade matrix', 'Prepare verified migration inputs', 'Validate release workflow'] + ['Historical world shard ' + str(i) for i in range(6)] CORE117 = ['Build, test, and audit', 'Analyze Java', 'Validation', 'Validate release workflow'] +CORE118 = ['Check hosted work policy', 'Build and audit locally qualified archives', 'Analyze Java', 'Validation', 'Validate release workflow'] MATRIX117 = [] MATRIX116 = ['World upgrade matrix', 'Prepare verified migration inputs', 'Validate release workflow'] + ['Historical world shard ' + str(i) for i in range(12)] MATRIX = [origin + ' / ' + stamps for origin in @@ -65,13 +68,14 @@ def validate(self, minecraft, names=None, mutation=None, release_exists=False, w '1.14.4': ('114041', 'master-1.14.4', '28.2.26'), '1.15.2': ('115021', 'master-1.15.2', '31.2.57'), '1.16.5': ('116051', 'master-1.16.5', '36.2.42'), - '1.17.1': ('117011', 'master-1.17.1', '37.1.1')}[minecraft] + '1.17.1': ('117011', 'master-1.17.1', '37.1.1'), + '1.18.2': ('118021', 'master-1.18.2', '40.3.12')}[minecraft] version = '2.4.2.' + suffix files={name:'C'*64 for name in ['main.jar','sources.jar','javadoc.jar','release.pom','CHANGELOG.txt','SHA256SUMS']} receipt={'schema':1,'source':SHA,'version':version,'files':files,'evidence_sha256':'F'*64,'qualified_locally':True,'manual_accepted':True} if local_receipt is not None:receipt=local_receipt values = dict(minecraft_version=minecraft, forge_version=forge, loader_name='forge', loader_code='1', - java_version='16' if minecraft=='1.17.1' else '8', java_toolchain_version='16.0.2+7' if minecraft=='1.17.1' else '8.0.502+7', gradle_java_version='17', mod_id='lootablebodies', + java_version='17' if minecraft=='1.18.2' else '16' if minecraft=='1.17.1' else '8', java_toolchain_version='17.0.20.1+1' if minecraft=='1.18.2' else '16.0.2+7' if minecraft=='1.17.1' else '8.0.502+7', gradle_java_version='17', mod_id='lootablebodies', mod_artifact='LootableBodies', mod_group='zone.moddev.mc.lootablebodies', mod_license='LGPL-2.1-only', curseforge_project_id='240968', mod_version=version) if mutation: @@ -81,12 +85,14 @@ def validate(self, minecraft, names=None, mutation=None, release_exists=False, w '1.14.4': MATRIX114 + ['Validate release workflow'], '1.15.2': MATRIX115, '1.16.5': MATRIX116}.get(minecraft, []) if names is None else names if minecraft=='1.17.1' and names is None:checks=CORE117+MATRIX117 + if minecraft=='1.18.2' and names is None:checks=CORE118 def command(args, **options): if args[0] == 'git': return SHA + '\n' - if '/actions/workflows/native117.yml/' in args[-1]: - row=dict(id=117,head_sha=SHA,head_branch='master-1.17.1',event='push',path='.github/workflows/native117.yml',conclusion='success') + if any('/actions/workflows/native'+code+'.yml/' in args[-1] for code in ['117','118']): + code='118' if minecraft=='1.18.2' else '117' + row=dict(id=int(code),head_sha=SHA,head_branch=branch,event='push',path='.github/workflows/native'+code+'.yml',conclusion='success') if qualification:row.update(qualification) return json.dumps({'workflow_runs':[row]}) if '/actions/workflows/' in args[-1]: @@ -105,12 +111,12 @@ def existing(args, **options): 'MINECRAFT_VERSION': minecraft, 'SOURCE_BRANCH': branch, 'GITHUB_OUTPUT': str(Path(directory) / 'output'), 'LOCAL_QUALIFICATION':json.dumps(receipt) if receipt else ''}), \ - patch('pathlib.Path.read_text', side_effect=['\n'.join(k+'='+v for k,v in values.items()),json.dumps({'files':files})]), \ + patch('pathlib.Path.read_text', side_effect=['\n'.join(k+'='+v for k,v in values.items()),json.dumps({'files':files,'local_qualification_complete':True,'qualification_integrity_status':'verified'})]), \ patch('subprocess.check_output', side_effect=command), patch('subprocess.run', side_effect=existing): exec(compile(code, 'release-preflight', 'exec'), {}) - def test_future_118_candidate_cannot_dispatch_publication(self): - code, values = self.select('2.4.9.118021') + def test_future_119_candidate_cannot_dispatch_publication(self): + code, values = self.select('2.4.10.119041') self.assertNotEqual(0, code) self.assertEqual({}, values) @@ -282,6 +288,28 @@ def test_117_promotes_exact_six_files_and_rejects_changed_or_extra_bytes(self): with self.assertRaises(SystemExit):exec(compile(code,'extra-native117-promotion','exec'),{}) finally:os.chdir(previous) + def test_118_routes_to_the_accepted_target_and_requires_only_artifact_checks(self): + code,values=self.select('2.4.9.118021') + self.assertEqual(0,code);self.assertEqual('master-1.18.2',values['source_branch']) + self.validate('1.18.2') + for missing in CORE118: + with self.subTest(missing=missing),self.assertRaises(SystemExit): + self.validate('1.18.2',names=[x for x in CORE118 if x!=missing]) + + def test_118_rejects_other_run_inputs_missing_receipt_audit_or_java(self): + for mutation in [{'head_sha':'b'*40},{'head_branch':'master-1.17.1'},{'event':'pull_request'},{'path':'.github/workflows/native117.yml'},{'conclusion':'failure'}]: + with self.subTest(mutation=mutation),self.assertRaises(SystemExit):self.validate('1.18.2',qualification=mutation) + for kwargs in [{'audit_success':False},{'local_receipt':{}},{'mutation':{'java_version':'8'}},{'mutation':{'java_toolchain_version':'17.0.1+12'}}]: + with self.subTest(kwargs=kwargs),self.assertRaises(SystemExit):self.validate('1.18.2',**kwargs) + + def test_118_staging_downloads_exact_inputs_without_build_tests_or_sdk(self): + workflow=WORKFLOW.read_text() + step=workflow.split('- name: Verify all six native118 bytes before publication',1)[1].split(' - name:',1)[0] + self.assertIn('python3 -B tools/native118_build.py --bundle build/release/bundle',step) + for forbidden in ['gradlew','native_runtime','unittest','prepare_native','xvfb']:self.assertNotIn(forbidden,step) + legacy=workflow.split('- name: Build, test and audit the release',1)[1].split(' - name:',1)[0] + self.assertIn("minecraft_version != '1.18.2'",legacy) + def test_identity_changes_are_rejected(self): for mutation in [{'mod_id': 'other'}, {'minecraft_version': '1.10.2'}, {'mod_license': 'CC0-1.0'}]: with self.subTest(mutation=mutation), self.assertRaises(SystemExit): @@ -291,6 +319,73 @@ def test_existing_release_is_not_uploaded_again(self): with self.assertRaises(SystemExit): self.validate('1.10.2', release_exists=True) + + + def run_staging_fixture(self,root,version,mutation=None): + bundle=root/'build/release/bundle';bundle.mkdir(parents=True) + prefix='LootableBodies-'+version + names=[prefix+s for s in ['.jar','-sources.jar','-javadoc.jar','.pom']]+['CHANGELOG.txt'] + for name in names:(bundle/name).write_text('qualified '+name) + (bundle/'SHA256SUMS').write_bytes(''.join(hashlib.sha256((bundle/name).read_bytes()).hexdigest()+' '+name+'\n' for name in names if name.endswith('.jar')).encode()) + if mutation=='missing':(bundle/(prefix+'.pom')).unlink() + if mutation=='extra':(bundle/'unapproved.txt').write_text('extra') + runner=root/'runner';runner.mkdir() + env=dict(os.environ,RUNNER_TEMP=runner.as_posix(),RELEASE_VERSION=version,RELEASE_SHA=SHA, + GITHUB_RUN_ID='1',GITHUB_RUN_ATTEMPT='1',GITHUB_OUTPUT=(root/'output.txt').as_posix(), + GITHUB_STEP_SUMMARY=(root/'summary.txt').as_posix(),CHERE_INVOKING='1') + shell=shutil.which('bash') + if os.name=='nt': + # Use the installed Git Bash, not the Windows WSL launcher. + shell=next((str(parent/'bin/bash.exe') for parent in Path(shutil.which('git')).parents + if (parent/'bin/bash.exe').is_file()),None) + if not shell:raise RuntimeError('Bash is required to test actual release staging') + code=script('Stage the exact audited bundle') + # Only select this test interpreter; execute the actual shell staging, + # checksum checks and summary without a legacy parent checksum file. + code=code.replace("python3 - <<'PY'",shlex.quote(Path(sys.executable).as_posix())+" - <<'PY'") + return subprocess.run([shell,'-lc',code],cwd=root,env=env,capture_output=True,text=True) + + + def test_staging_uses_only_the_promoted_six_file_bundle(self): + for version in ['2.4.7.116051','2.4.8.117011','2.4.9.118021']: + with self.subTest(version=version),tempfile.TemporaryDirectory() as d: + root=Path(d);result=self.run_staging_fixture(root,version) + self.assertEqual(0,result.returncode,result.stdout+'\n'+result.stderr) + self.assertFalse((root/'build/release/SHA256SUMS').exists()) + self.assertIn('LootableBodies-'+version+'.jar',(root/'summary.txt').read_text()) + + + def test_staging_missing_or_extra_file_fails_before_upload(self): + for mutation in ['missing','extra']: + with self.subTest(mutation=mutation),tempfile.TemporaryDirectory() as d: + root=Path(d);result=self.run_staging_fixture(root,'2.4.8.117011',mutation) + self.assertNotEqual(0,result.returncode) + self.assertIn('missing or unexpected files',result.stdout+result.stderr) + + + def test_112_artifact_audit_skips_only_explicitly_scoped_out_builds(self): + text=WORKFLOW.with_name('artifact-audit-1.12.yml').read_text() + code=text.split('def artifact_required(jobs):',1)[1].split('\n # Fetch',1)[0] + code='def artifact_required(jobs):'+code + import textwrap + code=code.replace('\n ','\n') + namespace={};exec(compile(code,'artifact-scope','exec'),namespace) + check=namespace['artifact_required'] + def job(name,conclusion,status='completed'):return dict(name=name,conclusion=conclusion,status=status) + build=job('Build, test, and audit','success') + self.assertTrue(check([build])) + skipped=[job('Check runtime inputs','success'),job('Build, test, and audit','skipped'),job('Cold Forge bootstrap','skipped')] + self.assertFalse(check(skipped)) + for invalid in [[],[build,build],skipped[1:],skipped[:2], + [dict(build,status='in_progress')], + [dict(build,conclusion='failure')], + [dict(skipped[0],conclusion='failure'),*skipped[1:]], + [*skipped[:2],dict(skipped[2],conclusion='failure')]]: + with self.subTest(invalid=invalid),self.assertRaises(ValueError):check(invalid) + self.assertIn("needs: scope",text) + self.assertIn("if: needs.scope.outputs.required == 'true'",text) + + def test_tag_at_another_commit_is_rejected(self): with self.assertRaises(SystemExit): self.validate('1.10.2', wrong_tag=True) diff --git a/.github/workflows/artifact-audit-1.12.yml b/.github/workflows/artifact-audit-1.12.yml index 3f812f9..3654925 100644 --- a/.github/workflows/artifact-audit-1.12.yml +++ b/.github/workflows/artifact-audit-1.12.yml @@ -11,13 +11,48 @@ permissions: actions: read jobs: - audit: - # Pull-request merge trees are audited inside CI. Push artifacts use the - # workflow run's exact head SHA, which is also their declared artifact name. + scope: + name: Check artifact producer scope if: >- github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && (github.event.workflow_run.head_branch == 'master-1.12.2' || github.event.workflow_run.head_branch == 'master-1.12') runs-on: ubuntu-latest + outputs: + required: ${{ steps.scope.outputs.required }} + steps: + - id: scope + env: + GH_TOKEN: ${{ github.token }} + PRODUCER_URL: ${{ github.event.workflow_run.jobs_url }} + REPOSITORY: ${{ github.repository }} + run: | + python3 - <<'PY' + import json,os,urllib.request + def artifact_required(jobs): + def exactly(name): + found=[j for j in jobs if j['name']==name] + if len(found)!=1 or found[0]['status']!='completed':raise ValueError('Missing, duplicate or pending producer job: '+name) + return found[0]['conclusion'] + build=exactly('Build, test, and audit') + if build=='success':return True + if build=='skipped' and exactly('Check runtime inputs')=='success' and exactly('Cold Forge bootstrap')=='skipped':return False + raise ValueError('Artifact producer did not succeed') + # Fetch the exact producer jobs; absent artifacts never imply a skip. + url=os.environ['PRODUCER_URL']+'?per_page=100' + if not url.startswith('https://api.github.com/repos/'+os.environ['REPOSITORY']+'/actions/runs/'):raise ValueError('Unexpected producer API URL') + request=urllib.request.Request(url,headers={'Authorization':'Bearer '+os.environ['GH_TOKEN'],'Accept':'application/vnd.github+json'}) + with urllib.request.urlopen(request,timeout=30) as response:result=json.load(response) + if result['total_count']!=len(result['jobs']):raise ValueError('Incomplete producer job list') + required=artifact_required(result['jobs']) + with open(os.environ['GITHUB_OUTPUT'],'a') as output:output.write('required='+str(required).lower()+'\n') + print('Artifact audit required:',required) + PY + audit: + # Successful builds always require their exact artifact. A dispatcher-only + # change has an explicitly skipped producer and has no release bytes to audit. + needs: scope + if: needs.scope.outputs.required == 'true' + runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/.github/workflows/artifact-audit-1.18.yml b/.github/workflows/artifact-audit-1.18.yml new file mode 100644 index 0000000..e14f0b5 --- /dev/null +++ b/.github/workflows/artifact-audit-1.18.yml @@ -0,0 +1,48 @@ +name: Audit LootableBodies 1.18 artifacts +run-name: Audit 1.18.2 artifacts from ${{ github.event.workflow_run.head_sha }} +on: + workflow_run: + workflows: [LootableBodies 1.18 CI] + types: [completed] +permissions: + contents: read + actions: read +jobs: + audit: + if: >- + github.repository == 'MinecraftModDevelopmentMods/LootableBodies' && + github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'master-1.18.2' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.workflow_run.head_sha }} + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: LootableBodies-1.18.2-${{ github.event.workflow_run.head_sha }} + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + path: audited + - name: Independently verify every locally qualified byte + env: + SOURCE_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + python3 -B tools/native118_build.py --bundle audited + python3 - <<'PY' + import json,os + from pathlib import Path + accepted=json.loads(Path('.github/release118.json').read_text()) + Path('independent-audit.json').write_text(json.dumps({'source':os.environ['SOURCE_SHA'],'files':accepted['files'],'all_six_exact':True,'identical_to_locally_audited_bundle':True},indent=2)+'\n') + PY + # Exact hashes reuse the local class61/licence/identity/mapping audit. + # This independent receipt is a byte match, not a fresh gameplay run or + # another Gradle/Forge preparation. The build job audits new archives. + - name: Retain independent audit evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: native118-independent-audit-${{ github.event.workflow_run.head_sha }} + if-no-files-found: error + path: independent-audit.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2d9f62c..e64403e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,6 @@ on: push: branches: - master-1.16.5 - - 'feature/**' pull_request: branches: - master-1.16.5 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index e1a3001..7fd5b0f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -3,11 +3,11 @@ name: CodeQL on: push: branches: - - master-1.17.1 + - master-1.18.2 - 'feature/**' pull_request: branches: - - master-1.17.1 + - master-1.18.2 schedule: - cron: '43 7 * * 4' @@ -17,11 +17,12 @@ permissions: security-events: write concurrency: - group: lootablebodies-1.17-codeql-${{ github.ref }} + group: lootablebodies-1.18-codeql-${{ github.ref }} cancel-in-progress: true jobs: analyze: + if: github.repository == 'MinecraftModDevelopmentMods/LootableBodies' name: Analyze Java runs-on: ubuntu-latest timeout-minutes: 45 @@ -36,21 +37,15 @@ jobs: distribution: temurin java-version: '25.0.3+9.0.LTS' - - name: Install pinned Java 16 compilation toolchain + - name: Install pinned Java 17 Gradle and compilation runtime uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin - java-version: '16.0.2+7' - - - name: Install pinned Java 17 Gradle runtime - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 - with: - distribution: temurin - java-version: '17.0.1+12' + java-version: '17.0.20.1+1' - name: Pin production JDK run: | - echo "LOOTABLEBODIES_JAVA16_HOME=$JAVA_HOME_16_X64" >> "$GITHUB_ENV" + echo "LOOTABLEBODIES_JAVA17_HOME=$JAVA_HOME_17_X64" >> "$GITHUB_ENV" echo "LOOTABLEBODIES_MAVENIZER_JAVA_HOME=$JAVA_HOME_25_X64" >> "$GITHUB_ENV" - name: Set up Gradle @@ -68,7 +63,7 @@ jobs: gradle_args=( clean classes --no-build-cache --rerun-tasks --no-daemon --stacktrace --max-workers=2 - -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_16_X64,$JAVA_HOME_25_X64" + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_25_X64" -Dorg.gradle.java.installations.auto-detect=false -Dorg.gradle.java.installations.auto-download=false ) diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index a87ebd1..45ecf5e 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: inputs: release_version: - description: Complete version from gradle.properties, ending in 110021, 111021, 112021, 113021, 114041, 115021, 116051 or 117011. + description: Complete version from gradle.properties, ending in 110021, 111021, 112021, 113021, 114041, 115021, 116051, 117011 or 118021. required: true type: string curseforge_release_level: @@ -20,7 +20,7 @@ on: default: false type: boolean local_qualification: - description: Hash-bound local qualification receipt, required for the 1.17.1 release. + description: Hash-bound local qualification receipt, required for the 1.17.1 and 1.18.2 releases. required: false default: '' type: string @@ -54,7 +54,7 @@ jobs: set -euo pipefail [[ "$GITHUB_REPOSITORY" == 'MinecraftModDevelopmentMods/LootableBodies' ]] [[ "$GITHUB_REF" == 'refs/heads/master-1.12' ]] - [[ "$RELEASE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(110021|111021|112021|113021|114041|115021|116051|117011)$ ]] + [[ "$RELEASE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(110021|111021|112021|113021|114041|115021|116051|117011|118021)$ ]] case "$RELEASE_VERSION" in *.110021) minecraft_version=1.10.2; source_branch=1.10.2 ;; *.111021) minecraft_version=1.11.2; source_branch=1.11.2-dev ;; @@ -64,6 +64,7 @@ jobs: *.115021) minecraft_version=1.15.2; source_branch=master-1.15.2 ;; *.116051) minecraft_version=1.16.5; source_branch=master-1.16.5 ;; *.117011) minecraft_version=1.17.1; source_branch=master-1.17.1 ;; + *.118021) minecraft_version=1.18.2; source_branch=master-1.18.2 ;; *) exit 1 ;; esac echo "minecraft_version=$minecraft_version" >> "$GITHUB_OUTPUT" @@ -96,14 +97,17 @@ jobs: '1.14.4': ('master-1.14.4', '28.2.26', '114041'), '1.15.2': ('master-1.15.2', '31.2.57', '115021'), '1.16.5': ('master-1.16.5', '36.2.42', '116051'), - '1.17.1': ('master-1.17.1', '37.1.1', '117011')} + '1.17.1': ('master-1.17.1', '37.1.1', '117011'), + '1.18.2': ('master-1.18.2', '40.3.12', '118021')} branch, forge, suffix = targets[minecraft] if os.environ['SOURCE_BRANCH'] != branch: raise SystemExit('The selected branch does not match its Minecraft version') native17 = minecraft == '1.17.1' + native18 = minecraft == '1.18.2' + locally_qualified = native17 or native18 expected = {'minecraft_version': minecraft, 'forge_version': forge, - 'loader_name': 'forge', 'loader_code': '1', 'java_version': '16' if native17 else '8', - 'java_toolchain_version': '16.0.2+7' if native17 else '8.0.502+7', 'gradle_java_version': '17', + 'loader_name': 'forge', 'loader_code': '1', 'java_version': '17' if native18 else '16' if native17 else '8', + 'java_toolchain_version': '17.0.20.1+1' if native18 else '16.0.2+7' if native17 else '8.0.502+7', 'gradle_java_version': '17', 'mod_id': 'lootablebodies', 'mod_artifact': 'LootableBodies', 'mod_group': 'zone.moddev.mc.lootablebodies', 'mod_license': 'LGPL-2.1-only', 'curseforge_project_id': '240968'} @@ -131,8 +135,10 @@ jobs: required.update(['Validate release workflow', 'Prepare verified migration inputs', 'World upgrade matrix'] + ['Historical world shard ' + str(i) for i in range(6 if minecraft == '1.15.2' else 12)]) qualification_run = '' - if native17: + if locally_qualified: required = {'Build, test, and audit', 'Analyze Java', 'Validation', 'Validate release workflow'} + if native18: + required = {'Check hosted work policy', 'Build and audit locally qualified archives', 'Analyze Java', 'Validation', 'Validate release workflow'} # The authorised local controller rehashes actual gameplay proof # before dispatch. Hosted CI verifies its exact source/artifact # binding; it does not rerun games or claim a fresh gameplay pass. @@ -141,7 +147,11 @@ jobs: except (ValueError,TypeError): raise SystemExit('A local qualification receipt is required') keys={'schema','source','version','files','evidence_sha256','qualified_locally','manual_accepted'} - accepted=json.loads(Path('.github/release117.json').read_text())['files'] + target_code='118' if native18 else '117' + metadata=json.loads(Path(f'.github/release{target_code}.json').read_text()) + accepted=metadata['files'] + if native18 and (metadata.get('local_qualification_complete') is not True or metadata.get('qualification_integrity_status')!='verified'): + raise SystemExit('The local 1.18.2 qualification declaration is incomplete') if (not isinstance(local,dict) or set(local)!=keys or type(local['schema']) is not int or local['schema']!=1 or local['source']!=sha or local['version']!=version or local['files']!=accepted or local['qualified_locally'] is not True or local['manual_accepted'] is not True @@ -149,17 +159,17 @@ jobs: or local['evidence_sha256']=='0'*64): raise SystemExit('Local qualification is missing or belongs to another source/artifact bundle') runs = json.loads(subprocess.check_output(['gh', 'api', - f'repos/{repo}/actions/workflows/native117.yml/runs?event=push&branch=master-1.17.1&head_sha={sha}&per_page=100'], text=True))['workflow_runs'] - exact = [r for r in runs if r['head_sha'] == sha and r['head_branch'] == 'master-1.17.1' - and r['event'] == 'push' and r['path'] == '.github/workflows/native117.yml'] + f'repos/{repo}/actions/workflows/native{target_code}.yml/runs?event=push&branch={branch}&head_sha={sha}&per_page=100'], text=True))['workflow_runs'] + exact = [r for r in runs if r['head_sha'] == sha and r['head_branch'] == branch + and r['event'] == 'push' and r['path'] == f'.github/workflows/native{target_code}.yml'] if not exact or max(exact, key=lambda r:r['id'])['conclusion'] != 'success': - raise SystemExit('No successful exact-merge native117 build and unit checks') + raise SystemExit('No successful exact-merge locally qualified artifact build') qualification_run = str(max(exact, key=lambda r:r['id'])['id']) for name in required: matching = [check for check in checks if check['name'] == name] if not matching or max(matching, key=lambda c: c['id'])['conclusion'] != 'success': raise SystemExit(f'The selected commit has no successful {name} check') - if minecraft in ('1.11.2', '1.12.2', '1.13.2', '1.14.4', '1.15.2', '1.16.5', '1.17.1'): + if minecraft in ('1.11.2', '1.12.2', '1.13.2', '1.14.4', '1.15.2', '1.16.5', '1.17.1', '1.18.2'): target = minecraft.rsplit('.', 1)[0] audits = json.loads(subprocess.check_output(['gh', 'api', f'repos/{repo}/actions/workflows/artifact-audit-{target}.yml/runs?event=workflow_run&per_page=100'], text=True))['workflow_runs'] @@ -193,29 +203,36 @@ jobs: with: ref: ${{ needs.preflight.outputs.release_sha }} - name: Install Mavenizer Java + if: needs.preflight.outputs.minecraft_version != '1.18.2' uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin java-version: '25.0.3+9.0.LTS' - name: Install production Java + if: needs.preflight.outputs.minecraft_version != '1.18.2' uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin - java-version: ${{ needs.preflight.outputs.minecraft_version == '1.17.1' && '16.0.2+7' || '8.0.502+7' }} + java-version: ${{ needs.preflight.outputs.minecraft_version == '1.18.2' && '17.0.20.1+1' || needs.preflight.outputs.minecraft_version == '1.17.1' && '16.0.2+7' || '8.0.502+7' }} - name: Install Gradle Java + if: needs.preflight.outputs.minecraft_version != '1.18.2' uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin - java-version: '17.0.1+12' + java-version: ${{ needs.preflight.outputs.minecraft_version == '1.18.2' && '17.0.20.1+1' || '17.0.1+12' }} - name: Pin the production toolchain + if: needs.preflight.outputs.minecraft_version != '1.18.2' run: | - if [ '${{ needs.preflight.outputs.minecraft_version }}' = '1.17.1' ]; then + if [ '${{ needs.preflight.outputs.minecraft_version }}' = '1.18.2' ]; then + echo "LOOTABLEBODIES_JAVA17_HOME=$JAVA_HOME_17_X64" >> "$GITHUB_ENV" + elif [ '${{ needs.preflight.outputs.minecraft_version }}' = '1.17.1' ]; then echo "LOOTABLEBODIES_JAVA16_HOME=$JAVA_HOME_16_X64" >> "$GITHUB_ENV" else echo "LOOTABLEBODIES_JAVA8_HOME=$JAVA_HOME_8_X64" >> "$GITHUB_ENV" fi echo "LOOTABLEBODIES_MAVENIZER_JAVA_HOME=$JAVA_HOME_25_X64" >> "$GITHUB_ENV" - name: Set up Gradle + if: needs.preflight.outputs.minecraft_version != '1.18.2' uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 - name: Download the qualified native117 six-file bundle if: needs.preflight.outputs.minecraft_version == '1.17.1' @@ -237,8 +254,19 @@ jobs: PY chmod +x ./gradlew ./gradlew verifyPreparedReleaseArtifacts -PpreparedReleaseDir=build/release/bundle --no-daemon --stacktrace + - name: Download the qualified native118 six-file bundle + if: needs.preflight.outputs.minecraft_version == '1.18.2' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: LootableBodies-1.18.2-${{ needs.preflight.outputs.release_sha }} + run-id: ${{ needs.preflight.outputs.qualification_run }} + github-token: ${{ github.token }} + path: build/release/bundle + - name: Verify all six native118 bytes before publication + if: needs.preflight.outputs.minecraft_version == '1.18.2' + run: python3 -B tools/native118_build.py --bundle build/release/bundle - name: Build, test and audit the release - if: needs.preflight.outputs.minecraft_version != '1.17.1' + if: needs.preflight.outputs.minecraft_version != '1.17.1' && needs.preflight.outputs.minecraft_version != '1.18.2' run: | chmod +x ./gradlew ./gradlew clean build javadoc verifyEclipseProductionClasspath prepareReleaseBundle \ @@ -270,7 +298,7 @@ jobs: { echo "Prepared Lootable Bodies $RELEASE_VERSION from commit $RELEASE_SHA." echo - cat build/release/SHA256SUMS + cat "$RUNNER_TEMP/release-bundle/SHA256SUMS" } >> "$GITHUB_STEP_SUMMARY" - name: Upload the immutable release bundle uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -343,15 +371,17 @@ jobs: uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin - java-version: ${{ needs.preflight.outputs.minecraft_version == '1.17.1' && '16.0.2+7' || '8.0.502+7' }} + java-version: ${{ needs.preflight.outputs.minecraft_version == '1.18.2' && '17.0.20.1+1' || needs.preflight.outputs.minecraft_version == '1.17.1' && '16.0.2+7' || '8.0.502+7' }} - name: Install Gradle Java uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin - java-version: '17.0.1+12' + java-version: ${{ needs.preflight.outputs.minecraft_version == '1.18.2' && '17.0.20.1+1' || '17.0.1+12' }} - name: Pin the production toolchain run: | - if [ '${{ needs.preflight.outputs.minecraft_version }}' = '1.17.1' ]; then + if [ '${{ needs.preflight.outputs.minecraft_version }}' = '1.18.2' ]; then + echo "LOOTABLEBODIES_JAVA17_HOME=$JAVA_HOME_17_X64" >> "$GITHUB_ENV" + elif [ '${{ needs.preflight.outputs.minecraft_version }}' = '1.17.1' ]; then echo "LOOTABLEBODIES_JAVA16_HOME=$JAVA_HOME_16_X64" >> "$GITHUB_ENV" else echo "LOOTABLEBODIES_JAVA8_HOME=$JAVA_HOME_8_X64" >> "$GITHUB_ENV" diff --git a/.github/workflows/migration-matrix.yml b/.github/workflows/migration-matrix.yml index cc04a56..d576dfe 100644 --- a/.github/workflows/migration-matrix.yml +++ b/.github/workflows/migration-matrix.yml @@ -5,7 +5,6 @@ on: branches: [master-1.16.5] pull_request: branches: [master-1.16.5] - workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/native118.yml b/.github/workflows/native118.yml new file mode 100644 index 0000000..7134c59 --- /dev/null +++ b/.github/workflows/native118.yml @@ -0,0 +1,78 @@ +name: LootableBodies 1.18 CI + +on: + push: + branches: [master-1.18.2] + pull_request: + branches: [master-1.18.2] + +permissions: + contents: read + +concurrency: + group: native118-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + PYTHONDONTWRITEBYTECODE: '1' + +defaults: + run: + shell: bash + +jobs: + policy: + name: Check hosted work policy + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Reject hosted test and runtime commands + run: python3 -B tools/hosted_policy.py + build: + name: Build and audit locally qualified archives + needs: policy + if: github.repository == 'MinecraftModDevelopmentMods/LootableBodies' + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Require local qualification before build preparation + run: | + set -euo pipefail + python3 -B tools/hosted_policy.py --require-qualified-manifest 2>&1 | tee "$RUNNER_TEMP/native118-build.log" + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17.0.20.1+1' + # Full Java/Python suites, games, IDE consumers and SDK installs stay local. + # Fork pushes run policy checks only. MMD builds independently check bytes. + - name: Compile and audit release archives + env: + LOOTABLEBODIES_JAVA17_HOME: ${{ env.JAVA_HOME_17_X64 }} + LOOTABLEBODIES_MAVENIZER_JAVA_HOME: ${{ env.JAVA_HOME_25_X64 }} + run: | + set -euo pipefail + chmod +x gradlew + ./gradlew assemble verifyProductionRuntimeClasspath prepareReleaseBundle --no-daemon --no-build-cache --stacktrace 2>&1 | tee -a "$RUNNER_TEMP/native118-build.log" + python3 -B tools/native118_build.py 2>&1 | tee -a "$RUNNER_TEMP/native118-build.log" + - name: Retain the exact six release files + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: LootableBodies-1.18.2-${{ github.sha }} + if-no-files-found: error + path: build/release/bundle/* + - name: Retain build evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: native118-fast-${{ github.sha }} + if-no-files-found: error + path: | + build/reports/** + build/release/bundle/* + ${{ runner.temp }}/native118-build.log diff --git a/.github/workflows/release-workflow-checks.yml b/.github/workflows/release-workflow-checks.yml index 00778d8..0589017 100644 --- a/.github/workflows/release-workflow-checks.yml +++ b/.github/workflows/release-workflow-checks.yml @@ -1,9 +1,9 @@ name: Validate release workflow on: push: - branches: ['feature/**', 'master-1.17.1'] + branches: ['feature/**', 'master-1.18.2'] pull_request: - branches: ['master-1.17.1'] + branches: ['master-1.18.2'] permissions: contents: read jobs: @@ -25,6 +25,7 @@ jobs: run: >- "$RUNNER_TEMP/actionlint/actionlint" -shellcheck= -pyflakes= .github/workflows/deploy-release.yml .github/workflows/release-workflow-checks.yml .github/workflows/artifact-audit-1.13.yml .github/workflows/artifact-audit-1.14.yml .github/workflows/artifact-audit-1.15.yml .github/workflows/artifact-audit-1.16.yml .github/workflows/artifact-audit-1.17.yml + .github/workflows/native118.yml .github/workflows/artifact-audit-1.18.yml .github/workflows/codeql-analysis.yml - name: Test version selection and publication checks run: python3 .github/scripts/test_release_workflow.py -v diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 5dba50a..cc6cad2 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,6 +1,6 @@ -2.4.8.117011 - Minecraft 1.17.1, Forge +2.4.9.118021 - Minecraft 1.18.2, Forge -Updated for Minecraft 1.17.1. +Updated for Minecraft 1.18.2. Preserves bodies and their contents when upgrading older worlds. Back up the whole world before upgrading. diff --git a/README.md b/README.md index 7240e20..f54d434 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# Lootable Bodies for Minecraft 1.17.1 +# Lootable Bodies for Minecraft 1.18.2 Your items stay on a corpse when you die. Right-click the body to recover them. -Use Forge 37.1.1 and Java 16. Install `LootableBodies-2.4.8.117011.jar` +Use Forge 40.3.12 and Java 17. Install `LootableBodies-2.4.9.118021.jar` in the client and server mods folders. Back up your world before upgrading. Created by Cyanobacterium (DrCyano), maintained by SkyBlade1978. @@ -12,7 +12,7 @@ Open Mods, select Lootable Bodies and click Config to change its settings. Appearance changes apply straight away on your client. World and server rules take effect after restarting Minecraft or the server. -Existing 1.10.2, 1.11.2, 1.12.2, 1.13.2, 1.14.4, 1.15.2 and 1.16.5 corpses are preserved when upgrading, +Existing 1.10.2, 1.11.2, 1.12.2, 1.13.2, 1.14.4, 1.15.2, 1.16.5 and 1.17.1 corpses are preserved when upgrading, including bodies in chunks you visit later. Keep the whole world backup, including its `data` folder. If preservation cannot finish safely, world loading stops with an explanation in the log. Items from missing mods are retained for diff --git a/RELEASING.md b/RELEASING.md index 05a5a66..6ab4769 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,8 +1,8 @@ # Releasing Gameplay, graphical clients and world migration checks run locally before -publishing. GitHub Actions runs the unit tests, build, archive checks and security -checks. It does not install a separate game runtime or run the gameplay suite. +publishing. Full Java/Python suites run locally. GitHub Actions runs production builds, +archive checks, security scans and small workflow/wrapper contracts. It does not install a separate game runtime or run the gameplay suite. 1. Qualify the packaged release locally, including the supported historical worlds, refusal cases, client/server packets, graphics, restart and IDE checks. @@ -13,11 +13,11 @@ checks. It does not install a separate game runtime or run the gameplay suite. actual artifacts, runtime, fixtures and relevant code are unchanged. Record that reuse explicitly, and test changed inputs before accepting them. Keep failed runs; do not replace them with later results. -3. Review the diff and merge after the unit, build, archive and security checks - pass. Recheck the exact merged source and all six file hashes. The independent +3. Review the diff and merge after local qualification and hosted build, archive + and security checks pass. Recheck the exact merged source and all six file hashes. The independent artifact audit checks the uploaded files without rebuilding them. 4. Rehash the retained local evidence and supply its receipt to the guarded - release workflow. For 1.17.1 the receipt binds the exact source commit, version, + release workflow. For 1.17.1 and 1.18.2 the receipt binds the exact source commit, version, six file hashes, evidence hash and manual acceptance. Publish the files already checked by CI, then independently download and compare the public GitHub files and CurseForge jar. An accepted upload alone does not complete the release. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..eea0383 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,53 @@ +# Testing + +Run functional qualification locally before publishing. Hosted jobs run small +workflow/wrapper contracts, production builds, archive checks and CodeQL security +analysis. They do not run the full Java/Python suites, Minecraft, graphical +clients, migration worlds, runtime SDK installers or IDE consumers. Fork pushes +get the cheap policy check; the MMD build independently checks the six release +files. Compiler dependencies needed by ForgeGradle are still build inputs. + +During development, reproduce the changed behaviour first, then batch the cheap +Java and Python checks. Reserve the full runtime suite for a frozen candidate. +`build`/`check` no longer starts Minecraft implicitly. The mapped +fresh/reload/deferred gate remains available through `localRuntimeVerification` +and is still required by complete local qualification. + +For a frozen candidate: + +1. Run the full Java suite once and the full Python suite once for their exact + input identities. Complete archive, metadata, licence and isolation checks. +2. Make two clean uncached production builds and compare all six files. The + second build compiles and audits production outputs; it does not repeat the + unchanged unit/game suites. Seal packaged probes, native controls and logging + inputs before a later `clean` removes their build outputs. +3. Prepare and seal genuine historical writers/worlds once. Run the known + recovery/terrain/entity-region failure origins first and count these runs in + the full matrix. Stop later work after a genuine failure; do not hunt green. +4. Complete the current 47-origin, 94-case, ten-phase history contract, all + refusals, packaged/development launches, five graphical modes, configuration + restart, rejected viewer, actual two-client inventory packets, 13 visibility + checks and 64 respawns. Keep both 6,200 ticks and 310 seconds and the connected + restart. Keep the real native controls, Eclipse and Java 21 Tooling checks. +5. Independently check complete logs, crashes, images, diagnostics, receipts, + original-world seals and every published byte. Missing, changed or unreadable + evidence blocks reuse and publication. Reused proof keeps its original run + identity; a different commit or event does not turn it into a fresh run. + +Unchanged mod-free resource/protocol controls can be reused only with the same +control, SDK, JDK, flags, resources and auditor contract. Changes to those inputs +require new control qualification. Changes confined to a test fixture require +the affected proof and tool contracts, not an automatic replay of unrelated +passed groups. A changed production migration path still requires its full +applicable migration coverage. + +Two cold-restart consolidations are under review. They remain **disabled**: +recovering near inventory during the first cold near reload, and removing the +empty corpse during the all-wave return. Neither may change the ten-phase +acceptance contract until real disk-equivalence and deliberate +loss/duplication/resurrection/deferred-chunk controls pass on a later authorised +run. Do not shorten waits, remove origins or reduce refusals to save time. + +`tools/qualification-policy.json` records the policy. Validate changes to the +hosted guards, workflow definitions and release contracts locally before pushing +or publishing. Keep the accepted files and their original evidence identities. diff --git a/build.gradle b/build.gradle index 1448b54..c4b6e76 100644 --- a/build.gradle +++ b/build.gradle @@ -14,18 +14,18 @@ group = project.mod_group version = project.mod_version base.archivesName = project.mod_artifact -// Gradle uses Java 17; every compiler, test and game process uses this JDK 16. +// Gradle uses Java 17; every compiler, test and game process uses this JDK 17. // Eclipse does not inherit the command-line helper's environment. Keep its // machine-specific toolchain selection in an ignored local properties file. Properties localJavaSetup = new Properties() File localJavaSetupFile = file('gradle-local.properties') if (localJavaSetupFile.isFile()) localJavaSetupFile.withInputStream { localJavaSetup.load(it) } -def pinnedJava16 = providers.environmentVariable('LOOTABLEBODIES_JAVA16_HOME') - .orElse(providers.gradleProperty('lootablebodies.java16.home')) - .orElse(providers.provider { localJavaSetup.getProperty('lootablebodies.java16.home') }) -def exactJava16Spec = pinnedJava16.isPresent() ? +def pinnedJava17 = providers.environmentVariable('LOOTABLEBODIES_JAVA17_HOME') + .orElse(providers.gradleProperty('lootablebodies.java17.home')) + .orElse(providers.provider { localJavaSetup.getProperty('lootablebodies.java17.home') }) +def exactJava17Spec = pinnedJava17.isPresent() ? org.gradle.jvm.toolchain.internal.SpecificInstallationToolchainSpec.fromJavaHome( - project.services.get(org.gradle.api.internal.provider.PropertyFactory), file(pinnedJava16.get())) : null + project.services.get(org.gradle.api.internal.provider.PropertyFactory), file(pinnedJava17.get())) : null def pinnedJava25 = providers.environmentVariable('LOOTABLEBODIES_MAVENIZER_JAVA_HOME') .orElse(providers.gradleProperty('lootablebodies.mavenizer.java.home')) .orElse(providers.provider { localJavaSetup.getProperty('lootablebodies.mavenizer.java.home') }) @@ -37,18 +37,18 @@ ext.mavenizerLauncher = exactJava25Spec != null ? javaToolchains.launcherFor(exa fgtools.configure('mavenizer') { javaLauncher.set(mavenizerLauncher) } java { toolchain { - languageVersion = JavaLanguageVersion.of(16) + languageVersion = JavaLanguageVersion.of(17) vendor = JvmVendorSpec.ADOPTIUM } withSourcesJar() withJavadocJar() } -ext.productionLauncher = exactJava16Spec != null ? javaToolchains.launcherFor(exactJava16Spec) : +ext.productionLauncher = exactJava17Spec != null ? javaToolchains.launcherFor(exactJava17Spec) : javaToolchains.launcherFor(java.toolchain) fgtools.configure('slimelauncher') { javaLauncher.set(productionLauncher) } -def productionCompiler = exactJava16Spec != null ? javaToolchains.compilerFor(exactJava16Spec) : +def productionCompiler = exactJava17Spec != null ? javaToolchains.compilerFor(exactJava17Spec) : javaToolchains.compilerFor(java.toolchain) -def productionJavadoc = exactJava16Spec != null ? javaToolchains.javadocToolFor(exactJava16Spec) : +def productionJavadoc = exactJava17Spec != null ? javaToolchains.javadocToolFor(exactJava17Spec) : javaToolchains.javadocToolFor(java.toolchain) tasks.register('verifyJavaSetup') { @@ -58,9 +58,9 @@ tasks.register('verifyJavaSetup') { def metadata = productionLauncher.get().metadata Properties release = new Properties() metadata.installationPath.file('release').asFile.withInputStream { release.load(it) } - if (release.getProperty('SEMANTIC_VERSION') != '"16.0.2+7"' || - release.getProperty('IMPLEMENTOR') != '"Eclipse Foundation"') { - throw new GradleException("Production requires Temurin 16.0.2+7, found ${metadata.javaRuntimeVersion} ${metadata.vendor}") + if (release.getProperty('SEMANTIC_VERSION') != '"17.0.20.1+1"' || + release.getProperty('IMPLEMENTOR') != '"Eclipse Adoptium"') { + throw new GradleException("Production requires Temurin 17.0.20.1+1, found ${metadata.javaRuntimeVersion} ${metadata.vendor}") } logger.lifecycle("Gradle Java: ${System.getProperty('java.runtime.version')}; production: ${metadata}") def worker = mavenizerLauncher.get().metadata @@ -69,14 +69,14 @@ tasks.register('verifyJavaSetup') { if (workerRelease.getProperty('JAVA_RUNTIME_VERSION') != '"25.0.3+9-LTS"' || workerRelease.getProperty('IMPLEMENTOR') != '"Eclipse Adoptium"') throw new GradleException('The separate Mavenizer worker requires Temurin 25.0.3+9-LTS') - logger.lifecycle("Mavenizer worker: ${worker}; game/compilation remain on Java 16") + logger.lifecycle("Mavenizer worker: ${worker}; game/compilation remain on Java 17") } } tasks.withType(JavaCompile).configureEach { dependsOn 'verifyJavaSetup' javaCompiler = productionCompiler - sourceCompatibility = '16' - targetCompatibility = '16' + sourceCompatibility = '17' + targetCompatibility = '17' options.encoding = 'UTF-8' options.compilerArgs.addAll(['-Xmaxerrs','1000']) } @@ -91,10 +91,17 @@ tasks.withType(Test).configureEach { // The full test task retains its archive/isolation prerequisites and all tests. tasks.register('nativeBoundaryTest', Test) { group = 'verification' - description = 'Runs native 1.17 conversion and storage regressions without launching Minecraft.' + description = 'Runs native conversion, storage and early-read regressions without launching Minecraft.' testClassesDirs = sourceSets.test.output.classesDirs classpath = sourceSets.test.runtimeClasspath - include '**/Native117*Test.class' + include '**/Native117*Test.class', '**/Native118*Test.class', '**/EarlyWorldPreservationTest.class', '**/HookRegistrationOrderTest.class' +} +tasks.register('generateNative118Fixtures', JavaExec) { + group = 'verification' + dependsOn 'testClasses' + classpath = sourceSets.test.runtimeClasspath + mainClass = 'cyano.lootable.buildtest.Native118FixtureGenerator' + args providers.gradleProperty('nativeFixtureOutput').getOrElse('build/native118-fixture-preparation-01') } tasks.withType(JavaExec).configureEach { javaLauncher = productionLauncher } tasks.withType(Javadoc).configureEach { @@ -189,8 +196,11 @@ dependencies { implementation minecraft.dependency("net.minecraftforge:forge:${minecraft_version}-${forge_version}") testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2' testImplementation 'org.junit.platform:junit-platform-launcher:1.12.2' + testImplementation 'org.ow2.asm:asm-util:9.7.1' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.12.2' + testRuntimeOnly 'org.openjdk.nashorn:nashorn-core:15.4' + testRuntimeOnly 'net.minecraftforge:coremods:5.2.5' } tasks.named('processResources', ProcessResources) { @@ -211,7 +221,7 @@ tasks.named('jar', Jar) { 'Implementation-Version': project.version, 'Implementation-Vendor': project.mod_vendor, 'Maven-Artifact': "${project.group}:${base.archivesName.get()}:${project.version}", 'Implementation-License': project.mod_license, - 'Built-On-Java': '16', 'Built-On': "${minecraft_version}-${forge_version}", + 'Built-On-Java': '17', 'Built-On': "${minecraft_version}-${forge_version}", 'FMLAT': 'accesstransformer.cfg') filesMatching(archiveTextPatterns, normalizeArchiveText) } @@ -232,8 +242,8 @@ ext.releaseJar = renamer.classes(tasks.named('jar', Jar)) { filesMatching(archiveTextPatterns, normalizeArchiveText) } } -// Java 16 writes this extensionless index with the host line separator. Keep -// the accepted 1.17 documentation bytes identical on Windows and Linux. +// Java 17 writes this extensionless index with the host line separator. Keep +// the 1.18 documentation bytes identical on Windows and Linux. tasks.named('javadocJar', Jar) { filesMatching('element-list') { filter(FixCrLfFilter, eol: FixCrLfFilter.CrLf.newInstance('crlf'), diff --git a/gradle.properties b/gradle.properties index 159593e..6538a42 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,16 +5,16 @@ org.gradle.caching=true org.gradle.parallel=false org.gradle.workers.max=2 net.minecraftforge.gradle.merge-source-sets=false -minecraft_version=1.17.1 -forge_version=37.1.1 +minecraft_version=1.18.2 +forge_version=40.3.12 mapping_channel=official -mapping_version=1.17.1 +mapping_version=1.18.2 # Release identity and dispatcher target metadata. loader_name=forge loader_code=1 -java_version=16 -java_toolchain_version=16.0.2+7 +java_version=17 +java_toolchain_version=17.0.20.1+1 gradle_java_version=17 curseforge_project_id=240968 @@ -25,7 +25,7 @@ mod_vendor=Cyanobacterium mod_license=LGPL-2.1-only mod_license_name=GNU Lesser General Public License, Version 2.1 mod_license_url=https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt -mod_version=2.4.8.117011 +mod_version=2.4.9.118021 mod_group=zone.moddev.mc.lootablebodies mod_authors=Cyanobacterium,SkyBlade1978 mod_description=Saves your items on a lootable corpse when you die. @@ -33,4 +33,4 @@ mod_url=https://github.com/MinecraftModDevelopmentMods/LootableBodies mod_scm_connection=scm:git:https://github.com/MinecraftModDevelopmentMods/LootableBodies.git mod_scm_developer_connection=scm:git:ssh://git@github.com/MinecraftModDevelopmentMods/LootableBodies.git -mcp_version=20210706.113038 +mcp_version=20220404.173914 diff --git a/gradle/assets.gradle b/gradle/assets.gradle index d297410..c09a051 100644 --- a/gradle/assets.gradle +++ b/gradle/assets.gradle @@ -1,8 +1,15 @@ // Keep development launches independent of any installed Minecraft instance. ext.minecraftGameHome = layout.projectDirectory.dir('.gradle/minecraft-home') +// Buildship refresh has no command-line -P arguments. Retain a prepared local +// client location rather than requiring its assets/natives to be copied. +Properties localMinecraftSetup = new Properties() +File localMinecraftSetupFile = file('gradle-local.properties') +if (localMinecraftSetupFile.isFile()) localMinecraftSetupFile.withInputStream { localMinecraftSetup.load(it) } String assetFolder=org.gradle.internal.os.OperatingSystem.current().isMacOsX()?'Library/Application Support/minecraft/assets':'.minecraft/assets' -ext.minecraftAssets = providers.gradleProperty('minecraftAssetsDirectory').isPresent() ? - layout.projectDirectory.dir(providers.gradleProperty('minecraftAssetsDirectory').get()) : minecraftGameHome.dir(assetFolder) +ext.minecraftAssets = layout.projectDirectory.dir(providers.gradleProperty('minecraftAssetsDirectory') + .getOrElse(localMinecraftSetup.getProperty('minecraftAssetsDirectory',minecraftGameHome.dir(assetFolder).asFile.absolutePath))) +ext.minecraftNatives = layout.projectDirectory.dir(providers.gradleProperty('minecraftNativesDirectory') + .getOrElse(localMinecraftSetup.getProperty('minecraftNativesDirectory','.gradle/native118-client-inputs/natives'))) tasks.register('prepareMinecraftAssets') { group = 'ide' doLast { @@ -17,10 +24,10 @@ tasks.register('prepareMinecraftAssets') { java.nio.file.Files.move(pending.toPath(),output.toPath(),java.nio.file.StandardCopyOption.REPLACE_EXISTING) } } - File descriptor=new File(root,'1.17.1.json') - download('https://piston-meta.mojang.com/v1/packages/e0e7ab5ed6f55bbd874ef95be3c9356d67e64b57/1.17.1.json','e0e7ab5ed6f55bbd874ef95be3c9356d67e64b57',descriptor) + File descriptor=new File(root,'1.18.2.json') + download('https://piston-meta.mojang.com/v1/packages/334b33fcba3c9be4b7514624c965256535bd7eba/1.18.2.json','334b33fcba3c9be4b7514624c965256535bd7eba',descriptor) def version=new groovy.json.JsonSlurper().parse(descriptor) - File index=new File(root,'indexes/1.17.json') + File index=new File(root,'indexes/1.18.json') download(version.assetIndex.url,version.assetIndex.sha1,index) Set hashes=new groovy.json.JsonSlurper().parse(index).objects.values().collect {it.hash} as Set hashes.each {hash -> diff --git a/gradle/audit.gradle b/gradle/audit.gradle index 25ae12e..d373def 100644 --- a/gradle/audit.gradle +++ b/gradle/audit.gradle @@ -33,7 +33,7 @@ ext.auditReleaseArchives = { File directory -> if (!path.startsWith('cyano/lootable/')) throw new GradleException("Foreign production class ${path}") byte[] bytes = zip.getInputStream(entry).bytes int major = ((bytes[6] & 255) << 8) | (bytes[7] & 255) - if (major != 60) throw new GradleException("${path} is class major ${major}") + if (major != 61) throw new GradleException("${path} is class major ${major}") } if (path.endsWith('.lang')) { java.nio.charset.StandardCharsets.UTF_8.newDecoder().decode( @@ -55,16 +55,22 @@ ext.auditReleaseArchives = { File directory -> throw new GradleException("Incorrect release manifest in ${name}") if (index == 0) { ['META-INF/mods.toml', 'pack.mcmeta', 'cyano/lootable/LootableBodies.class', + 'META-INF/coremods.json', 'coremods/early-world-preservation.js', + 'cyano/lootable/upgrade/EarlyWorldPreservation.class', 'assets/lootablebodies/textures/gui/corpse.png', 'assets/lootablebodies/textures/entity/corpse/corpse.png'].each { if (!names.contains(it)) throw new GradleException("Missing production entry ${it}") } String info=zip.getInputStream(zip.getEntry('META-INF/mods.toml')).getText('UTF-8') - ['modId="lootablebodies"',"version=\"${project.version}\"",'authors="Cyanobacterium, SkyBlade1978"','modLoader="javafml"','versionRange="[1.17.1]"'].each { + ['modId="lootablebodies"',"version=\"${project.version}\"",'authors="Cyanobacterium, SkyBlade1978"','modLoader="javafml"','versionRange="[1.18.2]"'].each { if(!info.contains(it))throw new GradleException("Incorrect production metadata: ${it}") } - if(new JsonSlurper().parseText(zip.getInputStream(zip.getEntry('pack.mcmeta')).getText('UTF-8')).pack.pack_format!=7) - throw new GradleException('Native resource pack format must be 7') + def pack=new JsonSlurper().parseText(zip.getInputStream(zip.getEntry('pack.mcmeta')).getText('UTF-8')).pack + if(pack.pack_format!=9 || pack['forge:resource_pack_format']!=8 || pack['forge:data_pack_format']!=9) + throw new GradleException('Native pack formats must be pack9/resource8/data9') + def coremods=new JsonSlurper().parseText(zip.getInputStream(zip.getEntry('META-INF/coremods.json')).getText('UTF-8')) + if(coremods!=[lootablebodies_preservation:'coremods/early-world-preservation.js']) + throw new GradleException('Missing or changed early native save attachment') def atEntry=zip.getEntry('META-INF/accesstransformer.cfg') def atRules=atEntry?zip.getInputStream(atEntry).getText('UTF-8').readLines() .collect {it.split('#',2)[0].trim()}.findAll {!it.isEmpty()}:[] diff --git a/gradle/ide.gradle b/gradle/ide.gradle index 269c9ba..1d816b1 100644 --- a/gradle/ide.gradle +++ b/gradle/ide.gradle @@ -1,5 +1,5 @@ eclipse { - jdt.javaRuntimeName = 'JavaSE-16' + jdt.javaRuntimeName = 'JavaSE-17' classpath { downloadSources = true downloadJavadoc = true @@ -36,10 +36,12 @@ tasks.register('configureEclipseBuildship') { File localJavaSetup = file('gradle-local.properties') Properties toolchains = new Properties() if (localJavaSetup.isFile()) localJavaSetup.withInputStream { toolchains.load(it) } - toolchains.setProperty('lootablebodies.java16.home', + toolchains.setProperty('lootablebodies.java17.home', productionLauncher.get().metadata.installationPath.asFile.canonicalPath) toolchains.setProperty('lootablebodies.mavenizer.java.home', mavenizerLauncher.get().metadata.installationPath.asFile.canonicalPath) + toolchains.setProperty('minecraftAssetsDirectory', minecraftAssets.asFile.canonicalPath) + toolchains.setProperty('minecraftNativesDirectory', minecraftNatives.asFile.canonicalPath) File workspacePrefs=new File(projectDir.parentFile,'.metadata/.plugins/org.eclipse.core.runtime/.settings/org.eclipse.buildship.core.prefs') Properties workspace=new Properties() if(workspacePrefs.isFile())workspacePrefs.withInputStream { workspace.load(it) } @@ -64,16 +66,15 @@ tasks.register('isolateEclipseProductionRuns') { File launch = file(name) if (!launch.isFile()) throw new GradleException("Generate ${name} with genEclipseRuns first") String contents = launch.getText('UTF-8') - File nativeRoot=file(providers.gradleProperty('minecraftNativesDirectory') - .getOrElse('.gradle/native117-client-inputs/natives')) - if(!nativeRoot.isDirectory())throw new GradleException('Prepare the official native1.17 client inputs before generating Eclipse launches') - contents=contents.replace('{asset_index}','1.17') + File nativeRoot=minecraftNatives.asFile + if(!nativeRoot.isDirectory())throw new GradleException('Prepare the official native1.18 client inputs before generating Eclipse launches') + contents=contents.replace('{asset_index}','1.18') .replace('{assets_root}',minecraftAssets.asFile.absolutePath) .replace('{natives}',nativeRoot.absolutePath) String eclipseRoot=file('bin/main').absolutePath String eclipseMods="${mod_id}%%${eclipseRoot}${File.pathSeparator}${mod_id}%%${eclipseRoot}" contents=contents.replaceAll(/()/) {whole,start,end -> start+eclipseMods+end} - contents = contents.replace('/JavaSE-8', '/JavaSE-16') + contents = contents.replace('/JavaSE-8', '/JavaSE-17') contents = contents.replaceAll(//, '') contents = contents.replace('', ' \n') @@ -127,7 +128,7 @@ tasks.register('verifyEclipseProductionClasspath') { xml =~ /(?i)(src\/test|bin\/test|junit-|integration-test|mockito|byte-buddy)/ || !xml.contains('PROJECT_ATTR" value="LootableBodies"')) throw new GradleException("${name} has the wrong project or exposes tests") - if (!xml.contains('--fml.mcVersion 1.17.1') || xml.contains('{asset_index}') || + if (!xml.contains("--fml.mcVersion ${minecraft_version}") || xml.contains('{asset_index}') || xml.contains('{assets_root}') || xml.contains('{natives}')) throw new GradleException('Eclipse Minecraft version is unresolved') def launchModel = new groovy.xml.XmlSlurper().parseText(xml) @@ -142,7 +143,7 @@ tasks.register('verifyEclipseProductionClasspath') { if (file(directory).canonicalFile != file(providers.gradleProperty('gameRunDirectory').getOrElse('run')).canonicalFile) throw new GradleException("${name} must use the production run directory, found ${directory}") if (name == 'runServer.launch' && !xml.contains('--nogui')) - throw new GradleException('Forge 28 dedicated server needs --nogui') + throw new GradleException('The dedicated server needs --nogui') } } } diff --git a/gradle/native117-writer.init.gradle b/gradle/native117-writer.init.gradle new file mode 100644 index 0000000..67263d4 --- /dev/null +++ b/gradle/native117-writer.init.gradle @@ -0,0 +1,26 @@ +// Isolated writer compiles against public17's API and exact Java16 toolchain. +gradle.projectsEvaluated { + def p=gradle.rootProject + File writerRoot=p.file(p.property('historicalWriterSource')) + def classes=p.layout.buildDirectory.dir('native117-seed/classes') + def compile=p.tasks.register('compileNative117Seed',JavaCompile) { + dependsOn 'classes' + source p.fileTree(new File(writerRoot,'src/nativeSeed117/java')) + classpath=p.files(p.sourceSets.main.output,p.sourceSets.main.compileClasspath) + destinationDirectory=classes + options.compilerArgs.add('-proc:none') + } + def jar=p.tasks.register('native117SeedJar',Jar) { + dependsOn compile + archiveFileName='lootablebodies-native117-world-fixtures.jar' + destinationDirectory=p.layout.buildDirectory.dir('native117-seed') + from classes + from new File(writerRoot,'src/nativeSeed117/resources') + } + def packaged=p.renamer.classes(jar) { + map.from p.minecraft.dependency.toSrgFile + libraries.from(p.tasks.named('jar'),p.sourceSets.main.compileClasspath) + output=p.layout.buildDirectory.file('native117-seed/lootablebodies-native117-world-fixtures-reobf.jar') + } + p.tasks.register('prepareNative117WorldFixtures') {dependsOn packaged} +} diff --git a/gradle/release.gradle b/gradle/release.gradle index 425c509..79a65f2 100644 --- a/gradle/release.gradle +++ b/gradle/release.gradle @@ -5,10 +5,10 @@ def expectedIdentity = [ mod_id: 'lootablebodies', mod_artifact: 'LootableBodies', mod_group: 'zone.moddev.mc.lootablebodies', mod_vendor: 'Cyanobacterium', mod_name: "DrCyano's Lootable Bodies", mod_authors: 'Cyanobacterium,SkyBlade1978', - minecraft_version: '1.17.1', forge_version: '37.1.1', - mapping_channel: 'official', mapping_version: '1.17.1', + minecraft_version: '1.18.2', forge_version: '40.3.12', + mapping_channel: 'official', mapping_version: '1.18.2', loader_name: 'forge', loader_code: '1', curseforge_project_id: '240968', - java_version: '16', java_toolchain_version: '16.0.2+7', gradle_java_version: '17', + java_version: '17', java_toolchain_version: '17.0.20.1+1', gradle_java_version: '17', mod_license: 'LGPL-2.1-only', mod_license_name: 'GNU Lesser General Public License, Version 2.1', mod_license_url: 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt', mod_url: 'https://github.com/MinecraftModDevelopmentMods/LootableBodies', diff --git a/gradle/runtime.gradle b/gradle/runtime.gradle index 1f02836..d2bf7e1 100644 --- a/gradle/runtime.gradle +++ b/gradle/runtime.gradle @@ -75,11 +75,10 @@ def prepareOwnedLoaderConfig = { File root -> } } -// Native37 cannot decode a bare FLAT generator through the ordinary properties -// path. Seed the exact native codec header in a newly owned fixture world. +// Use an actual native40 codec header in each newly owned flat fixture world. def prepareOwnedFlatWorld = { File root -> - File header = file('tools/fixtures/native117-flat-level.dat') - if (!header.isFile() || releaseDigest(header) != '358E4B91F290AFB219D85B09435B4A641035412686E31A09CF5C69F5A28515CB') + File header = file('tools/fixtures/native118-flat-level.dat') + if (!header.isFile() || releaseDigest(header) != 'A806A2751860CDB3D8566510D6A714435F36A6182C026825943BCC24838AC031') throw new GradleException('Missing or changed native flat fixture header') File world = new File(root,'world') if (world.exists()) throw new GradleException('Refusing to replace an existing fixture world') @@ -206,7 +205,13 @@ tasks.register('containerInteractionTest') { group='verification'; dependsOn int def reload = phaseTask('reload',fresh) def deferred = phaseTask('deferred',reload) tasks.register('integrationTest') { group = 'verification'; dependsOn deferred } -tasks.named('check') { dependsOn 'integrationTest' } +// Starting Minecraft is explicit. A routine build/check must not repeat it. +// Complete local qualification still requires these fresh/reload/deferred gates. +tasks.register('localRuntimeVerification') { + group = 'verification' + description = 'Runs the local mapped fresh/reload/deferred verification once for a frozen candidate.' + dependsOn 'integrationTest' +} tasks.register('clientIntegrationTest') { group = 'verification' diff --git a/settings.gradle b/settings.gradle index 252f72d..a165db8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,5 +3,23 @@ plugins { } rootProject.name = 'LootableBodies' +// Release uploads remain restricted by the confirmed-controller guard in gradle/release.gradle. +gradle.taskGraph.whenReady { graph -> + // Hosted jobs compile/audit artifacts; all test JVMs and game tasks are local. + // This also rejects an accidental build/check dependency on a costly task. + if (System.getenv('GITHUB_ACTIONS') == 'true') { + def localOnly = graph.allTasks.findAll { task -> + task instanceof org.gradle.api.tasks.testing.Test || + task instanceof org.gradle.api.tasks.JavaExec || + task.name in ['integrationTest', 'clientIntegrationTest', 'forgeControlServer', + 'localRuntimeVerification', 'prepareLegacyWorldFixtures', + 'generateNative118Fixtures'] || + task.name.startsWith('integration') + } + if (!localOnly.empty) throw new GradleException( + 'Run expensive verification locally before publishing: ' + localOnly*.path.join(', ')) + } +} + // Publication remains restricted to the confirmed MMD release controller in // gradle/release.gradle; local Maven and unconfirmed uploads are rejected there. diff --git a/src/controlTest/java/buildcontrol/ForgeControl.java b/src/controlTest/java/buildcontrol/ForgeControl.java index cc87061..4eed2bf 100644 --- a/src/controlTest/java/buildcontrol/ForgeControl.java +++ b/src/controlTest/java/buildcontrol/ForgeControl.java @@ -1,7 +1,7 @@ package buildcontrol; import net.minecraftforge.fml.common.Mod; import net.minecraftforge.eventbus.api.SubscribeEvent; -import net.minecraftforge.fmlserverevents.FMLServerStartedEvent; +import net.minecraftforge.event.server.ServerStartedEvent; import net.minecraftforge.event.TickEvent; /** Independent native Forge control, without a Lootable Bodies dependency. */ @Mod("forgeplatformcontrol") @@ -15,7 +15,7 @@ private void loadComplete(net.minecraftforge.fml.event.lifecycle.FMLLoadComplete int rounds=Integer.getInteger("lootablebodies.control.resourceReloads",0); if(rounds>0)event.enqueueWork(()->ResourceControl.run(rounds)); } - @SubscribeEvent public void started(FMLServerStartedEvent event) { + @SubscribeEvent public void started(ServerStartedEvent event) { if(net.minecraftforge.fml.ModList.get().getModContainerById("lootablebodies").isPresent())throw new AssertionError("Independent Forge control contains Lootable Bodies"); if(net.minecraftforge.fml.loading.FMLEnvironment.dist==net.minecraftforge.api.distmarker.Dist.DEDICATED_SERVER) { if(Boolean.getBoolean("lootablebodies.control.tcpServer"))System.out.println("FORGE_PLATFORM_CONTROL_READY"); @@ -25,7 +25,7 @@ private void loadComplete(net.minecraftforge.fml.event.lifecycle.FMLLoadComplete private int checkTicks,checkDeaths; @SubscribeEvent public void deathTick(TickEvent.ServerTickEvent event) { if(event.phase!=TickEvent.Phase.END || !Boolean.getBoolean("lootablebodies.control.deaths"))return; - net.minecraft.server.MinecraftServer server=net.minecraftforge.fmllegacy.server.ServerLifecycleHooks.getCurrentServer(); + net.minecraft.server.MinecraftServer server=net.minecraftforge.server.ServerLifecycleHooks.getCurrentServer(); net.minecraft.server.level.ServerPlayer p=server.getPlayerList().getPlayerByName("ControlOnly"); if(p==null || !p.isAlive())return; if(++checkTicks==1) { @@ -136,13 +136,13 @@ private void finish(net.minecraft.client.Minecraft mc) { System.out.println("FORGE_PLATFORM_CONTROL_PASS");mc.stop();stage=3; } private void createWorld(net.minecraft.client.Minecraft client) { - net.minecraft.core.RegistryAccess.RegistryHolder registries=net.minecraft.core.RegistryAccess.builtin(); + net.minecraft.core.RegistryAccess.Frozen registries=net.minecraft.core.RegistryAccess.BUILTIN.get(); net.minecraft.core.Registry types=registries.registryOrThrow(net.minecraft.core.Registry.DIMENSION_TYPE_REGISTRY); net.minecraft.core.Registry biomes=registries.registryOrThrow(net.minecraft.core.Registry.BIOME_REGISTRY); net.minecraft.world.level.levelgen.WorldGenSettings settings=new net.minecraft.world.level.levelgen.WorldGenSettings(4267321L,false,false, net.minecraft.world.level.levelgen.WorldGenSettings.withOverworld(types, - net.minecraft.world.level.dimension.DimensionType.defaultDimensions(types,biomes,registries.registryOrThrow(net.minecraft.core.Registry.NOISE_GENERATOR_SETTINGS_REGISTRY),4267321L), - new net.minecraft.world.level.levelgen.FlatLevelSource(net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings.getDefault(biomes)))); + net.minecraft.world.level.dimension.DimensionType.defaultDimensions(registries,4267321L), + new net.minecraft.world.level.levelgen.FlatLevelSource(registries.registryOrThrow(net.minecraft.core.Registry.STRUCTURE_SET_REGISTRY),net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings.getDefault(biomes,registries.registryOrThrow(net.minecraft.core.Registry.STRUCTURE_SET_REGISTRY))))); client.createLevel("platform-fixture",new net.minecraft.world.level.LevelSettings("Platform fixture",net.minecraft.world.level.GameType.CREATIVE,false,net.minecraft.world.Difficulty.NORMAL,false,new net.minecraft.world.level.GameRules(),net.minecraft.world.level.DataPackConfig.DEFAULT),registries,settings); } } diff --git a/src/controlTest/java/buildcontrol/ResourceControl.java b/src/controlTest/java/buildcontrol/ResourceControl.java index c5e0553..339cf68 100644 --- a/src/controlTest/java/buildcontrol/ResourceControl.java +++ b/src/controlTest/java/buildcontrol/ResourceControl.java @@ -6,7 +6,8 @@ import net.minecraft.commands.Commands; import net.minecraft.core.RegistryAccess; import net.minecraft.server.MinecraftServer; -import net.minecraft.server.ServerResources; +import net.minecraft.server.ReloadableServerResources; +import net.minecraft.server.packs.resources.MultiPackResourceManager; import net.minecraft.server.packs.PackType; import net.minecraft.server.packs.repository.PackRepository; import net.minecraft.server.packs.repository.ServerPacksSource; @@ -20,30 +21,32 @@ static void run(int rounds) { if(rounds!=50)throw new AssertionError("Each bounded resource qualification shard must contain50reloads"); if(net.minecraftforge.fml.ModList.get().getModContainerById("lootablebodies").isPresent()) throw new AssertionError("Independent resource control contains Lootable Bodies"); - if(net.minecraftforge.fmllegacy.server.ServerLifecycleHooks.getCurrentServer()!=null) + if(net.minecraftforge.server.ServerLifecycleHooks.getCurrentServer()!=null) throw new AssertionError("Resource control has already constructed a server"); Executor prepare=Util.backgroundExecutor(); - if(!(prepare instanceof ForkJoinPool) || ((ForkJoinPool)prepare).getParallelism()!=1) - throw new AssertionError("Historical native resource control requires one actual preparation worker"); int cpus=Runtime.getRuntime().availableProcessors(); - System.out.println("NATIVE37_RESOURCE_CONTROL_BEGIN cpus="+cpus+" preparationWorkers=1 rounds="+rounds); - RegistryAccess.RegistryHolder registries=RegistryAccess.builtin(); + if(!(prepare instanceof ForkJoinPool))throw new AssertionError("Native40 preparation executor changed"); + int workers=((ForkJoinPool)prepare).getParallelism(),limit=Integer.getInteger("max.bg.threads",255); + if(limit<1 || limit>255 || workers!=Math.max(1,Math.min(cpus-1,limit)))throw new AssertionError("Native40 worker configuration differs from its actual Util executor"); + System.out.println("NATIVE40_RESOURCE_CONTROL_BEGIN cpus="+cpus+" preparationWorkers="+workers+" rounds="+rounds); + RegistryAccess.Frozen registries=RegistryAccess.BUILTIN.get(); try(PackRepository packs=new PackRepository(PackType.SERVER_DATA,new ServerPacksSource())) { MinecraftServer.configurePackRepository(packs,DataPackConfig.DEFAULT,false); for(int round=1;round<=rounds;round++) { // These are Main's real resources and native executors. No // custom listeners, fake exception, HashSet or future edits. - try(ServerResources resources=ServerResources.loadResources(packs.openAllSelected(),registries, - Commands.CommandSelection.DEDICATED,2,prepare,Runnable::run).get(12,TimeUnit.SECONDS)) { + try(MultiPackResourceManager manager=new MultiPackResourceManager(PackType.SERVER_DATA,packs.openAllSelected())) { + ReloadableServerResources resources=ReloadableServerResources.loadResources(manager,registries, + Commands.CommandSelection.DEDICATED,2,prepare,Runnable::run).get(12,TimeUnit.SECONDS); if(resources.getRecipeManager().getRecipes().isEmpty())throw new AssertionError("No native recipes loaded"); } - if(round%10==0)System.out.println("NATIVE37_RESOURCE_RELOAD_COMPLETE="+round); + if(round%10==0)System.out.println("NATIVE40_RESOURCE_RELOAD_COMPLETE="+round); } } - if(net.minecraftforge.fmllegacy.server.ServerLifecycleHooks.getCurrentServer()!=null) + if(net.minecraftforge.server.ServerLifecycleHooks.getCurrentServer()!=null) throw new AssertionError("Resource control unexpectedly constructed a server"); - Files.writeString(Path.of("resource-control.pass"),"rounds="+rounds+" cpus="+cpus+" preparationWorkers=1 noWorld=true noLootableBodies=true\n",StandardOpenOption.CREATE_NEW); - System.out.println("NATIVE37_RESOURCE_CONTROL_PASS rounds="+rounds); + Files.writeString(Path.of("resource-control.pass"),"rounds="+rounds+" cpus="+cpus+" preparationWorkers="+workers+" noWorld=true noLootableBodies=true\n",StandardOpenOption.CREATE_NEW); + System.out.println("NATIVE40_RESOURCE_CONTROL_PASS rounds="+rounds); System.out.flush();System.exit(0); } catch(Throwable failure) { failure.printStackTrace();System.out.flush();System.exit(2); diff --git a/src/controlTest/resources/META-INF/mods.toml b/src/controlTest/resources/META-INF/mods.toml index 6a1bfa0..adf81ae 100644 --- a/src/controlTest/resources/META-INF/mods.toml +++ b/src/controlTest/resources/META-INF/mods.toml @@ -1,5 +1,5 @@ modLoader="javafml" -loaderVersion="[37,)" +loaderVersion="[40,)" license="LGPL-2.1-only" [[mods]] modId="forgeplatformcontrol" diff --git a/src/controlTest/resources/pack.mcmeta b/src/controlTest/resources/pack.mcmeta index bd8aafc..4202da0 100644 --- a/src/controlTest/resources/pack.mcmeta +++ b/src/controlTest/resources/pack.mcmeta @@ -1 +1 @@ -{"pack":{"pack_format":7,"description":"Disposable Forge control"}} +{"pack":{"pack_format":9,"forge:resource_pack_format":8,"forge:data_pack_format":9,"description":"Disposable Forge control"}} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ClientChecks.java index a0a94cd..877673f 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/ClientChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/ClientChecks.java @@ -31,10 +31,10 @@ public final class ClientChecks { private final VisibilitySkinClientChecks visibilitySkins = new VisibilitySkinClientChecks(); private final ConfigClientChecks configScreen = new ConfigClientChecks(); private final NamelessCorpseClientChecks namelessNames = new NamelessCorpseClientChecks(); - @SubscribeEvent public void mouse(net.minecraftforge.client.event.GuiScreenEvent.MouseClickedEvent.Pre event) { + @SubscribeEvent public void mouse(net.minecraftforge.client.event.ScreenEvent.MouseClickedEvent.Pre event) { event.setCanceled(true); } - @SubscribeEvent public void keyboard(net.minecraftforge.client.event.GuiScreenEvent.KeyboardKeyPressedEvent.Pre event) { + @SubscribeEvent public void keyboard(net.minecraftforge.client.event.ScreenEvent.KeyboardKeyPressedEvent.Pre event) { event.setCanceled(true); } @SubscribeEvent public void tooltip(net.minecraftforge.client.event.RenderTooltipEvent.Color event) { diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ConfigClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ConfigClientChecks.java index dec961c..a4913a1 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/ConfigClientChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/ConfigClientChecks.java @@ -15,7 +15,7 @@ import net.minecraft.client.Screenshot; import net.minecraft.world.level.GameType; import net.minecraft.world.level.Level; -import net.minecraftforge.fmlclient.gui.screen.ModListScreen; +import net.minecraftforge.client.gui.ModListScreen; /** Real native Mods button and widgets, without operating the user's desktop. */ final class ConfigClientChecks { private int stage,frames,step,tooltipsChecked;private File report;private ModListScreen mods;private CorpseConfigGui root;private Future pending; @@ -24,7 +24,7 @@ void tooltip(net.minecraftforge.client.event.RenderTooltipEvent.Color event) { if(!(screen instanceof CorpseConfigGui))return; int width=0,height=event.getComponents().size()==1?-2:0; for(net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipComponent line:event.getComponents()) { - width=Math.max(width,line.getWidth(event.getFontRenderer()));height+=line.getHeight(); + width=Math.max(width,line.getWidth(event.getFont()));height+=line.getHeight(); } RuntimeChecks.require(event.getX()>=3 && event.getX()+width+3<=screen.width && event.getY()>=4 && event.getY()+height+4<=screen.height, "Native configuration tooltip clipped: "+event.getX()+","+event.getY()+" "+width+"x"+height+" screen="+screen.width+"x"+screen.height); @@ -80,7 +80,7 @@ void tick(Minecraft mc)throws Exception { private void openMods(Minecraft mc)throws Exception { mods=new ModListScreen(new TitleScreen());mc.setScreen(mods);List list=(List)get(mods,"mods"); for(int i=0;i{CompoundTag e=effect();e.putBoolean("ShowIcon",false);EntityLootableBody b=read(with(e));require(!b.getEffect(MobEffects.NIGHT_VISION).showIcon(),"Native icon visibility changed");require(!saved(b).contains(NameLifetimeChecks.RECOVERY),"Native icon visibility quarantined");stable(b);}); check("missing_icon_flag_follows_particle_default",()->{CompoundTag e=effect();e.remove("ShowIcon");e.putBoolean("ShowParticles",false);EntityLootableBody b=read(with(e));require(!b.getEffect(MobEffects.NIGHT_VISION).showIcon(),"Missing native icon default changed");stable(b);}); + check("native_named_identity_survives_numeric_registry_remap",()->{CompoundTag e=effect();e.putByte("Id",(byte)MobEffect.getId(MobEffects.MOVEMENT_SPEED));EntityLootableBody b=read(with(e));require(b.getEffect(MobEffects.NIGHT_VISION)!=null && b.getEffect(MobEffects.MOVEMENT_SPEED)==null,"Native named identity did not take precedence");require(!saved(b).contains(NameLifetimeChecks.RECOVERY),"Healthy registry remap quarantined");stable(b);}); + check("native_named_identity_recovers_missing_legacy_number_with_raw_evidence",()->{CompoundTag e=effect();e.remove("Id");EntityLootableBody b=read(with(e));require(b.getEffect(MobEffects.NIGHT_VISION)!=null,"Known native name was lost");NameLifetimeChecks.retained(b,"ActiveEffect",e);stable(b);}); + check("unknown_or_malformed_named_identity_cannot_activate_unrelated_numeric_fallback",()->{for(Tag value:new Tag[]{IntTag.valueOf(16),StringTag.valueOf("bad name?!"),StringTag.valueOf("missing:unknown_effect")}){CompoundTag e=effect();e.put("forge:id",value);EntityLootableBody b=read(with(e));require(b.getActiveEffects().isEmpty(),"Unreadable native identity activated a different effect");NameLifetimeChecks.retained(b,"ActiveEffect",e);stable(b);}}); check("metadata_only_does_not_prevent_empty_decay",EffectHurtChecks::empty); try(PrintWriter out=new PrintWriter(new File(folder,"effect-hurt-checks.txt"),"UTF-8")){for(String s:results)out.println(s);out.println("checks="+results.size()+" failures="+failures);} require(failures==0,"Effect/hurt regressions: "+failures+" / "+results.size());System.out.println("LOOTABLE_BODIES_EFFECT_HURT_PASS checks="+results.size()); @@ -118,7 +121,7 @@ static void run(MinecraftServer server,ServerLevel target,File folder) throws Ex private static void badHurt(Tag raw){CompoundTag n=base();n.put("HurtTime",raw.copy());EntityLootableBody b=read(n);require(b.hurtTime==0 && b.isAlive(),"Bad hurt time activated");NameLifetimeChecks.retained(b,"HurtTime",raw);stable(b);} private static void validHurt(Tag raw){CompoundTag n=base();n.put("HurtTime",raw);EntityLootableBody b=read(n);int v=((NumericTag)raw).getAsInt();require(b.hurtTime==v,"Valid countdown narrowed");stable(b);b.baseTick();require(b.hurtTime==v-1,"Countdown no longer ticks");} private static void badList(Tag raw){CompoundTag n=base();n.put("ActiveEffects",raw.copy());EntityLootableBody b=read(n);require(b.getActiveEffects().isEmpty(),"Bad effect list activated");NameLifetimeChecks.retained(b,"ActiveEffects",raw);stable(b);} - private static void badEffect(String key,Tag raw){CompoundTag e=effect();if(raw==null)e.remove(key);else e.put(key,raw.copy());EntityLootableBody b=read(with(e));require(b.getActiveEffects().isEmpty(),"Malformed effect activated");NameLifetimeChecks.retained(b,"ActiveEffect",e);stable(b);} + private static void badEffect(String key,Tag raw){CompoundTag e=effect();if(key.equals("Id"))e.remove("forge:id");if(raw==null)e.remove(key);else e.put(key,raw.copy());EntityLootableBody b=read(with(e));require(b.getActiveEffects().isEmpty(),"Malformed legacy effect activated");NameLifetimeChecks.retained(b,"ActiveEffect",e);stable(b);} private static void badFlag(String key,Tag raw){CompoundTag e=effect();e.put(key,raw.copy());EntityLootableBody b=read(with(e));MobEffectInstance p=b.getEffect(MobEffects.NIGHT_VISION);require(p!=null && p.getDuration()==200 && p.getAmplifier()==1,"Bad optional flag lost valid effect");require(!p.isAmbient() && p.isVisible(),"Bad optional flag did not use defaults");NameLifetimeChecks.retained(b,"ActiveEffect",e);stable(b);} private static void numeric(){for(int type=0;type<4;type++){CompoundTag e=effect();Tag[] ids={ByteTag.valueOf((byte)16),ShortTag.valueOf((short)16),IntTag.valueOf(16),LongTag.valueOf(16)};Tag[] one={ByteTag.valueOf((byte)1),ShortTag.valueOf((short)1),IntTag.valueOf(1),LongTag.valueOf(1)};e.put("Id",ids[type]);e.put("Duration",one[type]);e.put("Amplifier",one[type].copy());e.put("Ambient",one[type].copy());e.put("ShowParticles",one[type].copy());EntityLootableBody b=read(with(e));MobEffectInstance p=b.getEffect(MobEffects.NIGHT_VISION);require(p!=null && p.getDuration()==1 && p.getAmplifier()==1 && p.isAmbient() && p.isVisible(),"Lossless integral effect changed");require(!saved(b).contains(NameLifetimeChecks.RECOVERY),"Valid numbers quarantined");stable(b);}} private static void missingOptional(){CompoundTag e=effect();for(String k:new String[]{"Duration","Amplifier","Ambient","ShowParticles"})e.remove(k);EntityLootableBody b=read(with(e));MobEffectInstance p=b.getEffect(MobEffects.NIGHT_VISION);require(p!=null && p.getDuration()==0 && p.getAmplifier()==0 && !p.isAmbient() && p.isVisible(),"Legacy defaults changed");require(!saved(b).contains(NameLifetimeChecks.RECOVERY),"Missing optional fields quarantined");stable(b);} @@ -137,6 +140,6 @@ static void run(MinecraftServer server,ServerLevel target,File folder) throws Ex private static void reusedOutput(){EntityLootableBody b=read(base());CompoundTag n=with(effect());b.addAdditionalSaveData(n);require(!n.contains("ActiveEffects"),"Writer retained obsolete ActiveEffects");stable(b);} private static void inventory(){CompoundTag n=base(),missing=NbtRecoveryChecks.entry(121,11,7);missing.putString("id","absentfixture:missing");n.getList("Equipment",10).add(missing);CompoundTag raw=malformedFields();for(String k:raw.getAllKeys())n.put(k,raw.get(k).copy());EntityLootableBody b=read(n);require(b.isAlive(),"Bad fields made corpse inaccessible");NbtRecoveryChecks.retained(b,missing);verifyWorldFields(b);stable(b);stable(read(saved(b)));} private static void empty(){CompoundTag n=base();n.put("Equipment",new ListTag());n.putString("HurtTime","opaque");EntityLootableBody b=read(n);require(b.isEmpty(),"Metadata counts as inventory");NameLifetimeChecks.retained(b,"HurtTime",n.get("HurtTime"));} - static CompoundTag malformedFields(){CompoundTag n=new CompoundTag();n.putLong("HurtTime",65537);CompoundTag bad=effect();bad.putString("Duration","opaque saved duration");CompoundTag unknown=effect();unknown.putInt("Id",250);unknown.putString("Opaque","missing effect data");n.put("ActiveEffects",NbtRecoveryChecks.list(bad,unknown));return n;} + static CompoundTag malformedFields(){CompoundTag n=new CompoundTag();n.putLong("HurtTime",65537);CompoundTag bad=effect();bad.putString("Duration","opaque saved duration");CompoundTag unknown=effect();unknown.remove("forge:id");unknown.putInt("Id",250);unknown.putString("Opaque","missing effect data");n.put("ActiveEffects",NbtRecoveryChecks.list(bad,unknown));return n;} static void verifyWorldFields(EntityLootableBody b){require(b.hurtTime==0 && b.getActiveEffects().isEmpty(),"Malformed disk effect/hurt state activated");CompoundTag raw=malformedFields();NameLifetimeChecks.retained(b,"HurtTime",raw.get("HurtTime"));ListTag list=raw.getList("ActiveEffects",10);for(int i=0;i types=registries.registryOrThrow(net.minecraft.core.Registry.DIMENSION_TYPE_REGISTRY); net.minecraft.core.Registry biomes=registries.registryOrThrow(net.minecraft.core.Registry.BIOME_REGISTRY); net.minecraft.world.level.levelgen.WorldGenSettings settings=new net.minecraft.world.level.levelgen.WorldGenSettings(4267321L,false,false, net.minecraft.world.level.levelgen.WorldGenSettings.withOverworld(types, - net.minecraft.world.level.dimension.DimensionType.defaultDimensions(types,biomes,registries.registryOrThrow(net.minecraft.core.Registry.NOISE_GENERATOR_SETTINGS_REGISTRY),4267321L), - new net.minecraft.world.level.levelgen.FlatLevelSource(net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings.getDefault(biomes)))); + net.minecraft.world.level.dimension.DimensionType.defaultDimensions(registries,4267321L), + new net.minecraft.world.level.levelgen.FlatLevelSource(registries.registryOrThrow(net.minecraft.core.Registry.STRUCTURE_SET_REGISTRY),net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings.getDefault(biomes,registries.registryOrThrow(net.minecraft.core.Registry.STRUCTURE_SET_REGISTRY))))); client.createLevel(folder,new net.minecraft.world.level.LevelSettings(title,mode,false,net.minecraft.world.Difficulty.NORMAL,commands,new net.minecraft.world.level.GameRules(),net.minecraft.world.level.DataPackConfig.DEFAULT),registries,settings); } } @@ -59,25 +59,28 @@ static Entity loadEntity(CompoundTag data, Level world) { } static Entity readChunkEntity(CompoundTag data, ServerLevel world, net.minecraft.world.level.chunk.LevelChunk chunk) { // Invoke the actual native chunk serializer; do not replace its admission callback. - CompoundTag root=new CompoundTag(),level=new CompoundTag();net.minecraft.nbt.ListTag list=new net.minecraft.nbt.ListTag();list.add(data.copy());level.put("Entities",list); - level.putInt("xPos",chunk.getPos().x);level.putInt("zPos",chunk.getPos().z);root.put("Level",level);root.putInt("DataVersion",2730); + CompoundTag root=new CompoundTag();net.minecraft.nbt.ListTag list=new net.minecraft.nbt.ListTag();list.add(data.copy());root.put("entities",list); + root.putInt("xPos",chunk.getPos().x);root.putInt("zPos",chunk.getPos().z);root.put("sections",new net.minecraft.nbt.ListTag());root.putInt("DataVersion",2975); // Current fixture NBT follows the real native conversion boundary too. // The post-load admission callback remains Minecraft's own method. CompoundTag fixed=cyano.lootable.upgrade.NativeNbt.update( cyano.lootable.upgrade.UpgradePreservation.wrapper(net.minecraft.util.datafix.DataFixers.getDataFixer()), - net.minecraft.util.datafix.fixes.References.CHUNK,root,2730,2730); + net.minecraft.util.datafix.fixes.References.CHUNK,root,2975,2975); try { java.lang.reflect.Method nativeRead=null; for(java.lang.reflect.Method method:net.minecraft.world.level.chunk.storage.ChunkSerializer.class.getDeclaredMethods()) - if(java.lang.reflect.Modifier.isStatic(method.getModifiers()) && !method.isSynthetic() && method.getReturnType()==void.class && java.util.Arrays.equals(method.getParameterTypes(),new Class[]{ServerLevel.class,CompoundTag.class,net.minecraft.world.level.chunk.LevelChunk.class})) { + if(java.lang.reflect.Modifier.isStatic(method.getModifiers()) && !method.isSynthetic() && method.getReturnType()==net.minecraft.world.level.chunk.LevelChunk.PostLoadProcessor.class && java.util.Arrays.equals(method.getParameterTypes(),new Class[]{ServerLevel.class,CompoundTag.class})) { if(nativeRead!=null)throw new IllegalStateException("Ambiguous native entity admission");nativeRead=method; } if(nativeRead==null)throw new IllegalStateException("Missing native entity admission"); - nativeRead.setAccessible(true);nativeRead.invoke(null,world,fixed.getCompound("Level"),chunk); + nativeRead.setAccessible(true); + net.minecraft.world.level.chunk.LevelChunk.PostLoadProcessor processor=(net.minecraft.world.level.chunk.LevelChunk.PostLoadProcessor)nativeRead.invoke(null,world,fixed); + if(processor==null)throw new IllegalStateException("Native entity admission returned no processor"); + processor.run(chunk); Object manager=singleField(ServerLevel.class,net.minecraft.world.level.entity.PersistentEntitySectionManager.class).get(world); net.minecraft.world.level.entity.EntitySectionStorage sections=(net.minecraft.world.level.entity.EntitySectionStorage) singleField(manager.getClass(),net.minecraft.world.level.entity.EntitySectionStorage.class).get(manager); - java.util.UUID id=fixed.getCompound("Level").getList("Entities",10).getCompound(0).getUUID("UUID"); + java.util.UUID id=fixed.getList("entities",10).getCompound(0).getUUID("UUID"); return sections.getExistingSectionsInChunk(chunk.getPos().toLong()).flatMap(section->section.getEntities()) .filter(entity->entity instanceof Entity && ((Entity)entity).getUUID().equals(id)).map(entity->(Entity)entity).findFirst().orElse(null); }catch(ReflectiveOperationException failure){throw new IllegalStateException("Native chunk admission failed",failure);} @@ -148,7 +151,7 @@ static void unload(ServerLevel world,int x,int z) { world.setChunkForced(x,z,false); // Expire temporary native load tickets and execute real save/unload work. for(int tick=0;tick<1200 && provider.hasChunk(x,z);tick++) { - provider.tick(()->true); + provider.tick(()->true,true); for(int task=0;task<1000 && provider.pollTask();task++) { } if((tick&31)==0)Thread.yield(); } diff --git a/src/integrationTest/java/cyano/lootable/buildtest/FixtureItems.java b/src/integrationTest/java/cyano/lootable/buildtest/FixtureItems.java index 7de8758..eb1fcc2 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/FixtureItems.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/FixtureItems.java @@ -8,20 +8,23 @@ import net.minecraftforge.common.capabilities.Capability; import net.minecraftforge.common.capabilities.ICapabilityProvider; import net.minecraftforge.common.capabilities.ICapabilitySerializable; -import net.minecraftforge.fmllegacy.common.registry.GameRegistry; /** Controlled dependency used only by sealed disposable upgrade worlds. */ public final class FixtureItems { public static Item keepsake; public static void register(net.minecraftforge.eventbus.api.IEventBus bus) { - keepsake=new Item(new Item.Properties()) { - @Override public ICapabilityProvider initCapabilities(ItemStack stack,CompoundTag nbt){return new Stored();} - }.setRegistryName("lootablebodiesbuildtest:keepsake"); bus.register(new RegistryItems()); } public static final class RegistryItems { @net.minecraftforge.eventbus.api.SubscribeEvent - public void register(net.minecraftforge.event.RegistryEvent.Register event) {event.getRegistry().register(keepsake);} + public void register(net.minecraftforge.event.RegistryEvent.Register event) { + // Native18 creates an intrusive holder inside Item's constructor; + // construction belongs to the same open registration event. + keepsake=new Item(new Item.Properties()) { + @Override public ICapabilityProvider initCapabilities(ItemStack stack,CompoundTag nbt){return new Stored();} + }.setRegistryName("lootablebodiesbuildtest:keepsake"); + event.getRegistry().register(keepsake); + } } private static final class Stored implements ICapabilitySerializable { private CompoundTag data=new CompoundTag(); diff --git a/src/integrationTest/java/cyano/lootable/buildtest/InventorySafetyChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/InventorySafetyChecks.java index 84f2377..96db3e4 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/InventorySafetyChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/InventorySafetyChecks.java @@ -29,7 +29,6 @@ import net.minecraft.core.BlockPos; import net.minecraft.world.level.GameRules; import net.minecraft.world.level.Level; -import net.minecraft.world.level.TickList; import net.minecraft.world.level.chunk.ChunkSource; import net.minecraftforge.common.MinecraftForge; @@ -366,19 +365,18 @@ static void seedBlockedRelease(ServerLevel target,File folder) throws Exception private static final class RejectingWorld extends Level { private final ServerLevel delegate; int attempts; RejectingWorld(ServerLevel target) { - super((net.minecraft.world.level.storage.WritableLevelData)target.getLevelData(),target.dimension(),target.dimensionType(),()->net.minecraft.util.profiling.InactiveProfiler.INSTANCE,false,false,target.getSeed()); + super((net.minecraft.world.level.storage.WritableLevelData)target.getLevelData(),target.dimension(),target.dimensionTypeRegistration(),()->net.minecraft.util.profiling.InactiveProfiler.INSTANCE,false,false,target.getSeed()); delegate=target; } @Override public net.minecraft.core.RegistryAccess registryAccess(){return delegate.registryAccess();} @Override public float getShade(net.minecraft.core.Direction direction,boolean shade){return delegate.getShade(direction,shade);} @Override public ChunkSource getChunkSource(){return delegate.getChunkSource();} - @Override public net.minecraft.world.level.biome.Biome getUncachedNoiseBiome(int x,int y,int z){return delegate.getUncachedNoiseBiome(x,y,z);} + @Override public net.minecraft.core.Holder getUncachedNoiseBiome(int x,int y,int z){return delegate.getUncachedNoiseBiome(x,y,z);} - @Override public net.minecraft.world.level.TickList getBlockTicks(){return delegate.getBlockTicks();} - @Override public net.minecraft.world.level.TickList getLiquidTicks(){return delegate.getLiquidTicks();} + @Override public net.minecraft.world.ticks.LevelTickAccess getBlockTicks(){return delegate.getBlockTicks();} + @Override public net.minecraft.world.ticks.LevelTickAccess getFluidTicks(){return delegate.getFluidTicks();} @Override public net.minecraft.world.item.crafting.RecipeManager getRecipeManager(){return delegate.getRecipeManager();} @Override public net.minecraft.world.scores.Scoreboard getScoreboard(){return delegate.getScoreboard();} - @Override public net.minecraft.tags.TagContainer getTagManager(){return delegate.getTagManager();} @Override public net.minecraft.server.MinecraftServer getServer(){return delegate.getServer();} @Override public long getGameTime(){return delegate==null?0:delegate.getGameTime();} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerChecks.java index 44a454d..7516c2a 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerChecks.java @@ -26,8 +26,8 @@ import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.event.TickEvent; -import net.minecraftforge.fmllegacy.network.NetworkRegistry; -import net.minecraftforge.fmllegacy.network.simple.*; +import net.minecraftforge.network.NetworkRegistry; +import net.minecraftforge.network.simple.*; import net.minecraftforge.api.distmarker.Dist; /** Disposable two-client coordinator. Step messages acknowledge prior TCP clicks on the server thread. */ @@ -39,7 +39,7 @@ public final class MultiplayerChecks { private EntityLootableBody body; private int phase,ticks; private boolean finishing; - public static void registerChannel(){CHANNEL.registerMessage(0,Step.class,(message,buffer)->buffer.writeInt(message.number),buffer->new Step(buffer.readInt()),(message,supplier)->{net.minecraftforge.fmllegacy.network.NetworkEvent.Context context=supplier.get();context.enqueueWork(()->acknowledge(message,context.getSender()));context.setPacketHandled(true);});CHANNEL.registerMessage(1,FixtureDeath.class,(message,buffer)->{},buffer->new FixtureDeath(),(message,supplier)->{net.minecraftforge.fmllegacy.network.NetworkEvent.Context context=supplier.get();context.enqueueWork(()->{RuntimeChecks.require(Boolean.getBoolean("lootablebodies.issueClientCheck") && context.getSender()!=null,"Unexpected fixture death signal");context.getSender().kill();});context.setPacketHandled(true);});} + public static void registerChannel(){CHANNEL.registerMessage(0,Step.class,(message,buffer)->buffer.writeInt(message.number),buffer->new Step(buffer.readInt()),(message,supplier)->{net.minecraftforge.network.NetworkEvent.Context context=supplier.get();context.enqueueWork(()->acknowledge(message,context.getSender()));context.setPacketHandled(true);});CHANNEL.registerMessage(1,FixtureDeath.class,(message,buffer)->{},buffer->new FixtureDeath(),(message,supplier)->{net.minecraftforge.network.NetworkEvent.Context context=supplier.get();context.enqueueWork(()->{RuntimeChecks.require(Boolean.getBoolean("lootablebodies.issueClientCheck") && context.getSender()!=null,"Unexpected fixture death signal");context.getSender().kill();});context.setPacketHandled(true);});} public static final class FixtureDeath { } public MultiplayerChecks(MinecraftServer server) throws Exception { this.server=server;sync=sync(); diff --git a/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java index d79ba50..8aa6279 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/MultiplayerClientChecks.java @@ -27,8 +27,8 @@ final class MultiplayerClientChecks { private boolean finished; private int lastPhase=-1,frames,ticks; void register(){if(role!=null&&!Boolean.getBoolean("lootablebodies.visibility")&&!Boolean.getBoolean("lootablebodies.respawn")){net.minecraftforge.common.MinecraftForge.EVENT_BUS.register(this);MinecraftForge.EVENT_BUS.register(this);}} - @SubscribeEvent public void mouse(net.minecraftforge.client.event.GuiScreenEvent.MouseClickedEvent.Pre e){e.setCanceled(true);} - @SubscribeEvent public void key(net.minecraftforge.client.event.GuiScreenEvent.KeyboardKeyPressedEvent.Pre e){e.setCanceled(true);} + @SubscribeEvent public void mouse(net.minecraftforge.client.event.ScreenEvent.MouseClickedEvent.Pre e){e.setCanceled(true);} + @SubscribeEvent public void key(net.minecraftforge.client.event.ScreenEvent.KeyboardKeyPressedEvent.Pre e){e.setCanceled(true);} @SubscribeEvent public void tick(TickEvent.ClientTickEvent e){ if(e.phase!=TickEvent.Phase.END)return;Minecraft mc=Minecraft.getInstance(); if(mc.isWindowActive())mc.mouseHandler.releaseMouse();net.minecraft.client.KeyMapping.releaseAll(); diff --git a/src/integrationTest/java/cyano/lootable/buildtest/Native118BiomeChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/Native118BiomeChecks.java new file mode 100644 index 0000000..2420d09 --- /dev/null +++ b/src/integrationTest/java/cyano/lootable/buildtest/Native118BiomeChecks.java @@ -0,0 +1,40 @@ +package cyano.lootable.buildtest; + +import java.util.Optional; +import net.minecraft.core.Holder; +import net.minecraft.core.Registry; +import net.minecraft.nbt.*; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.datafix.fixes.References; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.Biomes; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.PalettedContainer; +import net.minecraft.world.level.chunk.storage.ChunkStorage; + +/** Compare every old quart sample by native biome identity, not obsolete registry IDs. */ +final class Native118BiomeChecks { + private Native118BiomeChecks() { } + static void verify(ServerLevel world,CompoundTag record,int wave) { + int x=record.getInt("ChunkX"),z=record.getInt("ChunkZ"); + CompoundTag root=new CompoundTag(),level=new CompoundTag();root.putInt("DataVersion",2730);root.put("Level",level); + level.putInt("xPos",x);level.putInt("zPos",z);level.putString("Status","full");level.put("Sections",new ListTag()); + level.putIntArray("Biomes",HistoricalBiomes.nativeValues(record.getIntArray("Data"))); + ChunkStorage.injectDatafixingContext(root,world.dimension(),Optional.empty()); + CompoundTag nativeResult=cyano.lootable.upgrade.NativeNbt.update(NativeVanillaDataFixer.get(),References.CHUNK,root,2730,2975); + Registry registry=world.registryAccess().registryOrThrow(Registry.BIOME_REGISTRY); + com.mojang.serialization.Codec>> codec=PalettedContainer.codec(registry.asHolderIdMap(),registry.holderByNameCodec(),PalettedContainer.Strategy.SECTION_BIOMES,registry.getHolderOrThrow(Biomes.PLAINS)); + LevelChunk chunk=world.getChunk(x,z);int samples=0; + for(net.minecraft.nbt.Tag entry:nativeResult.getList("sections",10)) { + CompoundTag section=(CompoundTag)entry;int sectionY=section.getInt("Y"); + if(sectionY<0 || sectionY>=16)continue; // All 1024 samples from the saved 0..255 range. + PalettedContainer> expected=codec.parse(NbtOps.INSTANCE,section.getCompound("biomes")).getOrThrow(false,message->{throw new AssertionError(message);}); + for(int y=0;y<4;y++)for(int zz=0;zz<4;zz++)for(int xx=0;xx<4;xx++) { + Holder actual=chunk.getNoiseBiome(xx,sectionY*4+y,zz); + RuntimeChecks.require(expected.get(xx,y,zz).unwrapKey().equals(actual.unwrapKey()),"Historical biome changed in dimension "+record.getInt("Dimension")+" wave "+wave+" section "+sectionY+" sample "+xx+","+y+","+zz); + samples++; + } + } + RuntimeChecks.require(samples==1024,"Native biome conversion omitted saved quart samples"); + } +} diff --git a/src/integrationTest/java/cyano/lootable/buildtest/NativePreflightChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/NativePreflightChecks.java index 35360cd..a1f97d9 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/NativePreflightChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/NativePreflightChecks.java @@ -28,7 +28,7 @@ static void install(net.minecraftforge.eventbus.api.IEventBus bus) { DataFixer observer=new DataFixer() { public Schema getSchema(int version){return nativeFixer.getSchema(version);} public Dynamic update(DSL.TypeReference type,Dynamic input,int from,int to) { - if(type.typeName().equals("entity_chunk") && net.minecraftforge.fmllegacy.server.ServerLifecycleHooks.getCurrentServer()==null) { + if(type.typeName().equals("entity_chunk") && net.minecraftforge.server.ServerLifecycleHooks.getCurrentServer()==null) { boolean preflight=java.util.Arrays.stream(Thread.currentThread().getStackTrace()).anyMatch( frame->frame.getClassName().equals("cyano.lootable.upgrade.UpgradeJournal") && frame.getMethodName().equals("validateEntityChunks")); if(!preflight || worlds.get()!=0 || starts.get()!=0)throw new AssertionError("Entity conversion missed the pre-world boundary"); @@ -43,7 +43,7 @@ public Dynamic update(DSL.TypeReference type,Dynamic input,int from,in })); } @SubscribeEvent public void loaded(net.minecraftforge.event.world.WorldEvent.Load event){worlds.incrementAndGet();} - @SubscribeEvent public void starting(net.minecraftforge.fmlserverevents.FMLServerAboutToStartEvent event){starts.incrementAndGet();} + @SubscribeEvent public void starting(net.minecraftforge.event.server.ServerAboutToStartEvent event){starts.incrementAndGet();} static void verify(MinecraftServer server) throws Exception { RuntimeChecks.require(mode.equals("convert") && early.get()>0,"No actual pre-world entity conversion observed"); net.minecraft.server.level.ServerLevel world=server.overworld();RuntimeChecks.awaitChunk(world,0,0); @@ -59,7 +59,7 @@ static void verify(MinecraftServer server) throws Exception { && item.hasTag() && item.getTag().getString("keep").equals("slot-"+slot),"Preflight inventory changed at slot"+slot); } CompoundTag saved=new CompoundTag();body.saveAsPassenger(saved); - RuntimeChecks.require(saved.getInt("LootableBodiesDataVersion")==7,"Preflight corpse marker was not advanced"); + RuntimeChecks.require(saved.getInt("LootableBodiesDataVersion")==8,"Preflight corpse marker was not advanced"); ListTag aux=saved.getList("Aux",10);RuntimeChecks.require(aux.size()==5,"Preflight Aux record count changed"); for(int index=0;index<5;index++) { int sourceSlot=index<4?index:47;CompoundTag item=aux.getCompound(index); diff --git a/src/integrationTest/java/cyano/lootable/buildtest/NbtClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/NbtClientChecks.java index 8e07372..a3133b3 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/NbtClientChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/NbtClientChecks.java @@ -27,6 +27,10 @@ /** Real GUI recovery after loading malformed NBT, including unresolved data across another save. */ final class NbtClientChecks { + // Earlier native admission regressions intentionally leave live fixtures in + // chunk10,10. Keep the graphical disk fixture in a separate, empty chunk so + // native entity collisions cannot change its zero-motion load assertion. + private static final int COORDINATE=NbtWorldChecks.GRAPHICAL_COORDINATE; private interface Action {void run() throws Exception;} private final UUID bodyId=UUID.fromString("00000000-0000-4000-8000-000000000121"); private UUID playerId; @@ -35,18 +39,21 @@ private interface Action {void run() throws Exception;} private CompoundTag visible; private Map before; private ListTag expectedPlayer; - void start(Minecraft mc){playerId=mc.player.getUUID();} + void start(Minecraft mc){playerId=mc.player.getUUID();NbtWorldChecks.graphicalAdmissions=0;} boolean tick(Minecraft mc) throws Exception { frames++;RuntimeChecks.require(frames<300,"NBT graphical stage timeout "+stage); if(stage==0){schedule(mc,()->{ ServerPlayer p=player(mc);p.getInventory().clearContent();p.containerMenu.setCarried(ItemStack.EMPTY);p.setGameMode(GameType.SURVIVAL); - for(int x=157;x<=171;x++)for(int z=157;z<=163;z++)p.level.setBlockAndUpdate(new BlockPos(x,79,z),Blocks.STONE.defaultBlockState()); - NbtWorldChecks.seed(p.getLevel(),bodyId,160,folder(mc),"graphical"); + RuntimeChecks.awaitChunk(p.getLevel(),COORDINATE>>4,COORDINATE>>4); + for(int x=COORDINATE-3;x<=COORDINATE+11;x++)for(int z=COORDINATE-3;z<=COORDINATE+3;z++)p.level.setBlockAndUpdate(new BlockPos(x,79,z),Blocks.STONE.defaultBlockState()); + RuntimeChecks.require(p.getLevel().getEntities(null,new net.minecraft.world.phys.AABB(COORDINATE,79,COORDINATE-2,COORDINATE+16,83,COORDINATE+4)).isEmpty(),"Graphical malformed fixture chunk is not isolated"); + NbtWorldChecks.seed(p.getLevel(),bodyId,COORDINATE,folder(mc),"graphical"); });next(1);} else if(stage==1 && done() && frames>20){quit(mc);next(2);} else if(stage==2 && FixtureConnections.stopped(mc)){mc.loadLevel("build-fixture");next(3);} - else if(stage==3 && ready(mc) && frames>20){schedule(mc,()->player(mc).connection.teleport(168.5,80,159.5,0,0));next(30);} + else if(stage==3 && ready(mc) && frames>20){schedule(mc,()->player(mc).connection.teleport(COORDINATE+8.5,80,COORDINATE-.5,0,0));next(30);} else if(stage==30 && done() && frames>20){schedule(mc,()->{ + RuntimeChecks.require(NbtWorldChecks.graphicalAdmissions==1,"Missing exact first native graphical disk admission"); EntityLootableBody b=body(mc);for(int i=0;i<8;i++)b.baseTick();NbtWorldChecks.verify(b,false); before=NbtRecoveryChecks.items(b);visible=full(b);RuntimeChecks.saveExpected(folder(mc),"nbt-client-before",b); });next(4);} @@ -62,8 +69,9 @@ else if(stage==6 && frames>25){schedule(mc,()->{ });next(7);} else if(stage==7 && done()){checkPlayer(mc.player.getInventory());quit(mc);next(8);} else if(stage==8 && FixtureConnections.stopped(mc)){mc.loadLevel("build-fixture");next(9);} - else if(stage==9 && ready(mc) && frames>20){schedule(mc,()->player(mc).connection.teleport(168.5,80,159.5,0,0));next(90);} + else if(stage==9 && ready(mc) && frames>20){schedule(mc,()->player(mc).connection.teleport(COORDINATE+8.5,80,COORDINATE-.5,0,0));next(90);} else if(stage==90 && done() && frames>20){schedule(mc,()->{ + RuntimeChecks.require(NbtWorldChecks.graphicalAdmissions==2,"Missing exact second native graphical disk admission"); body(mc).baseTick();RuntimeChecks.verifyExpected(folder(mc),"nbt-client-after",body(mc)); RuntimeChecks.require(Objects.equals(visible.get(NbtRecoveryChecks.RECOVERY),NbtRecoveryChecks.saved(body(mc)).get(NbtRecoveryChecks.RECOVERY)),"Unresolved raw data changed on graphical disk reload"); NbtWorldChecks.verifyMetadata(body(mc));RuntimeChecks.require(NameLifetimeChecks.same(visible.get(NameLifetimeChecks.RECOVERY),full(body(mc)).get(NameLifetimeChecks.RECOVERY)),"Raw scalar evidence changed on graphical reload"); diff --git a/src/integrationTest/java/cyano/lootable/buildtest/NbtWorldChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/NbtWorldChecks.java index bf1dbb0..ad0c454 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/NbtWorldChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/NbtWorldChecks.java @@ -16,10 +16,21 @@ import net.minecraft.server.MinecraftServer; import net.minecraft.resources.ResourceLocation; import net.minecraft.world.level.Level; -import net.minecraftforge.fmllegacy.common.registry.GameRegistry; /** Raw malformed data written into real disposable region files, then read in later JVMs. */ final class NbtWorldChecks { + static final int GRAPHICAL_COORDINATE=8192; + static int graphicalAdmissions; + static void verifyGraphicalAdmission(Entity entity){ + if(!Boolean.getBoolean("lootablebodies.clientCheck") || entity.level.isClientSide + || entity.getClass()!=EntityLootableBody.class || !entity.getUUID().equals(NEAR))return; + EntityLootableBody body=(EntityLootableBody)entity; + PositionMotionChecks.verifyWorldFields(body); + RuntimeChecks.require(body.getX()==GRAPHICAL_COORDINATE+8.5 && body.getY()==80 && body.getZ()==GRAPHICAL_COORDINATE+.5, + "Malformed graphical disk geometry differs at native admission"); + graphicalAdmissions++; + System.out.println("NBT_CLIENT_NATIVE_ADMISSION_PASS count="+graphicalAdmissions+" position="+body.position()+" motion="+body.getDeltaMovement()); + } private static final UUID NEAR=UUID.fromString("00000000-0000-4000-8000-000000000121"),FAR=UUID.fromString("00000000-0000-4000-8000-000000000122"); private static final String RETURNING="lootablebodiesbuildtest:returning_item"; private static Item returning; diff --git a/src/integrationTest/java/cyano/lootable/buildtest/PortBaselineChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/PortBaselineChecks.java index d4c5e14..9249c00 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/PortBaselineChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/PortBaselineChecks.java @@ -48,7 +48,7 @@ public static void run(MinecraftServer server) throws Exception { require(RespawnPacketTrace.describe(sent).equals(RespawnPacketTrace.describe(received)),"Spawn observer differs before/after native decoding"); require(RespawnPacketTrace.describe(received).contains("mod=lootablebodies "),"Negative control cannot identify corpse spawns"); buffer.clear(); - net.minecraft.network.protocol.game.ClientboundRespawnPacket outgoing=new net.minecraft.network.protocol.game.ClientboundRespawnPacket(world.dimensionType(),world.dimension(),0x13572468ABCDEF01L,GameType.SURVIVAL,GameType.CREATIVE,false,true,true); + net.minecraft.network.protocol.game.ClientboundRespawnPacket outgoing=new net.minecraft.network.protocol.game.ClientboundRespawnPacket(world.dimensionTypeRegistration(),world.dimension(),0x13572468ABCDEF01L,GameType.SURVIVAL,GameType.CREATIVE,false,true,true); outgoing.write(buffer); net.minecraft.network.protocol.game.ClientboundRespawnPacket incoming=new net.minecraft.network.protocol.game.ClientboundRespawnPacket(buffer); require(RespawnPacketTrace.describe(outgoing).equals(RespawnPacketTrace.describe(incoming)),"Respawn observer differs before/after native decoding"); diff --git a/src/integrationTest/java/cyano/lootable/buildtest/PositionMotionChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/PositionMotionChecks.java index 60cf2aa..4069548 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/PositionMotionChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/PositionMotionChecks.java @@ -70,6 +70,7 @@ static void run(MinecraftServer server,ServerLevel target,File folder) throws Ex check("position_repair_does_not_heal_saved_zero_health",PositionMotionChecks::deadHealth); check("geometry_metadata_alone_is_not_inventory",PositionMotionChecks::empty); check("unknown_items_and_complete_inventory_survive_geometry_repair",PositionMotionChecks::inventory); + check("sanitized_motion_can_change_under_native_entity_collision",PositionMotionChecks::nativeCollision); try(PrintWriter out=new PrintWriter(new File(folder,"position-motion-checks.txt"),"UTF-8")){for(String s:results)out.println(s);out.println("checks="+results.size()+" failures="+failures);} require(failures==0,"Position/motion regressions: "+failures+" / "+results.size());System.out.println("LOOTABLE_BODIES_POSITION_MOTION_PASS checks="+results.size()); } @@ -104,4 +105,18 @@ static void run(MinecraftServer server,ServerLevel target,File folder) throws Ex private static void empty(){CompoundTag n=base();n.put("Equipment",new ListTag());n.remove("Aux");n.putString("Motion","opaque");EntityLootableBody b=read(n);require(b.isEmpty(),"Geometry evidence counts as items");NameLifetimeChecks.retained(b,"Motion",n.get("Motion"));} private static void inventory(){CompoundTag n=base(),raw=NbtRecoveryChecks.entry(121,11,7);raw.putString("id","absentfixture:missing");n.getList("Equipment",10).add(raw);n.put("Pos",vector(Double.NaN,80,160.5));n.put("Motion",vector(Double.NaN,Double.POSITIVE_INFINITY,Double.NEGATIVE_INFINITY));EntityLootableBody b=chunk(n,10,10);require(b.isAlive(),"Corrupt geometry made inventory inaccessible");NbtRecoveryChecks.retained(b,raw);verifyWorldFields(b);stable(b);NbtRecoveryChecks.retained(read(saved(b)),raw);} static void verifyWorldFields(EntityLootableBody b){finite(b);sameValue(b.getDeltaMovement().x,0,"Disk motion X");sameValue(b.getDeltaMovement().y,0,"Disk motion Y");sameValue(b.getDeltaMovement().z,0,"Disk motion Z");NameLifetimeChecks.retained(b,"Pos",vector(Double.NaN,80,b.getZ()));NameLifetimeChecks.retained(b,"Motion",vector(Double.NaN,Double.POSITIVE_INFINITY,Double.NEGATIVE_INFINITY));} + private static void nativeCollision(){ + CompoundTag raw=base();raw.put("Pos",vector(Double.NaN,80,160.5));raw.put("Motion",vector(Double.NaN,Double.POSITIVE_INFINITY,Double.NEGATIVE_INFINITY)); + EntityLootableBody body=chunk(raw,10,10); + try { + verifyWorldFields(body);CompoundTag before=saved(body); + EntityLootableBody neighbour=new EntityLootableBody(world);neighbour.setPos(body.getX()-.25,body.getY(),body.getZ()); + require(body.getBoundingBox().intersects(neighbour.getBoundingBox()),"Native collision control did not overlap"); + neighbour.push(body); + finite(body);require(body.getDeltaMovement().x>0 && body.getDeltaMovement().y==0 && body.getDeltaMovement().z==0,"Native push did not change sanitized motion"); + CompoundTag after=saved(body); + for(String key:new String[]{"Equipment","Aux","LootableBodiesInvalidFields"})require(NameLifetimeChecks.same(before.get(key),after.get(key)),"Native collision changed preserved "+key); + System.out.println("POSITION_NATIVE_COLLISION_MOTION "+body.getDeltaMovement()); + } finally {body.discard();} + } } diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java index 8281d12..e3ec7f2 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/ReportedIssueChecks.java @@ -227,7 +227,7 @@ private static Support support(int axis,int direction,UUID savedId) { f.platform=new BlockPos(floorX+(axis==0?direction:0),79,floorZ+(axis==1?direction:0)); // A body can straddle a chunk boundary while its centre remains in the ticketed chunk. // Remove only the two fixture columns' ground, leaving one thin supporting platform. - for(int y=0;y<5;y++){world.removeBlock(new BlockPos(floorX,y,floorZ),false);world.removeBlock(new BlockPos(f.platform.getX(),y,f.platform.getZ()),false);} + for(int y=world.getMinBuildHeight();y<79;y++){world.removeBlock(new BlockPos(floorX,y,floorZ),false);world.removeBlock(new BlockPos(f.platform.getX(),y,f.platform.getZ()),false);} world.setBlockAndUpdate(f.platform,Blocks.STONE.defaultBlockState()); f.body=fullBody(f.cx,f.cz,8.5,savedId);f.body.setPos(x,80,z);f.expected=inventory(f.body); f.forced=world.setChunkForced(f.cx,f.cz,true); @@ -249,7 +249,7 @@ private static void forcedSupport(int axis,int direction) { private static void realVoid() { try(Support f=support(0,1)) { world.removeBlock(f.platform,false); - // 1.11 applies ordinary 4-HP void hits with hurt-resistance intervals. + // Use ordinary native void hits with hurt-resistance intervals. // Allow that native sequence to finish; retain the same conservation gate. for(int i=0;i<250 && f.body.isAlive();i++)world.tickNonPassenger(f.body); for(int i=0;i<15;i++)f.body.baseTick(); diff --git a/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketChecks.java index ba4e076..63b4066 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketChecks.java @@ -82,16 +82,13 @@ static void verifySlowClientStartup(MinecraftServer server) throws Exception { if(a.level.dimension()!=dim)transfer(a,dim); if(b.level.dimension()!=dim)transfer(b,dim); double x=kind==6?-.4:96.5,z=kind==6?-.4:96.5; - for(int dx=-6;dx<=6;dx++)for(int dz=-6;dz<=6;dz++) { - BlockPos floor=new BlockPos(x+dx,79,z+dz);w.setBlockAndUpdate(floor,Blocks.OBSIDIAN.defaultBlockState()); - for(int y=80;y<=84;y++)w.removeBlock(new BlockPos(x+dx,y,z+dz),false); - } + FixtureArena.prepare(new BlockPos(x,79,z),w::setBlockAndUpdate); + FixtureArena.prepare(new BlockPos(160,79,96),w::setBlockAndUpdate); w.getGameRules().getRule(net.minecraft.world.level.GameRules.RULE_DOMOBSPAWNING).set(false,null);w.getGameRules().getRule(net.minecraft.world.level.GameRules.RULE_KEEPINVENTORY).set(false,null); a.setGameMode(GameType.SURVIVAL);b.setGameMode(GameType.SURVIVAL);a.getInventory().clearContent();b.getInventory().clearContent(); FixtureApi.spawn(a,new BlockPos(kind==5?160:(int)Math.floor(x),80,(int)Math.floor(z)-3),true,false,a.level.dimension()); FixtureApi.spawn(b,new BlockPos((int)Math.floor(x),80,(int)Math.floor(z)-3),true,false,b.level.dimension()); if(kind==7)FixtureApi.spawn(a,new BlockPos(96,80,93),true,false,a.level.dimension()); - for(int dx=157;dx<=164;dx++)for(int dz=90;dz<=98;dz++)w.setBlockAndUpdate(new BlockPos(dx,79,dz),Blocks.OBSIDIAN.defaultBlockState()); a.getInventory().items.set(9,RuntimeChecks.stack(520+index));a.containerMenu.broadcastChanges(); if(simultaneous){b.getInventory().items.set(9,RuntimeChecks.stack(620+index));b.containerMenu.broadcastChanges();} a.connection.teleport(x,80,z,0,0);b.connection.teleport(kind==4?160.5:x+2,80,z-2.5,0,25); @@ -115,6 +112,7 @@ static void verifySlowClientStartup(MinecraftServer server) throws Exception { // death and return. Saved UUIDs survive; runtime entity IDs do not. body=live(body);if(simultaneous)observerBody=live(observerBody);state(3); } else if(phase==3) { + RuntimeChecks.require(a!=null&&b!=null&&a.isAlive()&&b.isAlive(),"Unexpected extra player death during the accepted respawn observation"); // In a cold restart, many ordinary saved bodies can collide and // move the new corpse beyond the player's interaction reach. // Follow it with the viewer; never freeze production physics. diff --git a/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java index ae06627..aeb3d15 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketClientChecks.java @@ -41,8 +41,8 @@ final class RespawnPacketClientChecks { private File observations; private Level profileWorld; void register(){if(Boolean.getBoolean("lootablebodies.respawn")&&role!=null){net.minecraftforge.common.MinecraftForge.EVENT_BUS.register(this);MinecraftForge.EVENT_BUS.register(this);}} - @SubscribeEvent public void mouse(net.minecraftforge.client.event.GuiScreenEvent.MouseClickedEvent.Pre e){e.setCanceled(true);} - @SubscribeEvent public void key(net.minecraftforge.client.event.GuiScreenEvent.KeyboardKeyPressedEvent.Pre e){e.setCanceled(true);} + @SubscribeEvent public void mouse(net.minecraftforge.client.event.ScreenEvent.MouseClickedEvent.Pre e){e.setCanceled(true);} + @SubscribeEvent public void key(net.minecraftforge.client.event.ScreenEvent.KeyboardKeyPressedEvent.Pre e){e.setCanceled(true);} private void respawn(Minecraft mc) { if((!role.equals("A")&&!Boolean.getBoolean("lootablebodies.respawn.simultaneous"))||mc.player==null)return; if(mc.screen instanceof DeathScreen) { @@ -82,7 +82,7 @@ private void respawn(Minecraft mc) { p.getProperties().put("textures",new Property("textures",Base64.getEncoder().encodeToString(texture.getBytes(StandardCharsets.UTF_8))));return Optional.of(p); } }; - fixtureCache.setExecutor(mc::execute);SkullBlockEntity.setProfileCache(fixtureCache); + fixtureCache.setExecutor(mc::execute);SkullBlockEntity.profileCache=fixtureCache; } return; } diff --git a/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketTrace.java b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketTrace.java index 22deeb6..540cac1 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketTrace.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/RespawnPacketTrace.java @@ -5,7 +5,7 @@ import net.minecraft.network.protocol.game.ClientboundPlayerCombatEnterPacket; import net.minecraft.network.protocol.game.ClientboundPlayerCombatEndPacket; import net.minecraft.network.protocol.game.ClientboundPlayerCombatKillPacket; -import net.minecraft.network.protocol.game.ClientboundLevelChunkPacket; +import net.minecraft.network.protocol.game.ClientboundLevelChunkWithLightPacket; import net.minecraft.network.protocol.game.ClientboundForgetLevelChunkPacket; import net.minecraft.core.Registry; @@ -20,12 +20,11 @@ import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.protocol.game.ClientboundAddMobPacket; import net.minecraft.network.protocol.game.ClientboundForgetLevelChunkPacket; -import net.minecraft.network.protocol.game.ClientboundLevelChunkPacket; import net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket; import net.minecraft.network.protocol.game.ClientboundRespawnPacket; import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket; import net.minecraft.network.protocol.game.ServerboundClientCommandPacket; -import net.minecraftforge.fmllegacy.network.ICustomPacket; +import net.minecraftforge.network.ICustomPacket; /** Observes decoded packets without dropping, delaying, mutating or reordering them. */ final class RespawnPacketTrace extends ChannelDuplexHandler { @@ -61,7 +60,7 @@ static String describe(Object packet) { return "MOD_SPAWN id="+id+" mod="+name+" type="+type+" uuid="+new UUID(b.readLong(),b.readLong()); } if(!(packet instanceof ClientboundAddMobPacket||packet instanceof ClientboundRespawnPacket||packet instanceof ClientboundRemoveEntitiesPacket||packet instanceof ClientboundForgetLevelChunkPacket - ||packet instanceof ClientboundLevelChunkPacket||packet instanceof ClientboundPlayerCombatEnterPacket||packet instanceof ClientboundPlayerCombatEndPacket + ||packet instanceof ClientboundLevelChunkWithLightPacket||packet instanceof ClientboundPlayerCombatEnterPacket||packet instanceof ClientboundPlayerCombatEndPacket ||packet instanceof ClientboundPlayerCombatKillPacket||packet instanceof ClientboundSetEntityDataPacket||packet instanceof ServerboundClientCommandPacket))return null; if(packet instanceof ClientboundRespawnPacket) { return NativeRespawnDescription.describe((ClientboundRespawnPacket)packet); @@ -79,9 +78,9 @@ static String describe(Object packet) { } if(packet instanceof ClientboundRemoveEntitiesPacket){int[] ids=new int[b.readVarInt()];for(int i=0;i>4,net.minecraft.util.Mth.floor(body.getZ())>>4),"Manual corpse fixture spawned before chunk admission: "+body.blockPosition()+" name="+body.getCustomNameTag()); } } - @SubscribeEvent public void started(FMLServerStartedEvent event) { + @SubscribeEvent public void started(ServerStartedEvent event) { if ((net.minecraftforge.fml.loading.FMLEnvironment.dist==net.minecraftforge.api.distmarker.Dist.CLIENT)) return; - MinecraftServer server = net.minecraftforge.fmllegacy.server.ServerLifecycleHooks.getCurrentServer(); + MinecraftServer server = net.minecraftforge.server.ServerLifecycleHooks.getCurrentServer(); if(Boolean.getBoolean("lootablebodies.respawn")) { try{new RespawnPacketChecks(server);}catch(Exception error){throw new RuntimeException(error);}return; } @@ -97,9 +98,9 @@ static boolean sameStack(ItemStack first,ItemStack second) { public static void run(MinecraftServer server, String phase) throws Exception { ServerLevel world = server.getLevel(FixtureApi.dimensionKey(0)); File folder = world.getServer().getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).toFile(); - require(LootableBodies.MODID.equals("lootablebodies") && LootableBodies.VERSION.equals("2.4.8.117011"), "identity/version"); + require(LootableBodies.MODID.equals("lootablebodies") && LootableBodies.VERSION.equals("2.4.9.118021"), "identity/version"); require(net.minecraftforge.fml.ModList.get().getModContainerById("lootablebodies").get().getModInfo().getVersion().toString().equals(phase.equals("seed") ? "2.4.0" : phase.equals("issues") ? - System.getProperty("lootablebodies.expectedIssueVersion","2.4.8.117011") : "2.4.8.117011"), "loaded release version"); + System.getProperty("lootablebodies.expectedIssueVersion","2.4.9.118021") : "2.4.9.118021"), "loaded release version"); if (!defaultsChecked) require(LootableBodies.addBonesToCorpse && LootableBodies.displayNameTag && !LootableBodies.hurtByEnvironment && !LootableBodies.hurtByAttacks && !LootableBodies.hurtByMisc && LootableBodies.corpseHP == 20 && LootableBodies.ticksPerItemDecay == 6000 && LootableBodies.corpseDecayTime == 72000 && @@ -205,9 +206,7 @@ public static void run(MinecraftServer server, String phase) throws Exception { System.out.println("LOOTABLE_BODIES_RUNTIME_PASS phase=" + phase); } static net.minecraft.world.level.chunk.LevelChunk emptyChunk(ServerLevel world,int x,int z) { - net.minecraft.core.Registry registry=world.registryAccess().registryOrThrow(net.minecraft.core.Registry.BIOME_REGISTRY); - int[] biomes=new int[16*((world.getHeight()+3)/4)];java.util.Arrays.fill(biomes,registry.getId(registry.getOrThrow(net.minecraft.world.level.biome.Biomes.PLAINS))); - return new net.minecraft.world.level.chunk.LevelChunk(world,new net.minecraft.world.level.ChunkPos(x,z),new net.minecraft.world.level.chunk.ChunkBiomeContainer(registry,world,biomes)); + return new net.minecraft.world.level.chunk.LevelChunk(world,new net.minecraft.world.level.ChunkPos(x,z)); } static ItemStack stack(int index) { ItemStack stack=index<4?new ItemStack(new net.minecraft.world.item.Item[]{Items.DIAMOND_HELMET,Items.DIAMOND_CHESTPLATE,Items.DIAMOND_LEGGINGS,Items.DIAMOND_BOOTS}[index]):index==4?new ItemStack(Items.DIAMOND_SWORD):index==5?new ItemStack(Items.SHIELD):new ItemStack(Items.LAPIS_LAZULI,1+index%32); diff --git a/src/integrationTest/java/cyano/lootable/buildtest/VisibilityChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityChecks.java index 9c4bd9b..f16158a 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/VisibilityChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityChecks.java @@ -81,12 +81,13 @@ public VisibilityChecks(MinecraftServer server) throws Exception { w.setDayTime(6000); for(int x=88;x<=170;x++)for(int z=89;z<=103;z++)w.setBlockAndUpdate(new BlockPos(x,GROUND_Y,z),Blocks.OBSIDIAN.defaultBlockState()); for(ServerPlayer p:new ServerPlayer[]{a,b}) {p.setGameMode(GameType.SURVIVAL);p.getInventory().clearContent();FixtureApi.spawn(p,new BlockPos(96,PLAYER_Y,93),true,false,p.level.dimension());} - a.connection.teleport(96.5,PLAYER_Y,96.5,0,0);b.connection.teleport(98.5,PLAYER_Y,93.5,0,25); + a.connection.teleport(96.5,PLAYER_Y,96.5,0,65);b.connection.teleport(98.5,PLAYER_Y,93.5,0,25); a.getInventory().items.set(9,RuntimeChecks.stack(410));a.containerMenu.broadcastChanges();phase(1); } else if(phase==1 && both(1)) { + RuntimeChecks.require(file("A.predeath-render-1.json").isFile(),"Real death began before the owner rendered its loaded viewing area"); // A real death offset exposes assumptions about the corpse's coordinates. if(Boolean.getBoolean("lootablebodies.visibility.offsetFirstDeath")) - a.connection.teleport(96.5,PLAYER_Y,98.5,0,0); + a.connection.teleport(96.5,PLAYER_Y,98.5,0,65); before=new HashSet<>(FixtureApi.entities(world()));a.kill();first=newCorpse();saveId("first",first);phase(2); } else if(phase==2 && both(2)) {mark("both_clients_receive_actual_death_before_respawn");phase(3); } else if(phase==3 && both(3) && a!=null && a.isAlive()) { @@ -113,9 +114,10 @@ public VisibilityChecks(MinecraftServer server) throws Exception { phase(120); } else if(phase==120 && both(120)) { mark(Integer.getInteger("lootablebodies.visibility.waitTicks",6200)==6200?"default_corpse_visible_after_6200_ticks_and_310_seconds":"short_fixture_control_default_corpse_visible"); - a.connection.teleport(100.5,PLAYER_Y,96.5,0,0);b.connection.teleport(102.5,PLAYER_Y,93.5,0,25); + a.connection.teleport(100.5,PLAYER_Y,96.5,0,65);b.connection.teleport(102.5,PLAYER_Y,93.5,0,25); a.getInventory().items.set(9,RuntimeChecks.stack(411));a.containerMenu.broadcastChanges();phase(13); } else if(phase==13 && both(13)) { + RuntimeChecks.require(file("A.predeath-render-13.json").isFile(),"TNT death began before the owner rendered its loaded viewing area"); before=new HashSet<>(FixtureApi.entities(world()));PrimedTnt tnt=new PrimedTnt(world(),a.getX(),a.getY(),a.getZ(),null);tnt.setFuse(0); RuntimeChecks.require(world().addFreshEntity(tnt),"TNT fixture spawn");phase(14); } else if(phase==14) { diff --git a/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java index 6f8a5cc..aabbc7f 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java @@ -37,16 +37,37 @@ final class VisibilityClientChecks { private net.minecraft.network.Connection traced; private net.minecraft.client.multiplayer.ClientLevel profileWorld; void register(){if(Boolean.getBoolean("lootablebodies.visibility")&&role!=null){net.minecraftforge.common.MinecraftForge.EVENT_BUS.register(this);MinecraftForge.EVENT_BUS.register(this);}} - @SubscribeEvent public void mouse(net.minecraftforge.client.event.GuiScreenEvent.MouseClickedEvent.Pre e){e.setCanceled(true);} - @SubscribeEvent public void key(net.minecraftforge.client.event.GuiScreenEvent.KeyboardKeyPressedEvent.Pre e){e.setCanceled(true);} + @SubscribeEvent public void mouse(net.minecraftforge.client.event.ScreenEvent.MouseClickedEvent.Pre e){e.setCanceled(true);} + @SubscribeEvent public void key(net.minecraftforge.client.event.ScreenEvent.KeyboardKeyPressedEvent.Pre e){e.setCanceled(true);} @SubscribeEvent public void rendered(RenderLivingEvent.Post e){ if(e.getEntity() instanceof EntityLootableBody) { rendered.add(e.getEntity().getUUID()); if(Minecraft.getInstance().screen instanceof DeathScreen && !e.getEntity().isRemoved() && !e.getEntity().isInvisible())deathRendered.add(e.getEntity().getUUID()); } } - @SubscribeEvent public void deathScreenDrawn(net.minecraftforge.client.event.GuiScreenEvent.DrawScreenEvent.Post e) { - if(!role.equals("A") || !(e.getGui() instanceof DeathScreen) || (phase!=2&&phase!=14) || done.contains(phase))return; + @SubscribeEvent public void readyForRealDeath(net.minecraftforge.client.event.RenderLevelStageEvent e) { + if(!role.equals("A") || (phase!=1&&phase!=13) || done.contains(phase) || frames<20 || + e.getStage()!=net.minecraftforge.client.event.RenderLevelStageEvent.Stage.AFTER_PARTICLES)return; + Minecraft mc=Minecraft.getInstance(); + if(mc.level==null || mc.player==null || !mc.player.isAlive() || mc.screen!=null || + !mc.level.hasChunkAt(mc.player.blockPosition()) || e.getCamera().getEntity()!=mc.player || + e.getCamera().getLookVector().y()>-0.75F)return; + // Client ticks can catch up before a world frame has actually drawn. + // Native death removes the owner's tracking connection after20ticks; + // begin that finite window only with a real, loaded, downward view. + net.minecraft.world.phys.Vec3 p=mc.player.position(); + net.minecraft.world.phys.AABB futureBody=new net.minecraft.world.phys.AABB( + p.x-0.425D,p.y,p.z-0.425D,p.x+0.425D,p.y+1.25D,p.z+0.425D).inflate(0.5D); + if(!e.getFrustum().isVisible(futureBody))return; + try { + String receipt="{\"phase\":"+phase+",\"native_world_frame\":true,\"render_tick\":"+e.getRenderTick()+ + ",\"pitch\":"+mc.player.getXRot()+",\"corpse_box_in_native_frustum\":true,\"at_nanos\":"+System.nanoTime()+"}"; + Files.write(VisibilityChecks.file("A.predeath-render-"+phase+".json").toPath(),receipt.getBytes(StandardCharsets.UTF_8)); + System.out.println("VISIBILITY_PREDEATH_RENDER_READY "+receipt);ack(); + }catch(Throwable failure){failure.printStackTrace();finished=true;mc.stop();} + } + @SubscribeEvent public void deathScreenDrawn(net.minecraftforge.client.event.ScreenEvent.DrawScreenEvent.Post e) { + if(!role.equals("A") || !(e.getScreen() instanceof DeathScreen) || (phase!=2&&phase!=14) || done.contains(phase))return; Minecraft mc=Minecraft.getInstance(); try { EntityLootableBody b=body(mc,phase==2?"first":"second"); @@ -81,7 +102,7 @@ final class VisibilityClientChecks { profile.getProperties().put("textures",new com.mojang.authlib.properties.Property("textures",Base64.getEncoder().encodeToString(texture.getBytes(StandardCharsets.UTF_8)))); cache.add(profile); } - net.minecraft.world.level.block.entity.SkullBlockEntity.setProfileCache(cache); + net.minecraft.world.level.block.entity.SkullBlockEntity.profileCache=cache; } return; } @@ -105,7 +126,7 @@ final class VisibilityClientChecks { EntityLootableBody b=body(mc,"first");RuntimeChecks.require(b!=null&&!b.isRemoved()&&!b.isInvisible(),"Corpse disappeared during five-minute wait"); } if(done.contains(phase) || frames<20)return; - if(phase==0 || phase==1 || phase==13){ack();return;} + if(phase==0 || phase==1 || phase==13){if(!role.equals("A") || phase==0)ack();return;} if(phase==2 || phase==14) { // The owner proves the live corpse at the actual rendered death // screen. Native tracking may remove it before a later tick. diff --git a/src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinChecks.java index d26dbf9..ea93654 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/VisibilitySkinChecks.java @@ -50,8 +50,8 @@ static void run(Minecraft mc) throws Exception { @Override public Optional get(String name) {return Optional.of(new GameProfile(profile.getId(),name));} @Override public void add(GameProfile value) {super.add(value);if(value.getName().equals("VisibilitySlow"))slowDelivered.countDown();} }; - faultCache.setExecutor(Runnable::run);SkullBlockEntity.setProfileCache(faultCache); - SkullBlockEntity.setSessionService((MinecraftSessionService)Proxy.newProxyInstance(MinecraftSessionService.class.getClassLoader(),new Class[]{MinecraftSessionService.class},(proxy,method,args)->{ + faultCache.setExecutor(Runnable::run);cache.set(null,faultCache); + service.set(null,(MinecraftSessionService)Proxy.newProxyInstance(MinecraftSessionService.class.getClassLoader(),new Class[]{MinecraftSessionService.class},(proxy,method,args)->{ if(method.getName().equals("fillProfileProperties")) {requests.incrementAndGet();failedRequest.countDown();throw new IllegalStateException("injected profile lookup failure");} throw new UnsupportedOperationException(method.getName()); })); @@ -60,7 +60,7 @@ static void run(Minecraft mc) throws Exception { catch(Throwable failure) {failures.add("profile: "+failure);mark(report,"profile_failure_does_not_abort_client_tick",false);} RuntimeChecks.require(failedRequest.await(2,TimeUnit.SECONDS),"Profile failure was never injected"); // Deliberately slow lookup must leave the render/tick thread responsive. - SkullBlockEntity.setSessionService((MinecraftSessionService)Proxy.newProxyInstance(MinecraftSessionService.class.getClassLoader(),new Class[]{MinecraftSessionService.class},(proxy,method,args)->{ + service.set(null,(MinecraftSessionService)Proxy.newProxyInstance(MinecraftSessionService.class.getClassLoader(),new Class[]{MinecraftSessionService.class},(proxy,method,args)->{ if(method.getName().equals("fillProfileProperties")) {requests.incrementAndGet();Thread.sleep(400);return args[0];} throw new UnsupportedOperationException(method.getName()); })); diff --git a/src/integrationTest/java/cyano/lootable/buildtest/WorldUpgradeChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/WorldUpgradeChecks.java index 1e11071..38415e6 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/WorldUpgradeChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/WorldUpgradeChecks.java @@ -226,9 +226,7 @@ private static void verifyBiomes(ServerLevel world,int wave) { for(int i=0;i indexedPlayer(CompoundTag player,String key) { diff --git a/src/integrationTest/resources/META-INF/mods.toml b/src/integrationTest/resources/META-INF/mods.toml index ecc3967..9bc0506 100644 --- a/src/integrationTest/resources/META-INF/mods.toml +++ b/src/integrationTest/resources/META-INF/mods.toml @@ -1,5 +1,5 @@ modLoader="javafml" -loaderVersion="[37,)" +loaderVersion="[40,)" license="LGPL-2.1-only" [[mods]] modId="lootablebodiesbuildtest" @@ -8,6 +8,6 @@ displayName="Disposable build checks" [[dependencies.lootablebodiesbuildtest]] modId="lootablebodies" mandatory=true -versionRange="[2.4.8.117011]" +versionRange="[2.4.9.118021]" ordering="AFTER" side="BOTH" diff --git a/src/integrationTest/resources/pack.mcmeta b/src/integrationTest/resources/pack.mcmeta index d2e88a3..73b76a6 100644 --- a/src/integrationTest/resources/pack.mcmeta +++ b/src/integrationTest/resources/pack.mcmeta @@ -1 +1 @@ -{"pack":{"pack_format":7,"description":"Disposable build checks"}} +{"pack":{"pack_format":9,"forge:resource_pack_format":8,"forge:data_pack_format":9,"description":"Disposable build checks"}} diff --git a/src/main/java/cyano/lootable/ClientProxy.java b/src/main/java/cyano/lootable/ClientProxy.java index 693bd9c..1723f5f 100644 --- a/src/main/java/cyano/lootable/ClientProxy.java +++ b/src/main/java/cyano/lootable/ClientProxy.java @@ -5,7 +5,7 @@ import net.minecraft.world.entity.Entity; import net.minecraftforge.api.distmarker.*; import net.minecraftforge.fml.ModLoadingContext; -import net.minecraftforge.fmlclient.ConfigGuiHandler; +import net.minecraftforge.client.ConfigGuiHandler; import net.minecraftforge.client.event.EntityRenderersEvent; import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; @OnlyIn(Dist.CLIENT) diff --git a/src/main/java/cyano/lootable/LootableBodies.java b/src/main/java/cyano/lootable/LootableBodies.java index e135da6..d121b43 100644 --- a/src/main/java/cyano/lootable/LootableBodies.java +++ b/src/main/java/cyano/lootable/LootableBodies.java @@ -17,8 +17,8 @@ import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.fml.DistExecutor; import net.minecraftforge.fml.common.Mod; -import net.minecraftforge.fmlserverevents.FMLServerAboutToStartEvent; -import net.minecraftforge.fmlserverevents.FMLServerStoppedEvent; +import net.minecraftforge.event.server.ServerAboutToStartEvent; +import net.minecraftforge.event.server.ServerStoppedEvent; import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; import net.minecraftforge.fml.loading.FMLPaths; @@ -26,7 +26,7 @@ public final class LootableBodies { public static final String MODID = "lootablebodies"; public static final String NAME = "DrCyano's Lootable Bodies"; - public static final String VERSION = "2.4.8.117011"; + public static final String VERSION = "2.4.9.118021"; public static final ResourceLocation CORPSE_ID = new ResourceLocation("lootablebodies:corpse"); public static EntityType CORPSE; public static Item CORPSE_EGG; @@ -86,7 +86,7 @@ private void registerItems(RegistryEvent.Register event) { event.getRegistry().register(CORPSE_EGG); } private void registerContainers(RegistryEvent.Register> event) { - CORPSE_CONTAINER=net.minecraftforge.common.extensions.IForgeContainerType.create((window,inventory,buffer)-> { + CORPSE_CONTAINER=net.minecraftforge.common.extensions.IForgeMenuType.create((window,inventory,buffer)-> { net.minecraft.world.entity.Entity body=inventory.player.level.getEntity(buffer.readVarInt()); if(!(body instanceof EntityLootableBody))throw new IllegalStateException("Corpse container has no matching entity"); return new cyano.lootable.graphics.CorpseContainer(window,inventory,(EntityLootableBody)body); @@ -94,7 +94,7 @@ private void registerContainers(RegistryEvent.Register= 0 && id <= 255 ? MobEffect.byId(id.intValue()) : null; + MobEffect potion = SavedEffectIdentity.resolve(record,id); if(potion == null || duration == null || duration < 0 || duration > Integer.MAX_VALUE || amplifier == null || amplifier > 127 || (amplifier < 0 && rawAmplifier.getId() != 1) || !seen.add(potion)) { retainInvalid("ActiveEffect",record); continue; } CompoundTag accepted = new CompoundTag(); - accepted.putByte("Id",id.byteValue()); accepted.putInt("Duration",duration.intValue()); + SavedEffectIdentity.write(accepted,record,potion); accepted.putInt("Duration",duration.intValue()); accepted.putByte("Amplifier",amplifier.byteValue()); - boolean changed = !Arrays.asList("Id","Duration","Amplifier","Ambient","ShowParticles","ShowIcon","CurativeItems").containsAll(record.getAllKeys()); + boolean changed = SavedEffectIdentity.needsLegacyRecovery(record,id) || !Arrays.asList("Id","forge:id","Duration","Amplifier","Ambient","ShowParticles","ShowIcon","CurativeItems").containsAll(record.getAllKeys()); // Forge 1.11 persists curative stacks. Preserve their complete custom // data; an unreadable list uses Forge's defaults and stays recoverable. if(record.contains("CurativeItems")) { diff --git a/src/main/java/cyano/lootable/entities/SavedEffectIdentity.java b/src/main/java/cyano/lootable/entities/SavedEffectIdentity.java new file mode 100644 index 0000000..246a5eb --- /dev/null +++ b/src/main/java/cyano/lootable/entities/SavedEffectIdentity.java @@ -0,0 +1,31 @@ +package cyano.lootable.entities; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.effect.MobEffect; +import net.minecraftforge.registries.ForgeRegistries; + +/** Forge40's named identity takes precedence over its legacy byte fallback. */ +final class SavedEffectIdentity { + private SavedEffectIdentity() { } + static MobEffect resolve(CompoundTag record,Long legacyId) { + if(record.contains("forge:id")) { + if(!record.contains("forge:id",8))return null; + String name=record.getString("forge:id"); + if(!name.isEmpty()) { + ResourceLocation key=ResourceLocation.tryParse(name); + return key==null?null:ForgeRegistries.MOB_EFFECTS.getValue(key); + } + } + return legacyId!=null && legacyId>=0 && legacyId<=255?MobEffect.byId(legacyId.intValue()):null; + } + static boolean needsLegacyRecovery(CompoundTag record,Long legacyId) { + return legacyId==null || legacyId<0 || legacyId>255 || + (record.contains("forge:id") && record.getString("forge:id").isEmpty()); + } + static void write(CompoundTag accepted,CompoundTag record,MobEffect effect) { + accepted.putByte("Id",(byte)MobEffect.getId(effect)); + if(record.contains("forge:id",8) && !record.getString("forge:id").isEmpty()) + accepted.putString("forge:id",effect.getRegistryName().toString()); + } +} diff --git a/src/main/java/cyano/lootable/graphics/GUIHandler.java b/src/main/java/cyano/lootable/graphics/GUIHandler.java index d202336..4690963 100644 --- a/src/main/java/cyano/lootable/graphics/GUIHandler.java +++ b/src/main/java/cyano/lootable/graphics/GUIHandler.java @@ -5,7 +5,7 @@ import net.minecraft.world.entity.player.Player; import net.minecraft.world.level.Level; import net.minecraftforge.api.distmarker.*; -import net.minecraftforge.fmllegacy.network.NetworkHooks; +import net.minecraftforge.network.NetworkHooks; /** Native Forge container opening, with the historical local helper kept internal. */ public final class GUIHandler { private static final GUIHandler INSTANCE=new GUIHandler(); diff --git a/src/main/java/cyano/lootable/upgrade/EarlyWorldPreservation.java b/src/main/java/cyano/lootable/upgrade/EarlyWorldPreservation.java index 8b99c1b..2bd2fca 100644 --- a/src/main/java/cyano/lootable/upgrade/EarlyWorldPreservation.java +++ b/src/main/java/cyano/lootable/upgrade/EarlyWorldPreservation.java @@ -1,31 +1,63 @@ package cyano.lootable.upgrade; +import java.io.DataInputStream; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Collections; import java.util.Map; import java.util.WeakHashMap; +import java.util.zip.GZIPInputStream; import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.Tag; import net.minecraft.world.level.storage.LevelResource; -import net.minecraft.world.level.storage.WorldData; import net.minecraft.world.level.storage.LevelStorageSource; -import net.minecraftforge.fmllegacy.WorldPersistenceHooks; /** Preserve old chunks before Forge rewrites the level header on startup. */ -public final class EarlyWorldPreservation implements WorldPersistenceHooks.WorldPersistenceHook { - private static boolean installed; - private final Map metadata=Collections.synchronizedMap(new WeakHashMap<>()); +public final class EarlyWorldPreservation { + private static volatile boolean installed; + private static final Map metadata=Collections.synchronizedMap(new WeakHashMap<>()); private EarlyWorldPreservation(){ } public static synchronized void install() { - if(!installed){WorldPersistenceHooks.addHook(new EarlyWorldPreservation());installed=true;} + if(installed)return; + verifyAttachment(LevelStorageSource.LevelStorageAccess.class); + installed=true; } - @Override public String getModId(){return "lootablebodies";} - @Override public void readData(LevelStorageSource.LevelStorageAccess save,WorldData info,CompoundTag tag) { - metadata.put(save,tag.copy()); - try {UpgradePreservation.prepareEarly(save.getLevelPath(LevelResource.ROOT));} - catch(IOException unsafe){throw new UnsafeWorldUpgrade(unsafe);} + private static void verifyAttachment(Class reader) { + try { + if(!reader.getName().equals(LevelStorageSource.LevelStorageAccess.class.getName())) + throw new IllegalStateException("Unexpected native save reader"); + java.lang.reflect.Method marker=reader.getDeclaredMethod("lootablebodies$preservationAttached"); + if(marker.getReturnType()!=boolean.class || !marker.isSynthetic() || + !java.lang.reflect.Modifier.isStatic(marker.getModifiers()) || + !java.lang.reflect.Modifier.isPublic(marker.getModifiers()) || !Boolean.TRUE.equals(marker.invoke(null))) + throw new IllegalStateException("Native save preservation attachment is invalid"); + } catch(ReflectiveOperationException missing) { + throw new IllegalStateException("Native save preservation could not be attached. World loading has been stopped before reading or rewriting a world.",missing); + } + } + /** Called before Forge reads extra data or native level conversion. */ + public static void beforeRead(LevelStorageSource.LevelStorageAccess save) { + try { + if(!installed)throw new IllegalStateException("Early preservation was not installed before world loading"); + Path world=save.getLevelPath(LevelResource.ROOT),header=world.resolve("level.dat"); + if(!Files.isRegularFile(header) && Files.isRegularFile(world.resolve("level.dat_old")))header=world.resolve("level.dat_old"); + if(Files.isRegularFile(header)) { + CompoundTag root; + try(DataInputStream input=new DataInputStream(new GZIPInputStream(Files.newInputStream(header)))) { + root=NbtIo.read(input,new NbtAccounter(64L*1024*1024)); + } + Tag original=root.get("lootablebodies"); + if(original!=null)metadata.put(save,original.copy());else metadata.remove(save); + } else metadata.remove(save); + UpgradePreservation.prepareEarly(world); + } catch(IOException | RuntimeException unsafe){throw new UnsafeWorldUpgrade(unsafe);} } - @Override public CompoundTag getDataForWriting(LevelStorageSource.LevelStorageAccess save,WorldData info) { - CompoundTag original=metadata.get(save);return original==null?new CompoundTag():original.copy(); + /** Restore only this mod's original field on the same native save access. */ + public static void beforeWrite(LevelStorageSource.LevelStorageAccess save,CompoundTag root) { + Tag original=metadata.get(save);if(original!=null)root.put("lootablebodies",original.copy()); } private static final class UnsafeWorldUpgrade extends Error { private static final long serialVersionUID=1L; diff --git a/src/main/java/cyano/lootable/upgrade/LegacyProfessionRegistry.java b/src/main/java/cyano/lootable/upgrade/LegacyProfessionRegistry.java index 0d6043b..b18c090 100644 --- a/src/main/java/cyano/lootable/upgrade/LegacyProfessionRegistry.java +++ b/src/main/java/cyano/lootable/upgrade/LegacyProfessionRegistry.java @@ -1,7 +1,7 @@ package cyano.lootable.upgrade; import net.minecraft.resources.ResourceLocation; -import net.minecraftforge.event.RegistryEvent; +import net.minecraftforge.registries.NewRegistryEvent; import net.minecraftforge.registries.ForgeRegistryEntry; import net.minecraftforge.registries.IForgeRegistry; import net.minecraftforge.registries.RegistryBuilder; @@ -10,15 +10,17 @@ public final class LegacyProfessionRegistry { private LegacyProfessionRegistry() { } - public static void create(RegistryEvent.NewRegistry event) { - IForgeRegistry registry = new RegistryBuilder() + public static void create(NewRegistryEvent event) { + RegistryBuilder builder = new RegistryBuilder() .setName(new ResourceLocation("minecraft:villagerprofessions")) .setType(Entry.class).setIDRange(0, 32767) - .disableSaving().disableSync().create(); + .disableSaving().disableSync(); // These are the six actual 1.10-1.13 vanilla entries, in original ID order. // Unknown mod professions remain missing and retain Forge's normal gate. - for (String name : new String[]{"farmer", "librarian", "priest", "smith", "butcher", "nitwit"}) - registry.register(new Entry().setRegistryName(new ResourceLocation("minecraft", name))); + event.create(builder, registry -> { + for (String name : new String[]{"farmer", "librarian", "priest", "smith", "butcher", "nitwit"}) + registry.register(new Entry().setRegistryName(new ResourceLocation("minecraft", name))); + }); } public static final class Entry extends ForgeRegistryEntry { } diff --git a/src/main/java/cyano/lootable/upgrade/LegacyRegistryMappings.java b/src/main/java/cyano/lootable/upgrade/LegacyRegistryMappings.java index d3f90a4..cdffb02 100644 --- a/src/main/java/cyano/lootable/upgrade/LegacyRegistryMappings.java +++ b/src/main/java/cyano/lootable/upgrade/LegacyRegistryMappings.java @@ -7,9 +7,18 @@ import net.minecraftforge.fml.common.Mod; import net.minecraftforge.eventbus.api.SubscribeEvent; -/** The vanilla XP pickup sound was renamed after Minecraft 1.10. */ +/** Known vanilla registry renames, using the native conversion rules. */ @Mod.EventBusSubscriber(modid=LootableBodies.MODID,bus=Mod.EventBusSubscriber.Bus.FORGE) public final class LegacyRegistryMappings { + public static ResourceLocation biomeReplacement(ResourceLocation old) { + if(!old.getNamespace().equals("minecraft"))return null; + String name=old.toString(); + name=net.minecraft.util.datafix.fixes.BiomeFix.BIOMES.getOrDefault(name,name); + if(name.equals("minecraft:nether"))name="minecraft:nether_wastes"; + name=net.minecraft.util.datafix.fixes.CavesAndCliffsRenames.RENAMES.getOrDefault(name,name); + if(name.equals("minecraft:deep_warm_ocean"))name="minecraft:warm_ocean"; + return name.equals(old.toString())?null:new ResourceLocation(name); + } public static ResourceLocation itemReplacement(ResourceLocation old) { if (!old.getNamespace().equals("minecraft")) return null; switch (old.getPath()) { @@ -80,6 +89,13 @@ public static void retiredMemories(RegistryEvent.Registernet.minecraftforge.registries.ForgeRegistries.BLOCKS.getValue(replacement))); } } + @SubscribeEvent public static void biomes(RegistryEvent.MissingMappings event) { + if(!event.getRegistry().getRegistrySuperType().equals(net.minecraft.world.level.biome.Biome.class))return; + for(RegistryEvent.MissingMappings.Mapping mapping:event.getAllMappings()) { + ResourceLocation replacement=biomeReplacement(mapping.key); + if(replacement!=null)mapping.remap(requireMappingTarget(net.minecraftforge.registries.ForgeRegistries.BIOMES.containsKey(replacement),()->net.minecraftforge.registries.ForgeRegistries.BIOMES.getValue(replacement))); + } + } @SubscribeEvent public static void entities(RegistryEvent.MissingMappings> event) { if(!event.getRegistry().getRegistrySuperType().equals(net.minecraft.world.entity.EntityType.class))return; for(RegistryEvent.MissingMappings.Mapping> mapping:event.getAllMappings()) { diff --git a/src/main/java/cyano/lootable/upgrade/NativeSafeFixer.java b/src/main/java/cyano/lootable/upgrade/NativeSafeFixer.java index 612090c..bb6cd2b 100644 --- a/src/main/java/cyano/lootable/upgrade/NativeSafeFixer.java +++ b/src/main/java/cyano/lootable/upgrade/NativeSafeFixer.java @@ -55,7 +55,7 @@ private CompoundTag convert(DSL.TypeReference type,CompoundTag input,int source, if(currentSettings!=null)copy.remove("WorldGenSettings"); Tag currentBiomes=null; if(kind.equals("chunk")) { - CompoundTag level=copy.getCompound("Level"); + CompoundTag level=TerrainChunkData.payload(copy); // A damaged stamp can replay the old 2D biome expansion on an // already-current 3D array. Protect that field alone so older // vanilla entities and containers still receive their own fixes. @@ -68,7 +68,7 @@ private CompoundTag convert(DSL.TypeReference type,CompoundTag input,int source, if(source<1493 && (nonempty(level,"Sections") || nonempty(level,"Entities") || nonempty(level,"TileEntities"))) { level.putBoolean("TerrainPopulated",true);level.putBoolean("LightPopulated",true); } - shieldList(level.getList("Entities",10),source,tokens,level.getInt("xPos"),level.getInt("zPos"),true); + shieldList(level.getList(TerrainChunkData.entityKey(copy),10),source,tokens,level.getInt("xPos"),level.getInt("zPos"),true); } else if(kind.equals("entity_chunk")) { int[] position=copy.getIntArray("Position"); shieldList(copy.getList("Entities",10),source,tokens,position[0],position[1],true); @@ -80,20 +80,29 @@ else if(kind.equals("player")) { } protectEggs(copy,source,tokens); CompoundTag result; - if(source<704 && target>=705) { - // The old item hook writes Chest/Banner/Pig names back while the - // 704/705 schemas expect namespaced IDs. Cross that boundary on - // copies before the later item and flattening fixes run. - result=cyano.lootable.upgrade.NativeNbt.update(delegate,type,copy,source,703); - bridgeIdentifiers(result,kind); - result=cyano.lootable.upgrade.NativeNbt.update(delegate,type,result,705,target); - } else result=source=2832) { + // Cross the old 2D expansion on a copy without the already-3D + // array, then restore it before native18 consumes it into section + // biome palettes. A detached post-Level tag would lose this data. + result=nativeConvert(type,copy,source,2202); + if(!result.contains("Level",10))throw new IllegalStateException("Native biome prefix lost the legacy Level payload"); + result.getCompound("Level").put("Biomes",currentBiomes);currentBiomes=null; + result=nativeConvert(type,result,2202,target); + } else result=nativeConvert(type,copy,source,target); result=(CompoundTag)restore(result,tokens); - if(currentBiomes!=null)result.getCompound("Level").put("Biomes",currentBiomes); + if(currentBiomes!=null) { + if(!result.contains("Level",10))throw new IllegalStateException("Protected legacy biomes require staged native height conversion"); + result.getCompound("Level").put("Biomes",currentBiomes); + } if(currentSettings!=null)result.put("WorldGenSettings",updateCurrentWorldSettings((CompoundTag)currentSettings,source,target)); for(String token:tokens.values.keySet())if(!Integer.valueOf(1).equals(tokens.restored.get(token)))throw new IllegalStateException("Native conversion lost or duplicated protected Lootable Bodies data"); if(kind.equals("chunk")) { - CompoundTag level=result.getCompound("Level");ListTag entities=level.getList("Entities",10); + // Native height conversion flattens Level but leaves DataVersion + // for its ChunkStorage caller. Validate the converted payload with + // the proven conversion version, not a recovered stale input stamp. + // chunkVersion has already rejected genuinely future flat inputs. + result.putInt("DataVersion",Math.max(source,target)); + CompoundTag level=TerrainChunkData.payload(result);ListTag entities=level.getList(TerrainChunkData.entityKey(result),10); if(!UpgradeJournal.corpses(entities).isEmpty()) { if(!level.contains("xPos",3) || !level.contains("zPos",3))throw new IllegalStateException("Saved terrain has no containing coordinates for corpse recovery"); SavedCorpsePosition.prepare(entities,level.getInt("xPos"),level.getInt("zPos")); @@ -104,6 +113,17 @@ else if(kind.equals("player")) { if(kind.equals("chunk") || kind.equals("entity_chunk"))result.putInt("DataVersion",Math.max(source,target)); return result; } + private CompoundTag nativeConvert(DSL.TypeReference type,CompoundTag input,int source,int target) { + if(source<704 && target>=705) { + // The old item hook writes Chest/Banner/Pig names back while the + // 704/705 schemas expect namespaced IDs. Cross that boundary on + // copies before later item and flattening fixes run. + CompoundTag result=NativeNbt.update(delegate,type,input,source,703); + bridgeIdentifiers(result,type.typeName()); + return NativeNbt.update(delegate,type,result,705,target); + } + return sourceUpgradePreservation.MINECRAFT_VERSION) throw new IllegalStateException("Saved entity chunk comes from a newer Minecraft version"); // Terrain Sections/Biomes cannot identify the separate entity format. - // The owned current writer marker proves the native version even when - // an outer stamp was removed or downgraded. - for(UpgradeJournal.LocatedCorpse body:UpgradeJournal.corpses((ListTag)entities)) + // Current owned markers and the matching immutable journal can prove + // the minimum native source even when an outer stamp was damaged. + UpgradeJournal journal=preserved!=null?preserved:UpgradePreservation.journal(); + for(UpgradeJournal.LocatedCorpse body:UpgradeJournal.corpses((ListTag)entities)) { if(body.entity.getString("id").equals("lootablebodies:corpse") && body.entity.contains(UpgradePreservation.VERSION,3) && body.entity.getInt(UpgradePreservation.VERSION)==UpgradePreservation.DATA_VERSION) - return UpgradePreservation.MINECRAFT_VERSION; + version=Math.max(version,UpgradePreservation.MINECRAFT_VERSION); + else if(journal!=null && CorpseIdentity.present(body.entity))try { + CompoundTag original=journal.originalFor(CorpseIdentity.read(body.entity),position[0],position[1]); + if(original!=null) { + int proven=UpgradePreservation.chunkSourceVersion(original.getCompound("SourceVersions")); + if(proven>=512 && proven<=UpgradePreservation.MINECRAFT_VERSION)version=Math.max(version,proven); + } + }catch(IOException failure){throw new IllegalStateException("Entity chunk source context could not be verified",failure);} + } if(version<512)throw new IllegalStateException("Cannot prove saved entity chunk source version"); return version; } @@ -224,7 +253,7 @@ private CompoundTag sourceContext(CompoundTag entity,int version,int x,int z,boo }catch(IOException damaged){throw new IllegalStateException("Corpse preservation could not be verified",damaged);} } CompoundTag context=new CompoundTag();int marker=entity.getInt(UpgradePreservation.VERSION); - context.putInt("DataVersion",entity.getString("id").equals("lootablebodies.Corpse")?512:marker==1?922:marker==2?1343:marker==3?1631:marker==4?1976:marker==5?2230:marker==6?2586:version);return context; + context.putInt("DataVersion",entity.getString("id").equals("lootablebodies.Corpse")?512:marker==1?922:marker==2?1343:marker==3?1631:marker==4?1976:marker==5?2230:marker==6?2586:marker==7?2730:version);return context; } static CompoundTag stageCorpse(CompoundTag raw,CompoundTag context) { if(raw.contains(UpgradePreservation.VERSION,3) && raw.getInt(UpgradePreservation.VERSION)>UpgradePreservation.DATA_VERSION)throw new IllegalStateException("This corpse was saved by a newer Lootable Bodies format"); @@ -307,6 +336,7 @@ private static boolean compatible(int version,boolean legacy,boolean palette) { return version>=512 && version<=UpgradePreservation.MINECRAFT_VERSION && (!legacy || version<1451) && (!palette || version>=1451); } private static int chunkVersion(CompoundTag chunk,int requested) { + if(TerrainChunkData.payload(chunk)==chunk)return TerrainChunkData.flattenedVersion(chunk); boolean legacy=sections(chunk,"Blocks",7),palette=sections(chunk,"Palette",9); if(legacy && palette)throw new IllegalStateException("Mixed old and flattened sections cannot be converted safely"); int source=chunk.contains("DataVersion",3)?chunk.getInt("DataVersion"):requested; diff --git a/src/main/java/cyano/lootable/upgrade/TerrainChunkData.java b/src/main/java/cyano/lootable/upgrade/TerrainChunkData.java new file mode 100644 index 0000000..3f586f3 --- /dev/null +++ b/src/main/java/cyano/lootable/upgrade/TerrainChunkData.java @@ -0,0 +1,54 @@ +package cyano.lootable.upgrade; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; + +/** Distinguish native pre-1.18 Level payloads from the flattened 1.18 schema. */ +final class TerrainChunkData { + static final int FLATTENED_SCHEMA=2842; + private TerrainChunkData() { } + + static CompoundTag payload(CompoundTag root) { + if(root.contains("Level")) { + if(!root.contains("Level",10) || root.contains("xPos") || root.contains("zPos") || + root.contains("sections") || root.contains("entities") || root.contains("block_entities")) + throw new IllegalStateException("Ambiguous legacy and flattened terrain payload"); + CompoundTag level=root.getCompound("Level"); + compounds(level,"Entities");compounds(level,"TileEntities");compounds(level,"Sections"); + return level; + } + if(!root.contains("xPos",3) || !root.contains("zPos",3) || (root.contains("yPos")&&!root.contains("yPos",3)) || !root.contains("sections",9) || + root.contains("Entities") || root.contains("TileEntities") || root.contains("Sections")) + throw new IllegalStateException("Missing or ambiguous flattened terrain coordinates/schema"); + compounds(root,"sections");compounds(root,"entities");compounds(root,"block_entities"); + ListTag sections=root.getList("sections",10); + for(Tag value:sections) { + CompoundTag section=(CompoundTag)value; + // Native height conversion retains stored byte Ys and creates int + // Ys for new sections. Reject other types and byte-range overflow. + if((!section.contains("Y",1)&&!section.contains("Y",3)) || section.getInt("Y") < -128 || section.getInt("Y") > 127 || (section.contains("block_states")&&!section.contains("block_states",10)) || + (section.contains("biomes")&&!section.contains("biomes",10))) + throw new IllegalStateException("Unreadable flattened terrain section"); + } + flattenedVersion(root); + return root; + } + + static String entityKey(CompoundTag root) { return root.contains("Level",10)?"Entities":"entities"; } + + static int flattenedVersion(CompoundTag root) { + int stamp=root.contains("DataVersion",3)?root.getInt("DataVersion"):-1; + if(stamp>UpgradePreservation.MINECRAFT_VERSION)throw new IllegalStateException("Terrain was saved by a newer Minecraft version"); + // Valid root coordinates/sections prove the 2842 schema minimum, + // not the current game release. Let genuine later native fixes run. + return Math.max(stamp,FLATTENED_SCHEMA); + } + + private static void compounds(CompoundTag parent,String key) { + if(!parent.contains(key))return; + Tag value=parent.get(key); + if(!(value instanceof ListTag) || (((ListTag)value).size()>0 && ((ListTag)value).getElementType()!=10)) + throw new IllegalStateException("Unreadable terrain list: "+key); + } +} diff --git a/src/main/java/cyano/lootable/upgrade/UpgradeJournal.java b/src/main/java/cyano/lootable/upgrade/UpgradeJournal.java index 36f87fb..24db4d9 100644 --- a/src/main/java/cyano/lootable/upgrade/UpgradeJournal.java +++ b/src/main/java/cyano/lootable/upgrade/UpgradeJournal.java @@ -247,15 +247,15 @@ private CompoundTag scanRegion(Path file,String relative,String hash,CompoundTag throw new IOException("Entity chunk Position mismatch in "+relative); level=chunk; } else { - if(!chunk.contains("Level",10))throw new IOException("Missing chunk Level: "+relative); - level=chunk.getCompound("Level"); + level=TerrainChunkData.payload(chunk); if(!level.contains("xPos",3) || !level.contains("zPos",3) || level.getInt("xPos")!=x || level.getInt("zPos")!=z) throw new IOException("Chunk coordinate mismatch in "+relative); } - if(entityRegion && !level.contains("Entities"))throw new IOException("Missing native entity list in "+relative); - if(!level.contains("Entities")) continue; - if(!compoundList(level.get("Entities"))) throw new IOException("Unreadable entity list in "+relative); - ListTag entities=level.getList("Entities",10); + String entityKey=entityRegion?"Entities":TerrainChunkData.entityKey(chunk); + if(entityRegion && !level.contains(entityKey))throw new IOException("Missing native entity list in "+relative); + if(!level.contains(entityKey)) continue; + if(!compoundList(level.get(entityKey))) throw new IOException("Unreadable entity list in "+relative); + ListTag entities=level.getList(entityKey,10); String originalChunk=digest(payload); for(LocatedCorpse located:corpses(entities)) { CompoundTag entity=located.entity;int e=located.root; @@ -300,7 +300,10 @@ private static CompoundTag regionRecord(String relative,String hash,ListTag pres CompoundTag record=new CompoundTag();record.putString("Path",relative);record.putString("Digest",hash);record.put("Snapshots",preserved);return record; } private static CompoundTag comparisonPayload(CompoundTag chunk,boolean entityRegion) { - if(!entityRegion)return chunk.getCompound("Level"); + if(!entityRegion) { + CompoundTag payload=TerrainChunkData.payload(chunk); + if(payload!=chunk)return payload; + } CompoundTag payload=chunk.copy();payload.remove("DataVersion");return payload; } @@ -336,6 +339,7 @@ private static CompoundTag sourceVersions(CompoundTag chunk,CompoundTag world,Co else if(marker==5)version=2230; else if(marker==6)version=2586; else if(marker==7)version=2730; + else if(marker==8)version=2975; if(legacy) {if(version!=512)context.putBoolean("Inferred",true);version=512;} if(legacy || (marker>=1 && marker<=UpgradePreservation.DATA_VERSION)) context.putInt("CorpseWriterDataVersion",version); @@ -344,7 +348,8 @@ private static CompoundTag sourceVersions(CompoundTag chunk,CompoundTag world,Co for(int i=0;i event){ + keepsake=new Item(new Item.Properties()) { + @Override public ICapabilityProvider initCapabilities(ItemStack stack,CompoundTag nbt){return new Stored();} + }.setRegistryName("lootablebodiesbuildtest:keepsake"); + event.getRegistry().register(keepsake); + } + } + private static final class Stored implements ICapabilitySerializable { + private CompoundTag data=new CompoundTag(); + Stored(){data.putString("owner","Disposable fixture");data.putLong("number",9876543210123L);data.putByteArray("opaque",new byte[]{0,1,-1,17});} + public net.minecraftforge.common.util.LazyOptional getCapability(Capability capability,Direction side){return net.minecraftforge.common.util.LazyOptional.empty();} + public CompoundTag serializeNBT(){return data.copy();} + public void deserializeNBT(CompoundTag nbt){data=nbt.copy();} + } +} diff --git a/src/nativeSeed117/java/cyano/lootable/fixture/NativeWorldSeeder117.java b/src/nativeSeed117/java/cyano/lootable/fixture/NativeWorldSeeder117.java new file mode 100644 index 0000000..31c8b02 --- /dev/null +++ b/src/nativeSeed117/java/cyano/lootable/fixture/NativeWorldSeeder117.java @@ -0,0 +1,121 @@ +package cyano.lootable.fixture; + +import com.mojang.authlib.GameProfile; +import cyano.lootable.LootableBodies; +import cyano.lootable.entities.EntityLootableBody; +import cyano.lootable.buildtest.FixtureItems; +import java.io.*; +import java.util.*; +import net.minecraft.core.*; +import net.minecraft.nbt.*; +import net.minecraft.network.chat.TextComponent; +import net.minecraft.resources.*; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.*; +import net.minecraft.world.entity.*; +import net.minecraft.world.entity.animal.Pig; +import net.minecraft.world.entity.decoration.ArmorStand; +import net.minecraft.world.entity.npc.*; +import net.minecraft.world.entity.vehicle.MinecartChest; +import net.minecraft.world.item.*; +import net.minecraft.world.item.enchantment.Enchantments; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.biome.*; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.minecraft.world.level.chunk.ChunkBiomeContainer; +import net.minecraft.world.level.storage.*; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fmlserverevents.FMLServerStartedEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; + +/** Compiled against released17 source; runtime contains public854A, never target18. */ +@Mod("lootablebodiesbuildtest") +public final class NativeWorldSeeder117 { + private static final UUID PLAYER=UUID.fromString("00000000-2222-4222-8222-000000000001"); + public NativeWorldSeeder117(){FixtureItems.register(net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext.get().getModEventBus());net.minecraftforge.common.MinecraftForge.EVENT_BUS.register(this);} + @SubscribeEvent public void started(FMLServerStartedEvent event){ + MinecraftServer server=event.getServer(); + try{seed(server);}catch(Throwable failure){failure.printStackTrace();}finally{server.halt(false);} + } + private static void seed(MinecraftServer server)throws Exception { + String minecraft=server.getServerVersion(),version=net.minecraftforge.fml.ModList.get().getModContainerById("lootablebodies").orElseThrow().getModInfo().getVersion().toString(); + require(minecraft.equals("1.17.1") && version.equals("2.4.8.117011") && version.equals(System.getProperty("lootablebodies.fixture.version")),"Wrong public17 native writer identity"); + LootableBodies.allowCorpseDecay=false;LootableBodies.ticksPerItemDecay=-1; + CompoundTag manifest=new CompoundTag();manifest.putString("Minecraft",minecraft);manifest.putString("ModVersion",version); + ListTag bodies=new ListTag(),containers=new ListTag(),vanilla=new ListTag(),biomes=new ListTag(); + for(int dimension:new int[]{0,-1,1}) { + ServerLevel world=server.getLevel(dimensionKey(dimension)); + for(int wave=0;wave<3;wave++) { + int coordinate=wave==0?128:wave==1?4096:8192; + awaitChunk(world,coordinate>>4,coordinate>>4); + Biome[] samples=biomes(world.getChunk(coordinate>>4,coordinate>>4).getBiomes()); + Registry registry=world.registryAccess().registryOrThrow(Registry.BIOME_REGISTRY); + Biome[] choices={registry.getOrThrow(Biomes.PLAINS),registry.getOrThrow(Biomes.DESERT),registry.getOrThrow(Biomes.FOREST),registry.getOrThrow(Biomes.TAIGA)}; + int[] savedBiomes=new int[samples.length]; + for(int i=0;i>4);biomeRecord.putInt("ChunkZ",coordinate>>4);biomeRecord.putIntArray("Data",savedBiomes);biomes.add(biomeRecord); + EntityLootableBody body=new EntityLootableBody(world); + body.setUUID(UUID.nameUUIDFromBytes((minecraft+"/"+version+"/"+dimension+"/"+wave).getBytes("UTF-8"))); + body.setPos(coordinate+.5,80,coordinate+.5);body.setNoGravity(true);body.setUserName("SavedFixtureOwner");body.setRotation(37);body.setHealth(7.5F); + for(int slot=0;slot<48;slot++)body.setItem(slot,item(slot)); + for(int slot=70;slot<73;slot++)body.addItem(item(slot)); + if(Boolean.getBoolean("lootablebodies.fixture.recovery")) { + CompoundTag damaged=saved(body);ListTag equipment=damaged.getList("Equipment",10); + equipment.getCompound(20).putString("id","lootablebodiesbuildtest:returning_item"); + CompoundTag duplicate=equipment.getCompound(21).copy();duplicate.putByte("Count",(byte)9);duplicate.getCompound("tag").putString("extension","duplicate slot");equipment.add(duplicate); + CompoundTag invalid=equipment.getCompound(22).copy();invalid.putInt("Slot",999);invalid.putByte("Count",(byte)7);invalid.getCompound("tag").putString("extension","invalid slot");equipment.add(invalid); + damaged.putInt("Name",17);damaged.putDouble("DeathTime",Double.NaN);damaged.putString("Health","opaque health");damaged.putString("ActiveEffects","opaque effects");damaged.putString("HurtTime","opaque hurt timer");damaged.putDouble("FallDistance",Double.POSITIVE_INFINITY); + body.load(damaged);body.setPos(coordinate+.5,80,coordinate+.5); + } + java.lang.reflect.Field vacuum=EntityLootableBody.class.getDeclaredField("vacuumTime");vacuum.setAccessible(true);vacuum.setByte(body,(byte)20); + require(world.addFreshEntity(body),"Historical17 corpse admission"); + CompoundTag record=new CompoundTag();record.putInt("Dimension",dimension);record.putInt("Wave",wave);record.put("Body",saved(body));bodies.add(record); + BlockPos chestPos=new BlockPos(coordinate+2,80,coordinate+2);world.setBlockAndUpdate(chestPos,Blocks.CHEST.defaultBlockState()); + ChestBlockEntity chest=(ChestBlockEntity)world.getBlockEntity(chestPos);for(int slot=0;slot<16;slot++)chest.setItem(slot,item(slot+6)); + CompoundTag chestRecord=new CompoundTag();chestRecord.putInt("Dimension",dimension);chestRecord.putInt("Wave",wave);chestRecord.put("Tile",chest.save(new CompoundTag()));containers.add(chestRecord); + if(wave==0)seedVanilla(world,dimension,coordinate,vanilla); + } + } + ServerLevel world=server.getLevel(Level.OVERWORLD);ServerPlayer player=new ServerPlayer(server,world,new GameProfile(PLAYER,"UpgradeFixture"));player.setPos(136.5,82,136.5); + for(int slot=0;slot<36;slot++)player.getInventory().setItem(slot,item(slot+6)); + for(int slot=0;slot<4;slot++)player.setItemSlot(new EquipmentSlot[]{EquipmentSlot.HEAD,EquipmentSlot.CHEST,EquipmentSlot.LEGS,EquipmentSlot.FEET}[slot],item(slot)); + player.setItemSlot(EquipmentSlot.OFFHAND,item(5));for(int slot=0;slot<27;slot++)player.getEnderChestInventory().setItem(slot,item(slot+6)); + player.connection=new net.minecraft.server.network.ServerGamePacketListenerImpl(server,new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND),player){@Override public void send(net.minecraft.network.protocol.Packet packet){}}; + net.minecraft.world.item.crafting.Recipe sugar=server.getRecipeManager().byKey(new ResourceLocation("minecraft:sugar_from_sugar_cane")).orElseThrow();player.awardRecipes(Collections.singleton(sugar)); + net.minecraft.advancements.Advancement advancement=server.getAdvancements().getAdvancement(new ResourceLocation("minecraft:recipes/misc/sugar_from_sugar_cane"));require(advancement!=null,"Missing native17 sugar advancement"); + for(String criterion:advancement.getCriteria().keySet())player.getAdvancements().award(advancement,criterion);player.getAdvancements().save();playerData(server).save(player); + manifest.put("BiomeSamples",biomes);manifest.putString("Recipe","minecraft:sugar_from_sugar_cane");manifest.putString("Advancement","minecraft:recipes/misc/sugar_from_sugar_cane");manifest.putUUID("Player",PLAYER);manifest.put("Bodies",bodies);manifest.put("Containers",containers);manifest.put("Vanilla",vanilla); + server.saveAllChunks(true,true,false);File folder=server.getWorldPath(LevelResource.ROOT).toFile(); + try(FileOutputStream out=new FileOutputStream(new File(folder,"upgrade-fixtures.nbt"))){NbtIo.writeCompressed(manifest,out);} + try(FileWriter out=new FileWriter(new File(folder,"legacy-seed.pass"))){out.write(minecraft+" "+version+"\n");} + System.out.println("LOOTABLE_BODIES_LEGACY_SEED_PASS minecraft="+minecraft+" mod="+version+" corpses=9 dimensions=3"); + } + private static void seedVanilla(ServerLevel world,int dimension,int c,ListTag manifest){ + VillagerProfession[] professions={VillagerProfession.FARMER,VillagerProfession.LIBRARIAN,VillagerProfession.CLERIC,VillagerProfession.ARMORER,VillagerProfession.BUTCHER,VillagerProfession.NITWIT}; + for(int i=0;i dimensionKey(int id){if(id==0)return Level.OVERWORLD;if(id==-1)return Level.NETHER;if(id==1)return Level.END;throw new IllegalArgumentException("dimension "+id);} + private static void awaitChunk(ServerLevel world,int x,int z){world.getChunkSource().getChunk(x,z,net.minecraft.world.level.chunk.ChunkStatus.FULL,true);require(world.getChunkSource().hasChunk(x,z),"Native17 chunk admission");} + private static Biome[] biomes(ChunkBiomeContainer container)throws Exception {java.lang.reflect.Field found=null;for(java.lang.reflect.Field f:ChunkBiomeContainer.class.getDeclaredFields())if(f.getType()==Biome[].class){require(found==null,"Ambiguous native17 biome array");found=f;}require(found!=null,"Missing native17 biome array");found.setAccessible(true);return (Biome[])found.get(container);} + private static PlayerDataStorage playerData(MinecraftServer server)throws Exception {java.lang.reflect.Field found=null;for(Class owner=server.getClass();owner!=null;owner=owner.getSuperclass())for(java.lang.reflect.Field f:owner.getDeclaredFields())if(f.getType()==PlayerDataStorage.class){require(found==null,"Ambiguous native17 player storage");found=f;}require(found!=null,"Missing native17 player storage");found.setAccessible(true);return (PlayerDataStorage)found.get(server);} + private static ItemStack item(int slot){ + ItemStack stack; + if(slot<4)stack=new ItemStack(new Item[]{Items.DIAMOND_HELMET,Items.DIAMOND_CHESTPLATE,Items.DIAMOND_LEGGINGS,Items.DIAMOND_BOOTS}[slot]); + else if(slot==4)stack=new ItemStack(Items.DIAMOND_SWORD);else if(slot==5)stack=new ItemStack(Items.SHIELD);else if(slot==6)stack=new ItemStack(Items.PIG_SPAWN_EGG,3);else if(slot==7)stack=new ItemStack(Blocks.CHEST,2);else if(slot==8)stack=new ItemStack(Items.LIME_BANNER,2);else if(slot==9)stack=new ItemStack(FixtureItems.keepsake,11);else if(slot==10)stack=new ItemStack(Items.WHITE_BED);else if(slot==11)stack=new ItemStack(Items.LIME_BED);else if(slot==12)stack=new ItemStack(Items.OAK_SIGN,11);else stack=new ItemStack(Items.DIAMOND,slot%32+1); + CompoundTag tag=new CompoundTag();tag.putString("fixture_slot","original-"+slot);tag.putLong("typed_long",1234567890123L);tag.putByteArray("typed_bytes",new byte[]{0,-1,17});tag.putLongArray("typed_longs",new long[]{Long.MIN_VALUE,slot,Long.MAX_VALUE}); + if(slot==6){CompoundTag pig=new CompoundTag();pig.putString("id","minecraft:pig");pig.putString("CustomName",net.minecraft.network.chat.Component.Serializer.toJson(new TextComponent("Saved pig egg")));tag.put("EntityTag",pig);} + if(slot==7){CompoundTag tile=new CompoundTag();ListTag items=new ListTag();CompoundTag nested=new ItemStack(Items.EMERALD,17).save(new CompoundTag());nested.putByte("Slot",(byte)3);items.add(nested);tile.put("Items",items);tag.put("BlockEntityTag",tile);} + stack.setTag(tag);if(slot<6)stack.setDamageValue(slot<4?slot+2:slot==4?17:9);stack.setHoverName(new TextComponent("Original keepsake "+slot));if(slot==4)stack.enchant(Enchantments.UNBREAKING,2);return stack; + } + private static void require(boolean condition,String reason){if(!condition)throw new AssertionError(reason);} +} diff --git a/src/nativeSeed117/resources/META-INF/mods.toml b/src/nativeSeed117/resources/META-INF/mods.toml new file mode 100644 index 0000000..70831de --- /dev/null +++ b/src/nativeSeed117/resources/META-INF/mods.toml @@ -0,0 +1,13 @@ +modLoader="javafml" +loaderVersion="[37,38)" +license="LGPL-2.1-only" +[[mods]] +modId="lootablebodiesbuildtest" +version="1" +displayName="Disposable historical world writer" +[[dependencies.lootablebodiesbuildtest]] +modId="lootablebodies" +mandatory=true +versionRange="[2.4.8.117011]" +ordering="AFTER" +side="BOTH" diff --git a/src/nativeSeed117/resources/pack.mcmeta b/src/nativeSeed117/resources/pack.mcmeta new file mode 100644 index 0000000..bd26c81 --- /dev/null +++ b/src/nativeSeed117/resources/pack.mcmeta @@ -0,0 +1 @@ +{"pack":{"pack_format":7,"description":"Disposable historical world writer"}} diff --git a/src/test/java/cyano/lootable/buildtest/HistoricalVersionsTest.java b/src/test/java/cyano/lootable/buildtest/HistoricalVersionsTest.java index 7381661..7556d71 100644 --- a/src/test/java/cyano/lootable/buildtest/HistoricalVersionsTest.java +++ b/src/test/java/cyano/lootable/buildtest/HistoricalVersionsTest.java @@ -21,4 +21,7 @@ class HistoricalVersionsTest { assertThrows(IllegalArgumentException.class, () -> HistoricalVersions.dataVersion("")); assertThrows(IllegalArgumentException.class, () -> HistoricalVersions.dataVersion("1.99.2")); } + @Test void released117InventoryKeepsItsActual2730SourceContext() { + assertEquals(2730,HistoricalVersions.dataVersion("1.17.1")); + } } diff --git a/src/test/java/cyano/lootable/buildtest/Native115PacketTest.java b/src/test/java/cyano/lootable/buildtest/Native115PacketTest.java index 93e2a47..e1c63a6 100644 --- a/src/test/java/cyano/lootable/buildtest/Native115PacketTest.java +++ b/src/test/java/cyano/lootable/buildtest/Native115PacketTest.java @@ -17,7 +17,7 @@ class Native115PacketTest { static { net.minecraft.SharedConstants.tryDetectVersion();net.minecraft.server.Bootstrap.bootStrap(); } private static final long SEED=0x13572468ABCDEF01L; private ClientboundRespawnPacket packet(ResourceKey dimension,long seed) { - DimensionType type=RegistryAccess.builtin().registryOrThrow(Registry.DIMENSION_TYPE_REGISTRY).getOrThrow(DimensionType.OVERWORLD_LOCATION); + net.minecraft.core.Holder type=RegistryAccess.builtinCopy().registryOrThrow(Registry.DIMENSION_TYPE_REGISTRY).getHolderOrThrow(DimensionType.OVERWORLD_LOCATION); return new ClientboundRespawnPacket(type,dimension,seed,GameType.SURVIVAL,GameType.CREATIVE,false,true,true); } @Test void native115HashedSeedSurvivesDecodedPacketObservation() throws Exception { @@ -30,9 +30,7 @@ private ClientboundRespawnPacket packet(ResourceKey dimension,long seed) String expected="RESPAWN dimension=minecraft:the_nether hashedSeed="+SEED; assertEquals(expected,NativeRespawnDescription.describe(outgoing)); assertEquals(expected,NativeRespawnDescription.describe(incoming)); - for(Field field:ClientboundRespawnPacket.class.getDeclaredFields()) if(field.getType()==DimensionType.class) { - field.setAccessible(true);assertNotNull(field.get(incoming),"Native codec dimension type was lost"); - } + assertNotNull(incoming.getDimensionType().value(),"Native codec dimension type was lost"); assertEquals(before.length,bytes.readerIndex()); byte[] after=new byte[before.length];bytes.getBytes(0,after);assertArrayEquals(before,after); } finally {bytes.release();} diff --git a/src/test/java/cyano/lootable/buildtest/Native117ForgeConfigTest.java b/src/test/java/cyano/lootable/buildtest/Native117ForgeConfigTest.java index 5a1ed01..387a98f 100644 --- a/src/test/java/cyano/lootable/buildtest/Native117ForgeConfigTest.java +++ b/src/test/java/cyano/lootable/buildtest/Native117ForgeConfigTest.java @@ -9,7 +9,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; -/** Execute the pinned native37 spec; config corrections precede mod world hooks. */ +/** Execute the pinned native40 spec; config corrections precede mod world hooks. */ class Native117ForgeConfigTest { private ForgeConfigSpec spec() throws Exception { java.lang.reflect.Field field=ForgeConfig.class.getDeclaredField("serverSpec"); @@ -25,10 +25,11 @@ private Set keys(CommentedConfig config) { CommentedConfig config=old();Set previous=new HashSet<>(keys(config)); spec().correct(config); Set removed=new HashSet<>(previous);removed.removeAll(keys(config)); - assertEquals(new HashSet<>(Arrays.asList("removeErroringEntities","removeErroringTileEntities", + assertEquals(new HashSet<>(Arrays.asList("removeErroringTileEntities", "logCascadingWorldGeneration","fixVanillaCascading","dimensionUnloadQueueDelay")),removed); assertEquals(new HashSet<>(Arrays.asList("removeErroringBlockEntities","fullBoundingBoxLadders", - "zombieBaseSummonChance","zombieBabyChance","treatEmptyTagsAsAir","fixAdvancementLoading")),keys(config)); + "zombieBaseSummonChance","zombieBabyChance","treatEmptyTagsAsAir","fixAdvancementLoading", + "removeErroringEntities","permissionHandler","useItemWithDurationZero","skipEmptyShapelessCheck")),keys(config)); assertEquals(false,config.get("server.removeErroringBlockEntities")); assertEquals(0,spec().correct(config),"Native correction must be stable"); } @@ -37,14 +38,16 @@ private Set keys(CommentedConfig config) { config.set("server.zombieBaseSummonChance",0.37D);config.set("server.zombieBabyChance",0.29D); config.set("server.treatEmptyTagsAsAir",true);config.set("server.fixAdvancementLoading",false); config.set("server.removeErroringBlockEntities",true); + config.set("server.removeErroringEntities",true);config.set("server.useItemWithDurationZero",true);config.set("server.skipEmptyShapelessCheck",false); spec().correct(config); assertEquals(true,config.get("server.fullBoundingBoxLadders"));assertEquals(0.37D,(Double)config.get("server.zombieBaseSummonChance")); assertEquals(0.29D,(Double)config.get("server.zombieBabyChance"));assertEquals(true,config.get("server.treatEmptyTagsAsAir")); assertEquals(false,config.get("server.fixAdvancementLoading"));assertEquals(true,config.get("server.removeErroringBlockEntities")); + assertEquals(true,config.get("server.removeErroringEntities"));assertEquals(true,config.get("server.useItemWithDurationZero"));assertEquals(false,config.get("server.skipEmptyShapelessCheck")); } - @Test void actualNativeDefaultMatchesRetainedPackagedOutput() throws Exception { + @Test void actualNativeDefaultMatchesTheTargetSpecBaseline() throws Exception { CommentedConfig config=CommentedConfig.inMemory();spec().correct(config); - CommentedConfig expected=new TomlParser().parse(Files.readString(Paths.get("tools/fixtures/forge37-server-default.toml"))); + CommentedConfig expected=new TomlParser().parse(Files.readString(Paths.get("tools/fixtures/forge40-server-default.toml"))); assertEquals(expected,config); } } diff --git a/src/test/java/cyano/lootable/buildtest/Native117ProbeIsolationTest.java b/src/test/java/cyano/lootable/buildtest/Native117ProbeIsolationTest.java index 1ab6b09..dcb0d5a 100644 --- a/src/test/java/cyano/lootable/buildtest/Native117ProbeIsolationTest.java +++ b/src/test/java/cyano/lootable/buildtest/Native117ProbeIsolationTest.java @@ -6,7 +6,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; -/** Native37 loads mods as distinct Java modules: split packages are fatal. */ +/** Native40 loads mods as distinct Java modules: split packages are fatal. */ class Native117ProbeIsolationTest { private Set packages(String archive) throws Exception { Set result=new TreeSet<>(); @@ -15,16 +15,16 @@ private Set packages(String archive) throws Exception { java.util.zip.ZipEntry entry=entries.nextElement();String path=entry.getName(); if(!path.endsWith(".class"))continue; byte[] code=zip.getInputStream(entry).readAllBytes(); - assertEquals(60,((code[6]&255)<<8)|(code[7]&255),path); + assertEquals(61,((code[6]&255)<<8)|(code[7]&255),path); result.add(path.substring(0,path.lastIndexOf('/'))); } } assertFalse(result.isEmpty(),archive);return result; } @Test void integrationModuleHasNoProductionPackageDefinitions() throws Exception { - Set main=packages("build/libs-dev/LootableBodies-2.4.8.117011-deobf.jar"); + Set main=packages("build/libs-dev/LootableBodies-2.4.9.118021-deobf.jar"); Set probe=packages("build/integration-test-mod/lootablebodies-build-tests.jar"); - probe.retainAll(main);assertTrue(probe.isEmpty(),"Native37 module split packages: "+probe); + probe.retainAll(main);assertTrue(probe.isEmpty(),"Native40 module split packages: "+probe); } @Test void independentControlHasNoModOrIntegrationPackages() throws Exception { Set control=packages("build/control-test-mod/forge-platform-control.jar"); diff --git a/src/test/java/cyano/lootable/buildtest/Native117TestEnvironment.java b/src/test/java/cyano/lootable/buildtest/Native117TestEnvironment.java index 397e451..77842a4 100644 --- a/src/test/java/cyano/lootable/buildtest/Native117TestEnvironment.java +++ b/src/test/java/cyano/lootable/buildtest/Native117TestEnvironment.java @@ -7,6 +7,11 @@ public final class Native117TestEnvironment implements LauncherSessionListener { @Override public void launcherSessionOpened(LauncherSession session) { net.minecraft.SharedConstants.tryDetectVersion(); + // Plain JUnit has no ModLauncher-generated event constructors. Seed + // the real EventBus list through its supported instance API before + // native Bootstrap registers the Forge handshake channel listener. + new net.minecraftforge.network.NetworkEvent(() -> null).getListenerList(); + new net.minecraftforge.network.NetworkEvent.GatherLoginPayloadsEvent(new java.util.ArrayList<>(),false).getListenerList(); net.minecraft.server.Bootstrap.bootStrap(); } } diff --git a/src/test/java/cyano/lootable/buildtest/Native118FixtureGenerator.java b/src/test/java/cyano/lootable/buildtest/Native118FixtureGenerator.java new file mode 100644 index 0000000..47aaf0c --- /dev/null +++ b/src/test/java/cyano/lootable/buildtest/Native118FixtureGenerator.java @@ -0,0 +1,43 @@ +package cyano.lootable.buildtest; + +import java.nio.file.*; +import java.util.UUID; +import com.mojang.serialization.Lifecycle; +import net.minecraft.core.*; +import net.minecraft.nbt.*; +import net.minecraft.world.*; +import net.minecraft.world.level.*; +import net.minecraft.world.level.biome.*; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.levelgen.*; +import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings; +import net.minecraft.world.level.storage.PrimaryLevelData; +import cyano.lootable.upgrade.Native117RegionFixtures; + +/** Native18 codec fixture preparation, never authentic historical-writer proof. */ +public final class Native118FixtureGenerator { + public static void main(String[] args)throws Exception { + if(args.length!=1)throw new IllegalArgumentException("One new owned output root required"); + Path root=Path.of(args[0]);Files.createDirectory(root); + new Native117TestEnvironment().launcherSessionOpened(null); + RegistryAccess.Frozen registries=RegistryAccess.BUILTIN.get(); + Registry biomes=registries.registryOrThrow(Registry.BIOME_REGISTRY); + Registry structures=registries.registryOrThrow(Registry.STRUCTURE_SET_REGISTRY); + WorldGenSettings generation=new WorldGenSettings(4267321L,false,false,WorldGenSettings.withOverworld( + registries.registryOrThrow(Registry.DIMENSION_TYPE_REGISTRY),DimensionType.defaultDimensions(registries,4267321L), + new FlatLevelSource(structures,FlatLevelGeneratorSettings.getDefault(biomes,structures)))); + LevelSettings settings=new LevelSettings("Native18 disposable fixture",GameType.CREATIVE,false,Difficulty.NORMAL,true,new GameRules(),DataPackConfig.DEFAULT); + PrimaryLevelData nativeData=new PrimaryLevelData(settings,generation,Lifecycle.stable()); + nativeData.setSpawn(new BlockPos(0,80,0),0);nativeData.setGameTime(0);nativeData.setDayTime(6000); + CompoundTag data=nativeData.createTag(registries,null);data.putLong("LastPlayed",0); + if(data.getInt("DataVersion")!=2975 || !data.contains("WorldGenSettings",10))throw new AssertionError("Native18 header was not encoded"); + CompoundTag header=new CompoundTag();header.put("Data",data); + NbtIo.writeCompressed(header,root.resolve("native118-flat-level.dat").toFile()); + Path world=root.resolve("controlled-entity-source");Files.createDirectory(world); + CompoundTag originalOwned=new CompoundTag();originalOwned.putString("unknownField","retained-native18-header");originalOwned.putLongArray("unknownData",new long[]{Long.MIN_VALUE,1234567890123L,Long.MAX_VALUE}); + header.put("lootablebodies",originalOwned);NbtIo.writeCompressed(header,world.resolve("level.dat").toFile()); + CompoundTag corpse=Native117RegionFixtures.body(UUID.fromString("00000000-1171-4171-8171-000000000099"),7); + Native117RegionFixtures.write(world,Native117RegionFixtures.chunk(2730,corpse)); + System.out.println("NATIVE118_CODEC_FIXTURE_PREPARED version=2975 markerSource=7/2730 synthetic=true root="+root); + } +} diff --git a/src/test/java/cyano/lootable/buildtest/Native118RespawnArenaTest.java b/src/test/java/cyano/lootable/buildtest/Native118RespawnArenaTest.java new file mode 100644 index 0000000..6548df6 --- /dev/null +++ b/src/test/java/cyano/lootable/buildtest/Native118RespawnArenaTest.java @@ -0,0 +1,30 @@ +package cyano.lootable.buildtest; + +import java.util.*; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** Geometric fluid ingress regression; actual native fluid/packets remain runtime proof. */ +class Native118RespawnArenaTest { + @Test void naturalLavaAboveOrBesideTheArenaCannotReachItsPlayableInterior() { + for(BlockPos floor:List.of(new BlockPos(96,79,96),new BlockPos(-1,79,-1),new BlockPos(160,79,96))) { + Map states=new HashMap<>();FixtureArena.prepare(floor,(pos,state)->states.put(pos.immutable(),state)); + for(BlockPos source:List.of(floor.offset(0,8,0),floor.offset(8,3,0),floor.offset(0,3,-8))) { + Set visited=new HashSet<>();Deque pending=new ArrayDeque<>();pending.add(source); + boolean reached=false; + while(!pending.isEmpty()) { + BlockPos pos=pending.removeFirst();int dx=pos.getX()-floor.getX(),dy=pos.getY()-floor.getY(),dz=pos.getZ()-floor.getZ(); + if(Math.abs(dx)>9||Math.abs(dz)>9||dy<0||dy>8||!visited.add(pos)||!states.getOrDefault(pos,Blocks.AIR.defaultBlockState()).isAir())continue; + if(Math.abs(dx)<=6&&Math.abs(dz)<=6&&dy>=1&&dy<=5){reached=true;break;} + pending.add(pos.below());pending.add(pos.north());pending.add(pos.south());pending.add(pos.west());pending.add(pos.east()); + } + assertFalse(reached,"Ambient fluid ingress from "+source+" into disposable arena at "+floor); + } + for(int dx=-6;dx<=6;dx++)for(int dz=-6;dz<=6;dz++)for(int dy=1;dy<=5;dy++) + assertTrue(states.get(floor.offset(dx,dy,dz)).isAir(),"Keep the original playable space clear"); + } + } +} diff --git a/src/test/java/cyano/lootable/contracts/BuildContractTest.java b/src/test/java/cyano/lootable/contracts/BuildContractTest.java index 323a807..4d1b2f3 100644 --- a/src/test/java/cyano/lootable/contracts/BuildContractTest.java +++ b/src/test/java/cyano/lootable/contracts/BuildContractTest.java @@ -50,9 +50,9 @@ private String text(String path) throws Exception { assertTrue(info.contains("modId=\"lootablebodies\"")); Properties release=new Properties();try(java.io.InputStream input=Files.newInputStream(Paths.get("gradle.properties"))){release.load(input);} assertTrue(info.contains("version=\""+release.getProperty("mod_version")+"\"")); - assertTrue(info.contains("versionRange=\"[1.17.1]\""));assertTrue(info.contains("Cyanobacterium"));assertFalse(info.contains("${")); + assertTrue(info.contains("versionRange=\"[1.18.2]\""));assertTrue(info.contains("Cyanobacterium"));assertFalse(info.contains("${")); assertFalse(Files.exists(Paths.get("build/resources/main/mcmod.info"))); - assertEquals(7,new com.google.gson.JsonParser().parse(text("build/resources/main/pack.mcmeta")).getAsJsonObject().getAsJsonObject("pack").get("pack_format").getAsInt()); + assertEquals(9,new com.google.gson.JsonParser().parse(text("build/resources/main/pack.mcmeta")).getAsJsonObject().getAsJsonObject("pack").get("pack_format").getAsInt()); } @Test void everyOriginalTranslationSurvivesTheNativeJsonFormat() throws Exception { try(DirectoryStream locales=Files.newDirectoryStream(Paths.get("src/test/resources/legacy-translations"),"*.lang")){ diff --git a/src/test/java/cyano/lootable/contracts/NativeUpgradeBoundaryTest.java b/src/test/java/cyano/lootable/contracts/NativeUpgradeBoundaryTest.java index 969480c..b204a65 100644 --- a/src/test/java/cyano/lootable/contracts/NativeUpgradeBoundaryTest.java +++ b/src/test/java/cyano/lootable/contracts/NativeUpgradeBoundaryTest.java @@ -57,7 +57,7 @@ private CompoundTag find(Tag value) { } @Test void legacyIdentityAndEveryRawInventoryEntrySurviveBothAnvilPasses()throws Exception { CompoundTag body=corpse(),source=chunk(body,true);byte[] before=encoded(source);CompoundTag result=update(source),saved=findCorpse(result); - assertEquals("lootablebodies:corpse",saved.getString("id"));assertEquals(7,saved.getInt("LootableBodiesDataVersion"));assertEquals(ID,saved.getUUID("UUID")); + assertEquals("lootablebodies:corpse",saved.getString("id"));assertEquals(8,saved.getInt("LootableBodiesDataVersion"));assertEquals(ID,saved.getUUID("UUID")); ListTag records=saved.getList("LootableBodiesUnresolved",10);assertEquals(49,records.size()); for(int i=0;i<48;i++){CompoundTag record=records.getCompound(i);assertEquals("Equipment",record.getString("Source"));assertArrayEquals(encoded(body.getList("Equipment",10).getCompound(i)),encoded(record.getCompound("Data")));assertEquals(512,record.getCompound("SourceVersions").getInt("DataVersion"));} assertEquals("Aux",records.getCompound(48).getString("Source"));assertArrayEquals(before,encoded(source));assertFalse(saved.contains("LootableBodiesUpgradePayload"));assertFalse(saved.contains("LootableBodiesInventorySource")); diff --git a/src/test/java/cyano/lootable/contracts/ReleaseContractTest.java b/src/test/java/cyano/lootable/contracts/ReleaseContractTest.java index 9e8ae81..cf5e74b 100644 --- a/src/test/java/cyano/lootable/contracts/ReleaseContractTest.java +++ b/src/test/java/cyano/lootable/contracts/ReleaseContractTest.java @@ -28,7 +28,7 @@ private String text(String path) throws Exception { Properties values = properties(); String version = values.getProperty("mod_version"); assertTrue(version.matches("[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+"), version); - assertEquals("2.4.8.117011", version); + assertEquals("2.4.9.118021", version); assertEquals("forge", values.getProperty("loader_name")); assertEquals("1", values.getProperty("loader_code")); assertTrue(text("src/main/java/cyano/lootable/LootableBodies.java") @@ -44,7 +44,7 @@ private String text(String path) throws Exception { String source = text("src/main/java/cyano/lootable/LootableBodies.java"); assertTrue(source.startsWith("package cyano.lootable;")); assertTrue(source.contains("new ResourceLocation(\"lootablebodies:corpse\")")); - assertTrue(text("build/resources/main/META-INF/mods.toml").contains("versionRange=\"[1.17.1]\"")); + assertTrue(text("build/resources/main/META-INF/mods.toml").contains("versionRange=\"[1.18.2]\"")); } @Test void maintainedReleaseHasLgplAndRetainsTheOriginalNotice() throws Exception { diff --git a/src/test/java/cyano/lootable/contracts/UpgradeJournalTest.java b/src/test/java/cyano/lootable/contracts/UpgradeJournalTest.java index 1e6e670..716b222 100644 --- a/src/test/java/cyano/lootable/contracts/UpgradeJournalTest.java +++ b/src/test/java/cyano/lootable/contracts/UpgradeJournalTest.java @@ -182,7 +182,7 @@ private Path regionVersion(String dimension,int version,CompoundTag... bodies) t @Test void truncatedRegionAbortsBeforeLoading() throws Exception {Path region=region("",body(ID));Files.write(region,new byte[8193]);assertThrows(IOException.class,()->scan());} @Test void nativeEmptyRegionPlaceholdersRemainReadOnlyAndAreScannedWhenPopulated()throws Exception { Path folder=world.resolve("region");Files.createDirectories(folder);Path file=folder.resolve("r.0.0.mca"); - try(net.minecraft.world.level.chunk.storage.RegionFile nativeRegion=new net.minecraft.world.level.chunk.storage.RegionFile(file.toFile(),folder.toFile(),false)) { + try(net.minecraft.world.level.chunk.storage.RegionFile nativeRegion=new net.minecraft.world.level.chunk.storage.RegionFile(file,folder,false)) { assertNull(nativeRegion.getChunkDataInputStream(new net.minecraft.world.level.ChunkPos(0,0))); } assertEquals(0,Files.size(file));assertNull(original(scan(),ID,0,0));assertEquals(0,Files.size(file)); diff --git a/src/test/java/cyano/lootable/entities/Native118EffectIdentityTest.java b/src/test/java/cyano/lootable/entities/Native118EffectIdentityTest.java new file mode 100644 index 0000000..06dfc2c --- /dev/null +++ b/src/test/java/cyano/lootable/entities/Native118EffectIdentityTest.java @@ -0,0 +1,36 @@ +package cyano.lootable.entities; + +import net.minecraft.nbt.*; +import net.minecraft.world.effect.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class Native118EffectIdentityTest { + private CompoundTag nativeEffect(){return new MobEffectInstance(MobEffects.NIGHT_VISION,200,1,false,true,false).save(new CompoundTag());} + @Test void nativeForgeSaveUsesARecognizedStableNamedIdentity() { + CompoundTag raw=nativeEffect(),before=raw.copy();assertEquals("minecraft:night_vision",raw.getString("forge:id")); + assertSame(MobEffects.NIGHT_VISION,SavedEffectIdentity.resolve(raw,16L));assertFalse(SavedEffectIdentity.needsLegacyRecovery(raw,16L)); + CompoundTag accepted=new CompoundTag();SavedEffectIdentity.write(accepted,raw,MobEffects.NIGHT_VISION); + assertEquals(raw.get("Id"),accepted.get("Id"));assertEquals(raw.get("forge:id"),accepted.get("forge:id"));assertEquals(before,raw); + } + @Test void validatedNameFollowsActualNativePrecedenceWhenLegacyNumbersWereRemapped() { + CompoundTag raw=nativeEffect();raw.putByte("Id",(byte)MobEffect.getId(MobEffects.MOVEMENT_SPEED)); + assertSame(MobEffects.NIGHT_VISION,MobEffectInstance.load(raw).getEffect()); + assertSame(MobEffects.NIGHT_VISION,SavedEffectIdentity.resolve(raw,1L));assertFalse(SavedEffectIdentity.needsLegacyRecovery(raw,1L)); + } + @Test void knownNameCanRecoverInvalidLegacyNumbersWithoutDiscardingTheRawRecord() { + CompoundTag raw=nativeEffect();raw.putString("Id","damaged legacy fallback"); + assertSame(MobEffects.NIGHT_VISION,SavedEffectIdentity.resolve(raw,null));assertTrue(SavedEffectIdentity.needsLegacyRecovery(raw,null)); + } + @Test void malformedAndUnknownNamedIdentitiesRefuseRatherThanActivateAnUnrelatedFallback() { + for(Tag bad:new Tag[]{IntTag.valueOf(16),new ListTag(),StringTag.valueOf("bad name?!"),StringTag.valueOf("missing:unavailable_effect")}) { + CompoundTag raw=nativeEffect();raw.put("forge:id",bad);CompoundTag before=raw.copy(); + assertNull(SavedEffectIdentity.resolve(raw,16L));assertEquals(before,raw); + } + } + @Test void legacyOnlyAndNativeEmptyNameFallbacksRemainBounded() { + CompoundTag raw=nativeEffect();raw.remove("forge:id");assertSame(MobEffects.NIGHT_VISION,SavedEffectIdentity.resolve(raw,16L)); + for(Long bad:new Long[]{null,-1L,256L,4294967312L})assertNull(SavedEffectIdentity.resolve(raw,bad)); + raw.putString("forge:id","");assertSame(MobEffects.NIGHT_VISION,SavedEffectIdentity.resolve(raw,16L));assertTrue(SavedEffectIdentity.needsLegacyRecovery(raw,16L)); + } +} diff --git a/src/test/java/cyano/lootable/upgrade/EarlyWorldPreservationTest.java b/src/test/java/cyano/lootable/upgrade/EarlyWorldPreservationTest.java index 379a22a..7a0106a 100644 --- a/src/test/java/cyano/lootable/upgrade/EarlyWorldPreservationTest.java +++ b/src/test/java/cyano/lootable/upgrade/EarlyWorldPreservationTest.java @@ -6,13 +6,12 @@ import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.NbtIo; import net.minecraft.world.level.storage.LevelStorageSource; -import net.minecraftforge.fmllegacy.WorldPersistenceHooks; import org.junit.jupiter.api.*; import org.junit.jupiter.api.io.TempDir; import static org.junit.jupiter.api.Assertions.*; class EarlyWorldPreservationTest { - @TempDir Path directory; + @TempDir(cleanup=org.junit.jupiter.api.io.CleanupMode.ON_SUCCESS) Path directory; @AfterEach void close(){UpgradePreservation.close();} private CompoundTag level(int version) { CompoundTag root=new CompoundTag(),data=new CompoundTag();data.putInt("DataVersion",version);root.put("Data",data); @@ -24,27 +23,27 @@ private void write(Path world,CompoundTag root)throws Exception { @Test void nativeReadHookAbortsBeforeFallbackOrLevelWrites()throws Exception { Path world=directory.resolve("fixture");CompoundTag root=level(2230);write(world,root); Path journal=world.resolve("data/lootablebodies-upgrade");Files.createDirectories(journal);Files.write(journal.resolve("index.nbt"),new byte[]{1,2,3}); - byte[] before=Files.readAllBytes(world.resolve("level.dat"));EarlyWorldPreservation.install(); + byte[] before=Files.readAllBytes(world.resolve("level.dat"));Native118UnitLifecycle.activate(); LevelStorageSource format=new LevelStorageSource(directory,directory.resolve("backups"),NativeVanillaDataFixer.get()); try(LevelStorageSource.LevelStorageAccess save=format.createAccess("fixture")) { - Error failure=assertThrows(Error.class,()->WorldPersistenceHooks.handleWorldDataLoad(save,null,root)); + Error failure=assertThrows(Error.class,()->EarlyWorldPreservation.beforeRead(save)); assertTrue(failure.getMessage().contains("Lootable Bodies could not preserve this world's corpses")); } assertArrayEquals(before,Files.readAllBytes(world.resolve("level.dat"))); assertArrayEquals(new byte[]{1,2,3},Files.readAllBytes(journal.resolve("index.nbt"))); } @Test void startupKeepsItsEarlySourceContextAndUnknownOwnedMetadata()throws Exception { - Path world=directory.resolve("fixture");CompoundTag root=level(2230);write(world,root);EarlyWorldPreservation.install(); + Path world=directory.resolve("fixture");CompoundTag root=level(2230);write(world,root);Native118UnitLifecycle.activate(); LevelStorageSource format=new LevelStorageSource(directory,directory.resolve("backups"),NativeVanillaDataFixer.get()); try(LevelStorageSource.LevelStorageAccess save=format.createAccess("fixture")) { - WorldPersistenceHooks.handleWorldDataLoad(save,null,root); + EarlyWorldPreservation.beforeRead(save); UpgradeJournal early=UpgradePreservation.journal();assertEquals(2230,early.worldVersions().getInt("WorldDataVersion")); write(world,level(2586));assertSame(early,UpgradePreservation.prepareForStart(world)); assertEquals(2230,early.worldVersions().getInt("WorldDataVersion")); - CompoundTag output=new CompoundTag();WorldPersistenceHooks.handleWorldDataSave(save,null,output); + CompoundTag output=new CompoundTag();EarlyWorldPreservation.beforeWrite(save,output); assertEquals(root.get("lootablebodies"),output.get("lootablebodies")); output.getCompound("lootablebodies").putString("unknown","changed copy"); - CompoundTag again=new CompoundTag();WorldPersistenceHooks.handleWorldDataSave(save,null,again); + CompoundTag again=new CompoundTag();EarlyWorldPreservation.beforeWrite(save,again); assertEquals(root.get("lootablebodies"),again.get("lootablebodies")); } } diff --git a/src/test/java/cyano/lootable/upgrade/HookRegistrationOrderTest.java b/src/test/java/cyano/lootable/upgrade/HookRegistrationOrderTest.java index bae0e3e..773f848 100644 --- a/src/test/java/cyano/lootable/upgrade/HookRegistrationOrderTest.java +++ b/src/test/java/cyano/lootable/upgrade/HookRegistrationOrderTest.java @@ -7,38 +7,36 @@ import net.minecraft.nbt.CompoundTag; import net.minecraft.world.level.storage.WorldData; import net.minecraft.world.level.storage.LevelStorageSource; -import net.minecraftforge.fmllegacy.WorldPersistenceHooks; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.Test; import org.objectweb.asm.*; import static org.junit.jupiter.api.Assertions.*; /** Checks native registration after parallel construction has completed. */ class HookRegistrationOrderTest { - private static final class Hook implements WorldPersistenceHooks.WorldPersistenceHook { - private final String id; - Hook(String id){this.id=id;} - public String getModId(){return id;} - public void readData(LevelStorageSource.LevelStorageAccess save,WorldData info,CompoundTag tag){} - public CompoundTag getDataForWriting(LevelStorageSource.LevelStorageAccess save,WorldData info){return new CompoundTag();} - } - @Test void serialRegistrationKeepsEveryNativeHookAndRemainsIdempotent() throws Exception { - Field registry=WorldPersistenceHooks.class.getDeclaredField("worldPersistenceHooks");registry.setAccessible(true); + @TempDir(cleanup=org.junit.jupiter.api.io.CleanupMode.ON_SUCCESS) Path directory; + @Test void serialActivationIsRequiredBeforeReadAndRemainsIdempotent() throws Exception { Field installed=EarlyWorldPreservation.class.getDeclaredField("installed");installed.setAccessible(true); - Object original=registry.get(null);boolean wasInstalled=installed.getBoolean(null); - try { - for(int round=0;round<1000;round++) { - List hooks=new ArrayList<>();registry.set(null,hooks);installed.setBoolean(null,false); - // Forge's constructor has finished before deferred lifecycle work. - WorldPersistenceHooks.addHook(new Hook("forge")); - WorldPersistenceHooks.addHook(new Hook("fml")); - EarlyWorldPreservation.install();EarlyWorldPreservation.install(); - assertEquals(3,hooks.size());assertFalse(hooks.contains(null)); - Set ids=new HashSet<>();for(WorldPersistenceHooks.WorldPersistenceHook hook:hooks)ids.add(hook.getModId()); - assertEquals(new HashSet<>(Arrays.asList("forge","fml","lootablebodies")),ids); - CompoundTag saved=new CompoundTag();WorldPersistenceHooks.handleWorldDataSave(null,null,saved); - assertEquals(ids,saved.getAllKeys()); - } - } finally {registry.set(null,original);installed.setBoolean(null,wasInstalled);} + boolean wasInstalled=installed.getBoolean(null); + Path world=directory.resolve("serial");Files.createDirectories(world); + CompoundTag root=new CompoundTag(),data=new CompoundTag(),owned=new CompoundTag(); + data.putInt("DataVersion",2730);root.put("Data",data);owned.putString("unknown","keep");root.put("lootablebodies",owned); + net.minecraft.nbt.NbtIo.writeCompressed(root,world.resolve("level.dat").toFile()); + byte[] original=Files.readAllBytes(world.resolve("level.dat")); + try(LevelStorageSource.LevelStorageAccess save=LevelStorageSource.createDefault(directory).createAccess("serial")) { + installed.setBoolean(null,false); + Error refused=assertThrows(Error.class,()->EarlyWorldPreservation.beforeRead(save)); + assertTrue(refused.getCause().getMessage().contains("not installed")); + assertArrayEquals(original,Files.readAllBytes(world.resolve("level.dat"))); + assertFalse(Files.exists(world.resolve("data/lootablebodies-upgrade"))); + Native118UnitLifecycle.activate(); + for(int round=0;round<1000;round++){EarlyWorldPreservation.install();EarlyWorldPreservation.install();} + EarlyWorldPreservation.beforeRead(save); + CompoundTag saved=new CompoundTag();EarlyWorldPreservation.beforeWrite(save,saved); + assertEquals(owned,saved.getCompound("lootablebodies")); + } finally {UpgradePreservation.close();installed.setBoolean(null,wasInstalled);} } @Test void theProductionConstructorMustDeferHookRegistrationToSerialLifecycleWork() throws Exception { final boolean[] eager={false},queued={false}; @@ -63,7 +61,7 @@ public void visitInvokeDynamicInsn(String method,String descriptor,Handle bootst } },ClassReader.SKIP_DEBUG|ClassReader.SKIP_FRAMES); } - assertFalse(eager[0],"The parallel mod constructor mutates Forge's unsynchronised ArrayList alongside ForgeMod"); + assertFalse(eager[0],"The early preservation gate must not be activated in parallel mod construction"); assertTrue(queued[0],"Hook installation must be queued to serial lifecycle work before world loading"); } } diff --git a/src/test/java/cyano/lootable/upgrade/ItemNbtConversionTest.java b/src/test/java/cyano/lootable/upgrade/ItemNbtConversionTest.java index e8a0f8a..093c6ac 100644 --- a/src/test/java/cyano/lootable/upgrade/ItemNbtConversionTest.java +++ b/src/test/java/cyano/lootable/upgrade/ItemNbtConversionTest.java @@ -36,7 +36,7 @@ class ItemNbtConversionTest { CompoundTag versions=context(922);versions.putBoolean("Ambiguous",true);versions.putIntArray("CandidateDataVersions",new int[]{922,1343});assertNull(ItemMigration.convertNbt(item("minecraft:bed",0),versions));assertNotNull(ItemMigration.convertNbt(item("minecraft:stone",1),versions)); } @Test void malformedCountsAndUnknownFutureVersionsAreNotGuessed() { - CompoundTag raw=item("minecraft:stone",1);raw.putString("Count","bad");assertNull(ItemMigration.convertNbt(raw,context(512)));assertNull(ItemMigration.convertNbt(item("minecraft:stone",1),context(2731))); + CompoundTag raw=item("minecraft:stone",1);raw.putString("Count","bad");assertNull(ItemMigration.convertNbt(raw,context(512)));assertNull(ItemMigration.convertNbt(item("minecraft:stone",1),context(2976))); } @Test void unrecognisedMetadataAndEnchantmentsRemainRecoverable() { assertNull(ItemMigration.convertNbt(item("minecraft:stone",99),context(1343))); diff --git a/src/test/java/cyano/lootable/upgrade/Native114UpgradeTest.java b/src/test/java/cyano/lootable/upgrade/Native114UpgradeTest.java index 8167ba2..a2dcafa 100644 --- a/src/test/java/cyano/lootable/upgrade/Native114UpgradeTest.java +++ b/src/test/java/cyano/lootable/upgrade/Native114UpgradeTest.java @@ -28,14 +28,14 @@ private CompoundTag body(int marker) { body.put("Equipment", equipment); ListTag aux = new ListTag(); aux.add(item(47)); body.put("Aux", aux); return body; } - @Test void currentFormatNamesThe117VanillaSchema() { - assertEquals(2730, UpgradePreservation.MINECRAFT_VERSION); - assertEquals(7, UpgradePreservation.DATA_VERSION); + @Test void currentFormatNamesThe118VanillaSchema() { + assertEquals(2975, UpgradePreservation.MINECRAFT_VERSION); + assertEquals(8, UpgradePreservation.DATA_VERSION); } @Test void released113EquipmentAndOverflowAreRetainedBeforeNativeConversion() { CompoundTag original = body(3), before = original.copy(); CompoundTag staged = NativeSafeFixer.stageCorpse(original, context(1631)); - assertEquals(7, staged.getInt(UpgradePreservation.VERSION)); + assertEquals(8, staged.getInt(UpgradePreservation.VERSION)); assertEquals(original.getUUID("UUID"), staged.getUUID("UUID")); assertTrue(staged.getList("Equipment", 10).isEmpty()); ListTag records = staged.getList("LootableBodiesUnresolved", 10); assertEquals(49, records.size()); @@ -53,12 +53,12 @@ private CompoundTag body(int marker) { record.put("SourceVersions", context(922)); ListTag recovery = new ListTag(); recovery.add(record.copy()); original.put("LootableBodiesUnresolved", recovery); CompoundTag staged = NativeSafeFixer.stageCorpse(original, context(1631)); - assertEquals(7, staged.getInt(UpgradePreservation.VERSION)); + assertEquals(8, staged.getInt(UpgradePreservation.VERSION)); assertEquals(record, staged.getList("LootableBodiesUnresolved", 10).getCompound(49)); assertEquals(record, original.getList("LootableBodiesUnresolved", 10).getCompound(0)); } @Test void currentRecordsAreNotWrappedOrDuplicatedAgain() { - CompoundTag original = body(7); + CompoundTag original = body(8); assertEquals(original, NativeSafeFixer.stageCorpse(original, context(1976))); } @Test void vanilla114ItemRenamesRunForReleased113InventoryCopies() { diff --git a/src/test/java/cyano/lootable/upgrade/Native115UpgradeTest.java b/src/test/java/cyano/lootable/upgrade/Native115UpgradeTest.java index e9ca4db..e8d2236 100644 --- a/src/test/java/cyano/lootable/upgrade/Native115UpgradeTest.java +++ b/src/test/java/cyano/lootable/upgrade/Native115UpgradeTest.java @@ -32,9 +32,9 @@ private int[] currentBiomes() { for (int i = 0; i < values.length; i++) values[i] = i; return values; } - @Test void currentFormatUsesThe117SchemaAndItsOwnMarker() { - assertEquals(2730, UpgradePreservation.MINECRAFT_VERSION); - assertEquals(7, UpgradePreservation.DATA_VERSION); + @Test void currentFormatUsesThe118SchemaAndItsOwnMarker() { + assertEquals(2975, UpgradePreservation.MINECRAFT_VERSION); + assertEquals(8, UpgradePreservation.DATA_VERSION); } @Test void currentBiomeValuesSurvivePlausibleOlderStampsAndRepeatedConversion() { int[] values = currentBiomes(); CompoundTag original = chunk(values), before = original.copy(); @@ -78,7 +78,7 @@ private int[] currentBiomes() { ListTag items = new ListTag(); items.add(item); raw.put("Equipment", items); context.putInt("DataVersion", 1976); CompoundTag before = raw.copy(); CompoundTag staged = NativeSafeFixer.stageCorpse(raw, context); - assertEquals(7, staged.getInt(UpgradePreservation.VERSION)); + assertEquals(8, staged.getInt(UpgradePreservation.VERSION)); CompoundTag record = staged.getList("LootableBodiesUnresolved", 10).getCompound(0); assertEquals(item, record.get("Data")); assertEquals("Equipment", record.getString("Source")); assertEquals(1976, record.getCompound("SourceVersions").getInt("DataVersion")); diff --git a/src/test/java/cyano/lootable/upgrade/Native116DamagedStampTest.java b/src/test/java/cyano/lootable/upgrade/Native116DamagedStampTest.java index 723cf15..aa6c91d 100644 --- a/src/test/java/cyano/lootable/upgrade/Native116DamagedStampTest.java +++ b/src/test/java/cyano/lootable/upgrade/Native116DamagedStampTest.java @@ -13,13 +13,14 @@ import net.minecraft.world.level.chunk.storage.ChunkStorage; import org.junit.jupiter.api.*; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.io.CleanupMode; import static org.junit.jupiter.api.Assertions.*; /** Captured from the sealed released 1.15 writer; never candidate-generated. */ class Native116DamagedStampTest { static {net.minecraft.SharedConstants.tryDetectVersion();net.minecraft.server.Bootstrap.bootStrap();} - @TempDir Path world; - @AfterEach void close(){UpgradePreservation.close();} + @TempDir(cleanup=CleanupMode.ON_SUCCESS) Path world; + @AfterEach void close(){UpgradePreservation.close();System.out.println("NATIVE_DAMAGED_STAMP_FIXTURE="+world);} private CompoundTag original()throws Exception { Path fixture=Paths.get("src/test/resources/native115-release-chunk.nbt"); byte[] hash=java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(fixture));StringBuilder text=new StringBuilder();for(byte value:hash)text.append(String.format("%02X",value)); @@ -49,11 +50,15 @@ private void preserve(CompoundTag chunk)throws Exception { } @Test void bothNativeAnvilPassesRetainNestedChestDataWithMissingChunkStamps()throws Exception { CompoundTag source=original();assertEquals(2230,source.getInt("DataVersion")); - CompoundTag expected=NativeNbt.update(NativeVanillaDataFixer.get(),References.CHUNK,source.copy(),2230,2586); + CompoundTag nativeInput=source.copy();ChunkStorage.injectDatafixingContext(nativeInput,Level.OVERWORLD,java.util.Optional.empty()); + CompoundTag expected=NativeNbt.update(NativeVanillaDataFixer.get(),References.CHUNK,nativeInput,2230,2975); CompoundTag damaged=source.copy();damaged.remove("DataVersion");preserve(damaged); - try(ChunkStorage loader=new ChunkStorage(world.resolve("reader").toFile(),UpgradePreservation.wrapper(NativeVanillaDataFixer.get()),false)) { - CompoundTag actual=loader.upgradeChunkTag(Level.OVERWORLD,()->{throw new AssertionError("Current source must not replay legacy structure conversion");},damaged.copy()); - assertEquals(expected.getCompound("Level").get("TileEntities"),actual.getCompound("Level").get("TileEntities")); + byte[] originalRegion=Files.readAllBytes(world.resolve("region/r.0.0.mca")); + try(ChunkStorage loader=new ChunkStorage(world.resolve("reader"),UpgradePreservation.wrapper(NativeVanillaDataFixer.get()),false)) { + CompoundTag actual=loader.upgradeChunkTag(Level.OVERWORLD,()->{throw new AssertionError("Current source must not replay legacy structure conversion");},damaged.copy(),java.util.Optional.empty()); + assertNotNull(expected.get("block_entities"));assertFalse(actual.contains("Level")); + assertEquals(expected.get("block_entities"),actual.get("block_entities")); + assertArrayEquals(originalRegion,Files.readAllBytes(world.resolve("region/r.0.0.mca"))); assertEquals(source.getCompound("Level").get("TileEntities"),original().getCompound("Level").get("TileEntities")); } } diff --git a/src/test/java/cyano/lootable/upgrade/Native116UpgradeTest.java b/src/test/java/cyano/lootable/upgrade/Native116UpgradeTest.java index 4ba7020..337135f 100644 --- a/src/test/java/cyano/lootable/upgrade/Native116UpgradeTest.java +++ b/src/test/java/cyano/lootable/upgrade/Native116UpgradeTest.java @@ -34,8 +34,8 @@ private CompoundTag legacy() { } body.put("Equipment",inventory);return body; } - @Test void currentFormatUsesThe117SchemaAndMarker() { - assertEquals(2730,UpgradePreservation.MINECRAFT_VERSION);assertEquals(7,UpgradePreservation.DATA_VERSION); + @Test void currentFormatUsesThe118SchemaAndMarker() { + assertEquals(2975,UpgradePreservation.MINECRAFT_VERSION);assertEquals(8,UpgradePreservation.DATA_VERSION); } @Test void legacyCorpseUuidSurvivesShieldingWithoutTruncatingCustomInventory() { CompoundTag body=legacy(),before=body.copy(),context=new CompoundTag();context.putInt("DataVersion",2230); diff --git a/src/test/java/cyano/lootable/upgrade/Native117EntityChunkTest.java b/src/test/java/cyano/lootable/upgrade/Native117EntityChunkTest.java index f5afb69..564b322 100644 --- a/src/test/java/cyano/lootable/upgrade/Native117EntityChunkTest.java +++ b/src/test/java/cyano/lootable/upgrade/Native117EntityChunkTest.java @@ -20,7 +20,7 @@ class Native117EntityChunkTest { private static final UUID ID=UUID.fromString("00000000-1171-4171-8171-000000000011"); @AfterEach void close(){UpgradePreservation.close();System.out.println("ENTITY_CHUNK_FIXTURE="+world);} private CompoundTag convert(CompoundTag root,int source) { - return NativeNbt.update(UpgradePreservation.wrapper(NativeVanillaDataFixer.get()),References.ENTITY_CHUNK,root,source,2730); + return NativeNbt.update(UpgradePreservation.wrapper(NativeVanillaDataFixer.get()),References.ENTITY_CHUNK,root,source,2975); } @Test void nativeEntityChunkShieldsAllSlotsAuxRecoveryAndPassengers() throws Exception { CompoundTag corpse=body(ID,6),cart=new CompoundTag();cart.putString("id","minecraft:chest_minecart");cart.putUUID("UUID",new UUID(12,13)); @@ -28,7 +28,7 @@ private CompoundTag convert(CompoundTag root,int source) { CompoundTag raw=chunk(2586,cart),before=raw.copy();Path region=write(world,raw);byte[] bytes=Files.readAllBytes(region); UpgradePreservation.prepare(world); CompoundTag fixed=convert(raw,2586).getList("Entities",10).getCompound(0).getList("Passengers",10).getCompound(0); - assertEquals(7,fixed.getInt(UpgradePreservation.VERSION));assertEquals(2586,fixed.getCompound(UpgradePreservation.SOURCES).getInt("DataVersion")); + assertEquals(8,fixed.getInt(UpgradePreservation.VERSION));assertEquals(2586,fixed.getCompound(UpgradePreservation.SOURCES).getInt("DataVersion")); ListTag records=fixed.getList("LootableBodiesUnresolved",10);assertEquals(50,records.size()); for(int slot=0;slot<48;slot++) {assertEquals(corpse.getList("Equipment",10).getCompound(slot),records.getCompound(slot).getCompound("Data"));assertEquals("Equipment",records.getCompound(slot).getString("Source"));} assertEquals(corpse.getList("Aux",10).getCompound(0),records.getCompound(48).getCompound("Data")); @@ -36,8 +36,8 @@ private CompoundTag convert(CompoundTag root,int source) { assertEquals(before,raw);assertArrayEquals(bytes,Files.readAllBytes(region)); } @Test void currentMarkerProtectsNativeRootAgainstDamagedStamps() { - CompoundTag corpse=body(ID,7); - for(int version:new int[]{-1,0,512,2230,2586,2730}) { + CompoundTag corpse=body(ID,8); + for(int version:new int[]{-1,0,512,2230,2586,2730,2975}) { CompoundTag raw=chunk(version,corpse),before=raw.copy(); CompoundTag fixed=convert(raw,version); assertEquals(corpse,fixed.getList("Entities",10).getCompound(0));assertEquals(before,raw); diff --git a/src/test/java/cyano/lootable/upgrade/Native117EntityRegionTest.java b/src/test/java/cyano/lootable/upgrade/Native117EntityRegionTest.java index 054ba35..52476cf 100644 --- a/src/test/java/cyano/lootable/upgrade/Native117EntityRegionTest.java +++ b/src/test/java/cyano/lootable/upgrade/Native117EntityRegionTest.java @@ -33,7 +33,7 @@ private CompoundTag chunk(int x,int z,CompoundTag... entities) { private Path write(String dimension,String storage,int x,int z,CompoundTag root) throws Exception { Path directory=world.resolve(dimension).resolve(storage);Files.createDirectories(directory); Path path=directory.resolve("r."+(x>>5)+"."+(z>>5)+".mca"); - try(RegionFile region=new RegionFile(path.toFile(),directory.toFile(),false)) { + try(RegionFile region=new RegionFile(path,directory,false)) { try(DataOutputStream out=region.getChunkDataOutputStream(new ChunkPos(x,z))) {NbtIo.write(root,out);} } return path; @@ -109,7 +109,7 @@ private CompoundTag original(UpgradeJournal journal,UUID id,int x,int z) throws } @Test void nativeEmptyEntityRegionPlaceholderStaysEmpty() throws Exception { Path directory=world.resolve("entities");Files.createDirectories(directory);Path file=directory.resolve("r.0.0.mca"); - try(RegionFile region=new RegionFile(file.toFile(),directory.toFile(),false)) {assertNull(region.getChunkDataInputStream(new ChunkPos(0,0)));} + try(RegionFile region=new RegionFile(file,directory,false)) {assertNull(region.getChunkDataInputStream(new ChunkPos(0,0)));} byte[] before=Files.readAllBytes(file);assertEquals(0,UpgradeJournal.scan(world).size());assertArrayEquals(before,Files.readAllBytes(file)); } } diff --git a/src/test/java/cyano/lootable/upgrade/Native117ExternalSidecarTest.java b/src/test/java/cyano/lootable/upgrade/Native117ExternalSidecarTest.java index 5d3e3d1..169b849 100644 --- a/src/test/java/cyano/lootable/upgrade/Native117ExternalSidecarTest.java +++ b/src/test/java/cyano/lootable/upgrade/Native117ExternalSidecarTest.java @@ -36,14 +36,14 @@ class Native117ExternalSidecarTest { CompoundTag chunk=new CompoundTag();chunk.putInt("DataVersion",2730);chunk.putIntArray("Position",new int[]{0,0}); ListTag entities=new ListTag();entities.add(body);chunk.put("Entities",entities); Path file=directory.resolve("r.0.0.mca"); - try(RegionFile region=new RegionFile(file.toFile(),directory.toFile(),false)) { + try(RegionFile region=new RegionFile(file,directory,false)) { try(DataOutputStream out=region.getChunkDataOutputStream(new ChunkPos(0,0))){NbtIo.write(chunk,out);} } Path sidecar=directory.resolve("c.0.0.mcc");assertTrue(Files.isRegularFile(sidecar));assertTrue(Files.size(sidecar)>1024*1024); byte[] raw=Files.readAllBytes(file),payload=Files.readAllBytes(sidecar); int location=ByteBuffer.wrap(raw).getInt(),offset=(location>>>8)*4096; assertEquals(0x82,raw[offset+4]&255,"Native writer must actually select external zlib storage"); - try(RegionFile region=new RegionFile(file.toFile(),directory.toFile(),false); + try(RegionFile region=new RegionFile(file,directory,false); DataInputStream in=region.getChunkDataInputStream(new ChunkPos(0,0))) { assertEquals(chunk,NbtIo.read(in),"Native reader must decode the original external entity chunk"); } diff --git a/src/test/java/cyano/lootable/upgrade/Native118BiomeRegistryTest.java b/src/test/java/cyano/lootable/upgrade/Native118BiomeRegistryTest.java new file mode 100644 index 0000000..8b56bce --- /dev/null +++ b/src/test/java/cyano/lootable/upgrade/Native118BiomeRegistryTest.java @@ -0,0 +1,45 @@ +package cyano.lootable.upgrade; + +import com.mojang.serialization.Dynamic; +import net.minecraft.data.BuiltinRegistries; +import net.minecraft.nbt.NbtOps; +import net.minecraft.nbt.StringTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.datafix.fixes.BiomeFix; +import net.minecraft.util.datafix.fixes.CavesAndCliffsRenames; +import net.minecraft.util.datafix.fixes.References; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** Registry aliases must agree with actual vanilla biome conversion. */ +class Native118BiomeRegistryTest { + private String nativeName(String name,int source){ + return net.minecraft.util.datafix.DataFixers.getDataFixer().update(References.BIOME, + new Dynamic<>(NbtOps.INSTANCE,StringTag.valueOf(name)),source,2975).asString().getOrThrow(false,error->{throw new AssertionError(error);}); + } + @Test void everyCavesAndCliffsAliasMatchesTheActualNativeFixAndRegisteredTarget(){ + CavesAndCliffsRenames.RENAMES.forEach((old,current)->{ + assertEquals(current,nativeName(old,2730),old); + ResourceLocation target=new ResourceLocation(current); + assertTrue(BuiltinRegistries.BIOME.containsKey(target),current); + assertEquals(target,LegacyRegistryMappings.biomeReplacement(new ResourceLocation(old)),old); + }); + } + @Test void earlierNativeBiomeRenamesChainIntoTheCurrentNativeBiomeNames(){ + BiomeFix.BIOMES.forEach((old,renamed)->{ + String current=CavesAndCliffsRenames.RENAMES.getOrDefault(renamed,renamed); + if(current.equals("minecraft:deep_warm_ocean"))current="minecraft:warm_ocean"; + ResourceLocation target=new ResourceLocation(current); + assertTrue(BuiltinRegistries.BIOME.containsKey(target),current); + assertEquals(target,LegacyRegistryMappings.biomeReplacement(new ResourceLocation(old)),old); + }); + } + @Test void replacedNetherAndDeepWarmOceanFollowTheirSeparateActualNativeRules(){ + for(String old:new String[]{"minecraft:nether","minecraft:deep_warm_ocean"}) + assertEquals(new ResourceLocation(nativeName(old,2230)),LegacyRegistryMappings.biomeReplacement(new ResourceLocation(old))); + } + @Test void healthyUnknownAndOtherModNamesDoNotReceiveGuessedReplacements(){ + for(String name:new String[]{"minecraft:plains","minecraft:forest","minecraft:unknown_saved_biome","anothermod:wooded_hills"}) + assertNull(LegacyRegistryMappings.biomeReplacement(new ResourceLocation(name)),name); + } +} diff --git a/src/test/java/cyano/lootable/upgrade/Native118CoremodEngineTest.java b/src/test/java/cyano/lootable/upgrade/Native118CoremodEngineTest.java new file mode 100644 index 0000000..e15cf67 --- /dev/null +++ b/src/test/java/cyano/lootable/upgrade/Native118CoremodEngineTest.java @@ -0,0 +1,102 @@ +package cyano.lootable.upgrade; + +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.lang.reflect.InvocationTargetException; +import java.nio.file.Path; +import java.util.List; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.*; +import javax.script.Invocable; +import javax.script.ScriptEngine; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** Use the actual target CoreMods engine and its Java class-access filter. */ +class Native118CoremodEngineTest { + static void blackboardFixture()throws Exception { + Class launcher=Class.forName("cpw.mods.modlauncher.Launcher"); + if(launcher.getField("INSTANCE").get(null)==null) { + // Plain JUnit is not a ModLauncher module layer. The target engine + // only needs its native blackboard for one compatibility flag. + // Seed that fixture without running launcher services or any game. + Class unsafeType=Class.forName("sun.misc.Unsafe");Field singleton=unsafeType.getDeclaredField("theUnsafe");singleton.setAccessible(true); + Object fixture=unsafeType.getMethod("allocateInstance",Class.class).invoke(singleton.get(null),launcher); + Field blackboard=launcher.getDeclaredField("blackboard");blackboard.setAccessible(true); + blackboard.set(fixture,Class.forName("cpw.mods.modlauncher.api.TypesafeMap").getConstructor().newInstance()); + launcher.getField("INSTANCE").set(null,fixture); + } + } + static ScriptEngine actualEngine()throws Exception { + blackboardFixture(); + Class factory=Class.forName("net.minecraftforge.coremod.NashornFactory"); + String source=factory.getProtectionDomain().getCodeSource().getLocation().toString(); + assertTrue(source.endsWith("coremods-5.2.5.jar"),source);System.out.println("COREMOD_ENGINE_SOURCE="+source); + Method create=factory.getDeclaredMethod("createEngine");create.setAccessible(true); + return (ScriptEngine)create.invoke(null); + } + private Object actualTransformer()throws Exception { + blackboardFixture();Class type=Class.forName("net.minecraftforge.coremod.CoreModEngine");Object engine=type.getConstructor().newInstance(); + Method load=java.util.Arrays.stream(type.getDeclaredMethods()).filter(m->m.getName().equals("loadCoreMod")).findFirst().orElseThrow();load.setAccessible(true); + Class api=load.getParameterTypes()[0];java.util.List readers=new java.util.ArrayList<>(); + Object file=Proxy.newProxyInstance(api.getClassLoader(),new Class[]{api},(proxy,method,args)->{ + switch(method.getName()) { + case "getPath": return Path.of("coremods/early-world-preservation.js"); + case "getOwnerId": return "lootablebodies"; + case "readCoreMod": + java.io.Reader reader=new InputStreamReader(getClass().getClassLoader().getResourceAsStream("coremods/early-world-preservation.js"),StandardCharsets.UTF_8);readers.add(reader);return reader; + case "toString": return "Local production coremod fixture"; + default: throw new AssertionError("Unexpected coremod file API: "+method); + } + }); + try { + load.invoke(engine,file);List transforms=(List)type.getMethod("initializeCoreMods").invoke(engine); + assertEquals(1,transforms.size(),"Actual native engine must install the shipped transformer");return transforms.get(0); + } finally {for(var reader:readers)reader.close();} + } + private ClassNode nativeReader()throws Exception { + ClassNode node=new ClassNode(Opcodes.ASM9); + try(var input=getClass().getClassLoader().getResourceAsStream("net/minecraft/world/level/storage/LevelStorageSource$LevelStorageAccess.class")) {assertNotNull(input);new ClassReader(input).accept(node,0);} + return node; + } + private ClassNode transform(Object transformer,ClassNode node)throws Exception { + Method apply=transformer.getClass().getMethod("transform",Object.class,Class.forName("cpw.mods.modlauncher.api.ITransformerVotingContext")); + return (ClassNode)apply.invoke(transformer,node,null); + } + private Class define(ClassNode node) { + ClassWriter writer=new ClassWriter(ClassWriter.COMPUTE_MAXS);node.accept(writer);byte[] bytes=writer.toByteArray(); + class ReaderLoader extends ClassLoader {ReaderLoader(){super(Native118CoremodEngineTest.class.getClassLoader());}Class define(){return defineClass(node.name.replace('/','.'),bytes,0,bytes.length);}} + return new ReaderLoader().define(); + } + private void verify(Class reader)throws Exception { + Method verify=EarlyWorldPreservation.class.getDeclaredMethod("verifyAttachment",Class.class);verify.setAccessible(true);verify.invoke(null,reader); + } + @Test void productionScriptInitializesUnderTheActualForgeClassFilter()throws Exception { + ScriptEngine engine=actualEngine(); + try(var input=getClass().getClassLoader().getResourceAsStream("coremods/early-world-preservation.js")) { + assertNotNull(input);engine.eval(new InputStreamReader(input,StandardCharsets.UTF_8)); + } + assertNotNull(((Invocable)engine).invokeFunction("initializeCoreMod")); + } + @Test void nativeEngineTransformsActualReaderAndItsAttachmentIsVerifiable()throws Exception { + ClassNode transformed=transform(actualTransformer(),nativeReader()); + verify(define(transformed)); + } + @Test void serialSetupRefusesAnUntransformedReaderBeforeAnyWorldCanOpen()throws Exception { + Field installed=EarlyWorldPreservation.class.getDeclaredField("installed");installed.setAccessible(true);boolean before=installed.getBoolean(null); + try {installed.setBoolean(null,false);assertThrows(IllegalStateException.class,EarlyWorldPreservation::install);assertFalse(installed.getBoolean(null));} + finally {installed.setBoolean(null,before);} + } + @Test void nativeWrapperCannotTurnAFailedTransformIntoAnAcceptedAttachment()throws Exception { + ClassNode node=nativeReader();Object transformer=actualTransformer(); + for(MethodNode method:node.methods)for(AbstractInsnNode i=method.instructions.getFirst();i!=null;){AbstractInsnNode next=i.getNext();if(i instanceof MethodInsnNode&&((MethodInsnNode)i).owner.equals("net/minecraftforge/common/ForgeHooks")&&((MethodInsnNode)i).name.equals("writeAdditionalLevelSaveData")){method.instructions.insertBefore(i,new InsnNode(Opcodes.POP2));method.instructions.remove(i);}i=next;} + ClassNode returned=transform(transformer,node); + InvocationTargetException refused=assertThrows(InvocationTargetException.class,()->verify(define(returned))); + assertInstanceOf(IllegalStateException.class,refused.getCause()); + } +} diff --git a/src/test/java/cyano/lootable/upgrade/Native118EarlyHookTest.java b/src/test/java/cyano/lootable/upgrade/Native118EarlyHookTest.java new file mode 100644 index 0000000..64e58af --- /dev/null +++ b/src/test/java/cyano/lootable/upgrade/Native118EarlyHookTest.java @@ -0,0 +1,79 @@ +package cyano.lootable.upgrade; + +import com.google.gson.JsonParser; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import javax.script.Invocable; +import javax.script.ScriptEngine; +import javax.script.ScriptEngineManager; +import org.junit.jupiter.api.Test; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.*; +import org.objectweb.asm.util.CheckClassAdapter; +import static org.junit.jupiter.api.Assertions.*; + +/** Execute the shipped transformer against actual native18 bytecode. */ +class Native118EarlyHookTest { + private static final String SAVE="net/minecraft/world/level/storage/LevelStorageSource$LevelStorageAccess"; + private static final String HOOK="cyano/lootable/upgrade/EarlyWorldPreservation"; + private ScriptEngine engine; + private Object transformer()throws Exception { + String path; + try(InputStream input=getClass().getClassLoader().getResourceAsStream("META-INF/coremods.json")) { + assertNotNull(input,"Early native18 protection must be attached before native header rewrite"); + com.google.gson.JsonObject config=JsonParser.parseReader(new InputStreamReader(input,StandardCharsets.UTF_8)).getAsJsonObject(); + assertTrue(config.has("lootablebodies_preservation"),"Production early-read attachment is missing"); + path=config.get("lootablebodies_preservation").getAsString(); + } + engine=Native118CoremodEngineTest.actualEngine();assertNotNull(engine,"Use the actual target Forge JavaScript engine/filter"); + try(InputStream input=getClass().getClassLoader().getResourceAsStream(path)) { + assertNotNull(input);engine.eval(new InputStreamReader(input,StandardCharsets.UTF_8)); + } + Map map=(Map)((Invocable)engine).invokeFunction("initializeCoreMod"); + Map spec=(Map)map.get("lootablebodies_preservation"); + Map target=(Map)spec.get("target");assertEquals("CLASS",target.get("type"));assertEquals(SAVE.replace('/','.'),target.get("name")); + return spec; + } + private ClassNode original()throws Exception { + ClassNode node=new ClassNode(Opcodes.ASM9); + try(InputStream input=getClass().getClassLoader().getResourceAsStream(SAVE+".class")) { + assertNotNull(input);new ClassReader(input).accept(node,0); + } + return node; + } + private ClassNode apply(Object transformer,ClassNode node)throws Exception { + return (ClassNode)((Invocable)engine).invokeMethod(transformer,"transformer",node); + } + private int calls(ClassNode node,String name) { + int count=0;for(MethodNode method:node.methods)for(AbstractInsnNode instruction:method.instructions) + if(instruction instanceof MethodInsnNode){MethodInsnNode call=(MethodInsnNode)instruction;if(call.owner.equals(HOOK)&&call.name.equals(name))count++;} + return count; + } + @Test void actualNativeClassGetsExactlyOneEarlyReadAndOneHeaderWriteHook()throws Exception { + ClassNode before=original();Object transformer=transformer();ClassNode after=apply(transformer,before); + assertEquals(1,calls(after,"beforeRead"));assertEquals(1,calls(after,"beforeWrite")); + MethodNode read=after.methods.stream().filter(m->m.name.equals("readAdditionalLevelSaveData")&&m.desc.equals("()V")).findFirst().orElseThrow(); + AbstractInsnNode first=read.instructions.getFirst();while(first.getOpcode()<0)first=first.getNext(); + assertEquals(Opcodes.ALOAD,first.getOpcode());assertTrue(first.getNext() instanceof MethodInsnNode); + assertEquals("beforeRead",((MethodInsnNode)first.getNext()).name); + ClassWriter writer=new ClassWriter(ClassWriter.COMPUTE_MAXS);after.accept(writer);assertTrue(writer.toByteArray().length>0); + StringWriter errors=new StringWriter();CheckClassAdapter.verify(new ClassReader(writer.toByteArray()),getClass().getClassLoader(),false,new PrintWriter(errors)); + assertEquals("",errors.toString(),"Actual native bytecode must pass operand-stack verification"); + apply(transformer,after);assertEquals(1,calls(after,"beforeRead"));assertEquals(1,calls(after,"beforeWrite")); + } + @Test void missingNativeWriteSeamIsFatalRatherThanAnUnprotectedLaunch()throws Exception { + Object transformer=transformer();ClassNode node=original(); + for(MethodNode method:node.methods)for(AbstractInsnNode i=method.instructions.getFirst();i!=null;){AbstractInsnNode next=i.getNext();if(i instanceof MethodInsnNode&&((MethodInsnNode)i).owner.equals("net/minecraftforge/common/ForgeHooks")&&((MethodInsnNode)i).name.equals("writeAdditionalLevelSaveData"))method.instructions.remove(i);i=next;} + assertThrows(Exception.class,()->apply(transformer,node)); + } + @Test void aDifferentClassCannotBeSilentlyTransformed()throws Exception { + Object transformer=transformer();ClassNode node=original();node.name="wrong/SaveAccess"; + assertThrows(Exception.class,()->apply(transformer,node)); + } +} diff --git a/src/test/java/cyano/lootable/upgrade/Native118EntityBoundaryTest.java b/src/test/java/cyano/lootable/upgrade/Native118EntityBoundaryTest.java new file mode 100644 index 0000000..b096557 --- /dev/null +++ b/src/test/java/cyano/lootable/upgrade/Native118EntityBoundaryTest.java @@ -0,0 +1,59 @@ +package cyano.lootable.upgrade; + +import java.io.*; +import java.nio.file.*; +import java.util.UUID; +import net.minecraft.nbt.*; +import net.minecraft.util.datafix.fixes.References; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.chunk.storage.RegionFile; +import cyano.lootable.buildtest.NativeVanillaDataFixer; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.*; +import static org.junit.jupiter.api.Assertions.*; +import static cyano.lootable.upgrade.Native117RegionFixtures.*; + +/** Native-codec synthetic boundaries; genuine released-writer worlds are separate. */ +class Native118EntityBoundaryTest { + @TempDir(cleanup=CleanupMode.ON_SUCCESS) Path world; + @AfterEach void close(){UpgradePreservation.close();System.out.println("NATIVE118_ENTITY_FIXTURE="+world);} + @Test void released17WriterWithDamagedOuterStampStillUsesItsPreservedSource() throws Exception { + CompoundTag corpse=body(new UUID(118,171),7),raw=chunk(-1,corpse),before=raw.copy(); + Path file=write(world,raw);byte[] original=Files.readAllBytes(file); + UpgradeJournal journal=UpgradePreservation.prepareEarly(world); + assertEquals(1,journal.size()); + CompoundTag converted=NativeNbt.update(UpgradePreservation.wrapper(NativeVanillaDataFixer.get()),References.ENTITY_CHUNK,raw,-1,2975); + CompoundTag actual=converted.getList("Entities",10).getCompound(0); + assertEquals(8,actual.getInt(UpgradePreservation.VERSION)); + assertEquals(2730,actual.getCompound(UpgradePreservation.SOURCES).getInt("DataVersion")); + assertEquals(50,actual.getList("LootableBodiesUnresolved",10).size()); + for(int slot=0;slot<48;slot++)assertEquals(corpse.getList("Equipment",10).getCompound(slot),actual.getList("LootableBodiesUnresolved",10).getCompound(slot).getCompound("Data")); + assertEquals(before,raw);assertArrayEquals(original,Files.readAllBytes(file)); + } + @Test void futureFlattenedTerrainIsRefusedDuringPreflightEvenWithoutACorpse() throws Exception { + CompoundTag root=new CompoundTag();root.putInt("DataVersion",4000);root.putInt("xPos",0);root.putInt("zPos",0); + root.put("sections",new ListTag());root.put("entities",new ListTag()); + Path folder=world.resolve("region");Files.createDirectories(folder);Path file=folder.resolve("r.0.0.mca"); + try(RegionFile region=new RegionFile(file,folder,false);DataOutputStream output=region.getChunkDataOutputStream(new ChunkPos(0,0))){NbtIo.write(root,output);} + byte[] original=Files.readAllBytes(file); + assertThrows(IOException.class,()->UpgradePreservation.prepareEarly(world)); + assertNull(UpgradePreservation.journal());assertArrayEquals(original,Files.readAllBytes(file)); + } + @Test void futureSeparateEntityVersionRefusesEvenWhenItsCorpseHasAnOlderKnownMarker() throws Exception { + CompoundTag raw=chunk(2976,body(new UUID(118,172),7)),before=raw.copy(); + Path file=write(world,raw);byte[] original=Files.readAllBytes(file); + assertThrows(IOException.class,()->UpgradePreservation.prepareEarly(world)); + assertNull(UpgradePreservation.journal());assertEquals(before,raw);assertArrayEquals(original,Files.readAllBytes(file)); + } + @Test void malformedNonIntSeparateStampUsesVerifiedReleased17SourceWithoutGuessingFutureData() throws Exception { + net.minecraft.nbt.Tag[] stamps={StringTag.valueOf("damaged"),DoubleTag.valueOf(Double.NaN),LongTag.valueOf(Integer.MAX_VALUE),ShortTag.valueOf((short)512)}; + for(int i=0;ifixed(raw,true));assertEquals(before,raw); } @Test void verticalValuesOutsideTheNativeLoadLimitRetainRawDataBeforeClamping() { for(boolean separate:new boolean[]{false,true})for(double y:new double[]{-20000000.01,20000000.01,-3e7,3e7}) { - CompoundTag body=body(ID,7);body.put("Pos",pos(8.5,y,8.5));CompoundTag raw=root(body,separate,0,0),before=raw.copy(); + CompoundTag body=body(ID,8);body.put("Pos",pos(8.5,y,8.5));CompoundTag raw=root(body,separate,0,0),before=raw.copy(); CompoundTag actual=first(fixed(raw,separate),separate); assertEquals(pos(8.5,64,8.5),actual.get("Pos"));assertEquals(before,raw); assertEquals(body.get("Pos"),actual.getList(RECOVERY,10).getCompound(0).get("Data")); @@ -73,7 +73,7 @@ private CompoundTag root(CompoundTag body,boolean separate,int x,int z) { } @Test void nativeHorizontalAndVerticalBoundaryValuesRemainExact() { for(boolean separate:new boolean[]{false,true})for(double y:new double[]{-2e7,2e7}) { - CompoundTag body=body(ID,7);body.put("Pos",pos(3e7,y,3e7)); + CompoundTag body=body(ID,8);body.put("Pos",pos(3e7,y,3e7)); assertEquals(body,first(fixed(root(body,separate,1875000,1875000),separate),separate)); } } diff --git a/src/test/java/cyano/lootable/upgrade/Native117TargetTest.java b/src/test/java/cyano/lootable/upgrade/Native118TargetTest.java similarity index 56% rename from src/test/java/cyano/lootable/upgrade/Native117TargetTest.java rename to src/test/java/cyano/lootable/upgrade/Native118TargetTest.java index d0c73f4..e1c759e 100644 --- a/src/test/java/cyano/lootable/upgrade/Native117TargetTest.java +++ b/src/test/java/cyano/lootable/upgrade/Native118TargetTest.java @@ -6,12 +6,12 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; -class Native117TargetTest { +class Native118TargetTest { @Test void targetMatchesTheActualNativeWorldVersion() { net.minecraft.SharedConstants.tryDetectVersion(); - assertEquals(2730,net.minecraft.SharedConstants.getCurrentVersion().getWorldVersion()); - assertEquals(2730,UpgradePreservation.MINECRAFT_VERSION); - assertEquals(7,UpgradePreservation.DATA_VERSION); + assertEquals(2975,net.minecraft.SharedConstants.getCurrentVersion().getWorldVersion()); + assertEquals(2975,UpgradePreservation.MINECRAFT_VERSION); + assertEquals(8,UpgradePreservation.DATA_VERSION); } @Test void released16MarkerIsMigratedWithAllSlotsAndOriginalsRetained() { assertEquals(2586,cyano.lootable.buildtest.HistoricalVersions.dataVersion("1.16.5")); @@ -25,7 +25,7 @@ class Native117TargetTest { body.put("Equipment",items);CompoundTag before=body.copy(); CompoundTag source=new CompoundTag();source.putInt("DataVersion",2586); CompoundTag staged=NativeSafeFixer.stageCorpse(body,source); - assertEquals(7,staged.getInt(UpgradePreservation.VERSION)); + assertEquals(8,staged.getInt(UpgradePreservation.VERSION)); assertEquals(48,staged.getList("LootableBodiesUnresolved",10).size()); for(int slot=0;slot<48;slot++) { CompoundTag record=staged.getList("LootableBodiesUnresolved",10).getCompound(slot); @@ -35,9 +35,9 @@ class Native117TargetTest { } assertEquals(before,body); } - @Test void productionClassesUseJava16() throws Exception { + @Test void productionClassesUseJava17() throws Exception { try(DataInputStream bytes=new DataInputStream(getClass().getResourceAsStream("/cyano/lootable/entities/EntityLootableBody.class"))) { - assertEquals(0xCAFEBABE,bytes.readInt());bytes.readUnsignedShort();assertEquals(60,bytes.readUnsignedShort()); + assertEquals(0xCAFEBABE,bytes.readInt());bytes.readUnsignedShort();assertEquals(61,bytes.readUnsignedShort()); } } @Test void marker6OverridesADamagedOuterEntityStamp() { @@ -48,8 +48,31 @@ class Native117TargetTest { public com.mojang.serialization.Dynamic update(com.mojang.datafixers.DSL.TypeReference type,com.mojang.serialization.Dynamic value,int from,int to){return value;} }; CompoundTag staged=NativeNbt.update(UpgradePreservation.wrapper(identity), - net.minecraft.util.datafix.fixes.References.ENTITY_TREE,body,512,2730); + net.minecraft.util.datafix.fixes.References.ENTITY_TREE,body,512,2975); assertEquals(2586,staged.getCompound(UpgradePreservation.SOURCES).getInt("DataVersion")); assertEquals(6,body.getInt(UpgradePreservation.VERSION)); } + @Test void released17MarkerRetainsItsSourceAndEveryIndexedOriginal() { + CompoundTag body=Native117RegionFixtures.body(java.util.UUID.randomUUID(),7),before=body.copy(); + CompoundTag source=new CompoundTag();source.putInt("DataVersion",2730); + CompoundTag staged=NativeSafeFixer.stageCorpse(body,source); + assertEquals(8,staged.getInt(UpgradePreservation.VERSION)); + assertEquals(2730,staged.getCompound(UpgradePreservation.SOURCES).getInt("DataVersion")); + ListTag records=staged.getList("LootableBodiesUnresolved",10);assertEquals(50,records.size()); + for(int slot=0;slot<48;slot++) { + assertEquals("Equipment",records.getCompound(slot).getString("Source")); + assertEquals(before.getList("Equipment",10).getCompound(slot),records.getCompound(slot).getCompound("Data")); + } + assertEquals(before,body); + } + @Test void marker7OverridesDamagedOuterStampWhenTheNative18TargetRuns() { + CompoundTag body=Native117RegionFixtures.body(java.util.UUID.randomUUID(),7),before=body.copy(); + com.mojang.datafixers.DataFixer identity=new com.mojang.datafixers.DataFixer() { + public com.mojang.datafixers.schemas.Schema getSchema(int version){throw new AssertionError("No schema needed");} + public com.mojang.serialization.Dynamic update(com.mojang.datafixers.DSL.TypeReference type,com.mojang.serialization.Dynamic value,int from,int to){return value;} + }; + CompoundTag staged=NativeNbt.update(UpgradePreservation.wrapper(identity),net.minecraft.util.datafix.fixes.References.ENTITY_TREE,body,512,2975); + assertEquals(2730,staged.getCompound(UpgradePreservation.SOURCES).getInt("DataVersion")); + assertEquals(before,body); + } } diff --git a/src/test/java/cyano/lootable/upgrade/Native118TerrainBoundaryTest.java b/src/test/java/cyano/lootable/upgrade/Native118TerrainBoundaryTest.java new file mode 100644 index 0000000..6622cbd --- /dev/null +++ b/src/test/java/cyano/lootable/upgrade/Native118TerrainBoundaryTest.java @@ -0,0 +1,109 @@ +package cyano.lootable.upgrade; + +import cyano.lootable.buildtest.NativeVanillaDataFixer; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.file.*; +import java.util.Optional; +import java.util.UUID; +import net.minecraft.nbt.*; +import net.minecraft.util.datafix.fixes.References; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.chunk.storage.ChunkStorage; +import net.minecraft.world.level.chunk.storage.RegionFile; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Synthetic native-codec cases, distinct from genuine historical writer worlds. */ +class Native118TerrainBoundaryTest { + @TempDir(cleanup=CleanupMode.ON_SUCCESS) Path world; + @AfterEach void close(){UpgradePreservation.close();System.out.println("NATIVE118_TERRAIN_FIXTURE="+world);} + private CompoundTag root(CompoundTag... bodies) { + CompoundTag root=new CompoundTag();root.putInt("DataVersion",2975);root.putInt("xPos",0);root.putInt("zPos",0);root.putInt("yPos",-4); + root.putString("Status","full");root.put("sections",new ListTag()); + ListTag list=new ListTag();for(CompoundTag body:bodies)list.add(body.copy());root.put("entities",list);return root; + } + private Path write(CompoundTag root)throws Exception { + Path folder=world.resolve("region");Files.createDirectories(folder);Path file=folder.resolve("r.0.0.mca"); + try(RegionFile region=new RegionFile(file,folder,false);DataOutputStream output=region.getChunkDataOutputStream(new ChunkPos(0,0))){NbtIo.write(root,output);} + return file; + } + private CompoundTag legacy() { + CompoundTag old=new CompoundTag(),level=new CompoundTag();old.putInt("DataVersion",2730);old.put("Level",level); + level.putInt("xPos",0);level.putInt("zPos",0);level.putString("Status","full");level.putIntArray("Biomes",new int[1024]); + CompoundTag section=new CompoundTag(),state=new CompoundTag();section.putByte("Y",(byte)0);state.putString("Name","minecraft:stone"); + ListTag palette=new ListTag();palette.add(state);section.put("Palette",palette);section.putLongArray("BlockStates",new long[256]); + ListTag sections=new ListTag();sections.add(section);level.put("Sections",sections);level.put("Entities",new ListTag());return old; + } + @Test void invalidSectionYTypesAndOverflowAreRefusedWithoutChangingOriginals()throws Exception { + for(net.minecraft.nbt.Tag bad:new net.minecraft.nbt.Tag[]{DoubleTag.valueOf(-4.5),IntTag.valueOf(128),StringTag.valueOf("-4")}) { + CompoundTag chunk=root(),section=new CompoundTag();section.put("Y",bad);ListTag sections=new ListTag();sections.add(section);chunk.put("sections",sections); + Path file=write(chunk);byte[] original=Files.readAllBytes(file); + assertThrows(IOException.class,()->UpgradeJournal.scan(world));assertArrayEquals(original,Files.readAllBytes(file)); + } + } + @Test void missingStampOnModernThreeDimensionalBiomesStillRunsNativeHeightFix()throws Exception { + CompoundTag old=legacy();int[] biomes=new int[1024];for(int i=0;iUpgradeJournal.scan(world));assertArrayEquals(original,Files.readAllBytes(file)); + } + @Test void actualNativeHeightAndRootConversionRetainsTheProtectedCorpse()throws Exception { + CompoundTag old=legacy(),level=old.getCompound("Level"); + CompoundTag corpse=Native117RegionFixtures.body(UUID.randomUUID(),7);ListTag bodies=new ListTag();bodies.add(corpse);level.put("Entities",bodies); + CompoundTag before=old.copy();Path file=write(old);byte[] original=Files.readAllBytes(file);UpgradePreservation.prepare(world); + ChunkStorage.injectDatafixingContext(old,Level.OVERWORLD,Optional.empty()); + CompoundTag vanilla=old.copy();vanilla.getCompound("Level").put("Entities",new ListTag()); + CompoundTag expected=NativeNbt.update(NativeVanillaDataFixer.get(),References.CHUNK,vanilla,2730,2975); + NbtIo.writeCompressed(expected,world.resolve("native-reference.nbt").toFile()); + CompoundTag fixed=NativeNbt.update(UpgradePreservation.wrapper(NativeVanillaDataFixer.get()),References.CHUNK,old,2730,2975); + assertFalse(fixed.contains("Level"));assertEquals(expected.get("sections"),fixed.get("sections"));assertTrue(fixed.getList("sections",10).size()>=16); + CompoundTag staged=fixed.getList("entities",10).getCompound(0);assertEquals(8,staged.getInt(UpgradePreservation.VERSION)); + assertEquals(50,staged.getList("LootableBodiesUnresolved",10).size());assertEquals(2730,staged.getCompound(UpgradePreservation.SOURCES).getInt("DataVersion")); + old.remove("__context");assertEquals(before,old);assertArrayEquals(original,Files.readAllBytes(file)); + } + @Test void nativeHeightConversionNormalizesARecoveredLegacyStampBeforeValidatingTheFlattenedResult()throws Exception { + CompoundTag old=legacy(),body=Native117RegionFixtures.body(UUID.randomUUID(),7); + ListTag bodies=new ListTag();bodies.add(body);old.getCompound("Level").put("Entities",bodies); + old.putInt("DataVersion",Integer.MAX_VALUE); + Path file=write(old);byte[] original=Files.readAllBytes(file);UpgradePreservation.prepare(world); + ChunkStorage.injectDatafixingContext(old,Level.OVERWORLD,Optional.empty());CompoundTag before=old.copy(); + // The native schema conversion leaves DataVersion to ChunkStorage's + // caller. This stale stamp does not describe the newly converted root. + CompoundTag nativeCopy=old.copy();nativeCopy.getCompound("Level").put("Entities",new ListTag()); + CompoundTag nativeResult=NativeNbt.update(NativeVanillaDataFixer.get(),References.CHUNK,nativeCopy,2730,2975); + assertFalse(nativeResult.contains("Level"));assertEquals(Integer.MAX_VALUE,nativeResult.getInt("DataVersion")); + CompoundTag fixed=NativeNbt.update(UpgradePreservation.wrapper(NativeVanillaDataFixer.get()),References.CHUNK,old,2730,2975); + assertFalse(fixed.contains("Level"));assertEquals(2975,fixed.getInt("DataVersion")); + assertEquals(nativeResult.get("sections"),fixed.get("sections")); + assertEquals(body.getUUID("UUID"),fixed.getList("entities",10).getCompound(0).getUUID("UUID")); + assertEquals(before,old);assertArrayEquals(original,Files.readAllBytes(file)); + } + @Test void genuinelyFutureFlattenedTerrainStillRefusesBeforeConversion()throws Exception { + CompoundTag future=root();future.putInt("DataVersion",2976);CompoundTag before=future.copy(); + Path file=write(future);byte[] original=Files.readAllBytes(file); + assertThrows(IOException.class,()->UpgradeJournal.scan(world)); + assertThrows(Error.class,()->NativeNbt.update(UpgradePreservation.wrapper(NativeVanillaDataFixer.get()),References.CHUNK,future,2976,2975)); + assertEquals(before,future);assertArrayEquals(original,Files.readAllBytes(file)); + } +} diff --git a/src/test/java/cyano/lootable/upgrade/Native118WorldgenTest.java b/src/test/java/cyano/lootable/upgrade/Native118WorldgenTest.java new file mode 100644 index 0000000..be61c8b --- /dev/null +++ b/src/test/java/cyano/lootable/upgrade/Native118WorldgenTest.java @@ -0,0 +1,35 @@ +package cyano.lootable.upgrade; + +import java.nio.file.*; +import net.minecraft.nbt.*; +import net.minecraft.util.datafix.fixes.References; +import cyano.lootable.buildtest.NativeVanillaDataFixer; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class Native118WorldgenTest { + private CompoundTag source()throws Exception { + CompoundTag settings=TagParser.parseTag(Files.readString(Paths.get("src/test/resources/native116-worldgen.snbt"))); + settings.getCompound("dimensions").getCompound("minecraft:overworld").getCompound("generator").getCompound("settings").getList("layers",10).getCompound(2).putString("block","minecraft:grass_path"); + return settings; + } + @Test void actualNative18GeneratorConversionRunsDespiteDamagedCompleteSettingsStamps()throws Exception { + CompoundTag raw=source(),before=raw.copy(); + CompoundTag expected=NativeNbt.update(NativeVanillaDataFixer.get(),References.WORLD_GEN_SETTINGS,raw.copy(),2586,2975); + for(int stamp:new int[]{-1,0,512,2230,2586}) { + CompoundTag actual=NativeNbt.update(UpgradePreservation.wrapper(NativeVanillaDataFixer.get()),References.WORLD_GEN_SETTINGS,raw,stamp,2975); + assertEquals(expected,actual,"stamp="+stamp);assertEquals(before,raw); + } + ListTag layers=expected.getCompound("dimensions").getCompound("minecraft:overworld").getCompound("generator").getCompound("settings").getList("layers",10); + // Genuine 1.18 height conversion prepends 64 air blocks, retaining the + // original third layer at index3 and the same world-space height. + assertEquals("minecraft:air",layers.getCompound(0).getString("block"));assertEquals(64,layers.getCompound(0).getInt("height")); + assertEquals("minecraft:dirt_path",layers.getCompound(3).getString("block")); + } + @Test void levelProtectionKeepsUnknownTagsWhileItsGeneratorReachesNative18()throws Exception { + CompoundTag raw=source(),level=new CompoundTag();level.put("WorldGenSettings",raw);level.putLong("CustomKeepTag",1234567890123L); + CompoundTag before=level.copy(),expected=NativeNbt.update(NativeVanillaDataFixer.get(),References.WORLD_GEN_SETTINGS,raw.copy(),2586,2975); + CompoundTag actual=NativeNbt.update(UpgradePreservation.wrapper(NativeVanillaDataFixer.get()),References.LEVEL,level,-1,2975); + assertEquals(expected,actual.get("WorldGenSettings"));assertEquals(1234567890123L,actual.getLong("CustomKeepTag"));assertEquals(before,level); + } +} diff --git a/src/testSupport/java/cyano/lootable/buildtest/FixtureArena.java b/src/testSupport/java/cyano/lootable/buildtest/FixtureArena.java new file mode 100644 index 0000000..6bb35bd --- /dev/null +++ b/src/testSupport/java/cyano/lootable/buildtest/FixtureArena.java @@ -0,0 +1,19 @@ +package cyano.lootable.buildtest; + +import java.util.function.BiConsumer; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; + +/** Disposable packet-fixture terrain; never changes production entity physics. */ +public final class FixtureArena { + private FixtureArena() { } + public static void prepare(BlockPos floor, BiConsumer write) { + // Native Nether springs can refill an open cleared space between + // respawns. Keep the original13x13x5 playable volume, with a complete + // disposable shell; never grant players immunity or alter the mod. + for(int dx=-7;dx<=7;dx++)for(int dz=-7;dz<=7;dz++)for(int dy=0;dy<=6;dy++) + write.accept(floor.offset(dx,dy,dz), + (dy==0||dy==6||Math.abs(dx)==7||Math.abs(dz)==7?Blocks.OBSIDIAN:Blocks.AIR).defaultBlockState()); + } +} diff --git a/src/testSupport/java/cyano/lootable/buildtest/HistoricalVersions.java b/src/testSupport/java/cyano/lootable/buildtest/HistoricalVersions.java index 564eaad..ae4b58a 100644 --- a/src/testSupport/java/cyano/lootable/buildtest/HistoricalVersions.java +++ b/src/testSupport/java/cyano/lootable/buildtest/HistoricalVersions.java @@ -11,6 +11,7 @@ public static int dataVersion(String minecraft) { if ("1.14.4".equals(minecraft)) return 1976; if ("1.15.2".equals(minecraft)) return 2230; if ("1.16.5".equals(minecraft)) return 2586; + if ("1.17.1".equals(minecraft)) return 2730; throw new IllegalArgumentException("Unsupported historical Minecraft " + minecraft); } } diff --git a/src/testSupport/java/cyano/lootable/upgrade/Native117RegionFixtures.java b/src/testSupport/java/cyano/lootable/upgrade/Native117RegionFixtures.java index 7758e3b..4926378 100644 --- a/src/testSupport/java/cyano/lootable/upgrade/Native117RegionFixtures.java +++ b/src/testSupport/java/cyano/lootable/upgrade/Native117RegionFixtures.java @@ -29,7 +29,7 @@ public static CompoundTag chunk(int version,CompoundTag... roots) { } public static Path write(Path world,CompoundTag chunk) throws Exception { Path folder=world.resolve("entities");Files.createDirectories(folder);Path path=folder.resolve("r.0.0.mca"); - try(RegionFile region=new RegionFile(path.toFile(),folder.toFile(),false)) { + try(RegionFile region=new RegionFile(path,folder,false)) { try(DataOutputStream output=region.getChunkDataOutputStream(new ChunkPos(0,0))){NbtIo.write(chunk,output);} } return path; diff --git a/src/testSupport/java/cyano/lootable/upgrade/Native118UnitLifecycle.java b/src/testSupport/java/cyano/lootable/upgrade/Native118UnitLifecycle.java new file mode 100644 index 0000000..22c3349 --- /dev/null +++ b/src/testSupport/java/cyano/lootable/upgrade/Native118UnitLifecycle.java @@ -0,0 +1,13 @@ +package cyano.lootable.upgrade; + +/** Direct header units have no transformed ModLauncher class layer. + * Actual engine/attestation contracts are tested separately before runtime proof. + * This fixture models completed serial activation only; production has no bypass. + */ +final class Native118UnitLifecycle { + private Native118UnitLifecycle() { } + static void activate()throws Exception { + java.lang.reflect.Field installed=EarlyWorldPreservation.class.getDeclaredField("installed"); + installed.setAccessible(true);installed.setBoolean(null,true); + } +} diff --git a/tools/fixtures/forge40-server-default.toml b/tools/fixtures/forge40-server-default.toml new file mode 100644 index 0000000..794b3b6 --- /dev/null +++ b/tools/fixtures/forge40-server-default.toml @@ -0,0 +1,12 @@ +# Native Forge 40.3.12 server spec baseline; packaged output is checked separately. +[server] +zombieBaseSummonChance = 0.1 +zombieBabyChance = 0.05 +fullBoundingBoxLadders = false +fixAdvancementLoading = true +treatEmptyTagsAsAir = false +removeErroringBlockEntities = false +removeErroringEntities = false +permissionHandler = "forge:default_handler" +useItemWithDurationZero = false +skipEmptyShapelessCheck = true diff --git a/tools/fixtures/native118-flat-level.dat b/tools/fixtures/native118-flat-level.dat new file mode 100644 index 0000000000000000000000000000000000000000..40d61ceb3158b32f244ff064f7da9f540b130c2c GIT binary patch literal 1289 zcmV+k1@`(MiwFP!00000|AkiTP8&xQ9%HlEYiFVimyTZ0S`kSq{7ZuT4X1;8u8CC1f5*+ z7;4isO}WGfq(xebnkH$V&=@Y~i4!$CWPJpB2Kt@GoD6v&{>ReQBYU}gKw}!gIXVdy z-2D9a@9z7k>5pEVDj* z`eTFiB9Ne|g>eYfrWy>M;xjBp8m^@A$!^Rg1e%8SCxtcKx&S`u6n;wkq;0BT?dp^^ zS)Fi&J=w1P6M_9Q9pVW(2aRWAtDK!Hg7j*;2{4VKCh?}gqp8iBT^VlHhKy^wXth#X zJ*urDO^q36;^iYdWY<`o1}@{VD^7C;rkx$M;Ey&+MpGqlc009r#)ZHUVb!aG-eAcX zyk*_Y7cvY5wUzv#R${yNYO$TdSOWgli!E-~EnaMAZg_8`K3UKDJ~k@V0knyi;5fj3 z?O0~DP-%=?&&N`6169&n3w}$A2h+xKddqTZR3teoP}-7gsU zJ@NtZ4)zb;?7u)1t_|qCW{htVgefwK9N4nanCN>*RLdiC-(dW2}7 z^H1rqt%0QhumMcc=v0P^tcFw=)I4F_(20N}F}IqY;a($k*!~nSTxqjMrBo?0?;)Ex z0(KUrJbDwce(ShwIpR)`CzvtZuc=K^Td6{&BR+!xPP$_tc4WMqOM6)F zk+j^-`XVaVR5%OBwWWk{2=(00}0D#2;XnTtk`%&!1;wJe5Yp zlGCaWD2iiX9O?T4j4Qf2Z&x}Vrs4t$3-j1=_n?a1N{$i->c&en%nG=mjHiaI z9AL@Y*uQ$c7yQ>l4ei{%{fiuW?fC&b%1-uLb$Py)J1x)uXq;(J+A2t4&FRNTqY`m=ChFBx$D^@e$B^+zy0|` z(P;PB`%qTW{eslzB&1','>','>>'):break + if word in ALLOWED_OPTIONS:continue + if word not in ALLOWED_ROOTS:raise ValueError('Hosted Gradle root is not compile/archive-only: '+word) + roots.append(word) + if set(roots)!=ALLOWED_ROOTS:raise ValueError('Missing explicit hosted archive roots') + return roots + +def validate_workflow(text): + commands='\n'.join(line.strip() for line in text.splitlines() if line.startswith(' ') and not line.strip().startswith('#')) + for forbidden in ('unittest discover','-m unittest','native_runtime.py','native117_ci.py','verify_world_upgrades.py','verify_packaged','prepare_native','xvfb-run','--seed-only'): + if forbidden in commands:raise ValueError('Expensive hosted command: '+forbidden) + # Positive command allowlist also catches new/renamed Python game runners, + # shell wrappers, direct Java launches and SDK downloads not listed above. + lines=text.splitlines();shell=[] + for index,line in enumerate(lines): + match=re.match(r'^(\s*)run:\s*(.*)$',line) + if not match:continue + indent=len(match.group(1));value=match.group(2) + if value not in ('|','>-'): + shell.append(value);continue + for following in lines[index+1:]: + if not following.strip():continue + if len(following)-len(following.lstrip())<=indent:break + shell.append(following.strip()) + for line in shell: + if not line or line.startswith('#'):continue + words=shlex.split(line) + if '|' in words: + split=words.index('|');tail=words[split+1:] + if tail not in (['tee','$RUNNER_TEMP/native118-build.log'],['tee','-a','$RUNNER_TEMP/native118-build.log']):raise ValueError('Unknown hosted pipe') + words=words[:split] + if words and words[-1]=='2>&1':words=words[:-1] + allowed=[['set','-euo','pipefail'],['chmod','+x','gradlew'], + ['python3','-B','tools/hosted_policy.py'], + ['python3','-B','tools/hosted_policy.py','--require-qualified-manifest'], + ['python3','-B','tools/native118_build.py']] + if words and words[0]=='./gradlew':gradle_roots(shlex.join(words)) + elif words not in allowed:raise ValueError('Unknown hosted command: '+line) + launches=[line.strip() for line in text.splitlines() if line.strip().startswith('./gradlew ')] + if len(launches)!=1:raise ValueError('Expected one authoritative MMD artifact build') + gradle_roots(launches[0]) + if MMD not in text or 'needs: policy' not in text:raise ValueError('Forks must not run a Gradle artifact build') + if 'set -euo pipefail' not in text or 'shell: bash' not in text:raise ValueError('Piped failure must stop the hosted job') + if 'build/test-results/' in text:raise ValueError('Hosted jobs must not claim a new unit-test report') + if text.count('if-no-files-found: error')!=2:raise ValueError('Missing artifacts must remain fatal') + if text.index('tools/hosted_policy.py --require-qualified-manifest')>text.index('actions/setup-java@'):raise ValueError('Qualification must be checked before expensive preparation') + +def require_qualified_manifest(record): + if record.get('local_qualification_complete') is not True or record.get('qualification_integrity_status')!='verified': + raise ValueError('Local qualification evidence is incomplete or on an integrity hold') + version=record.get('version','') + if not re.fullmatch(r'\d+\.\d+\.\d+\.118021',version) or record.get('minecraft')!='1.18.2' or record.get('forge')!='40.3.12':raise ValueError('Wrong target qualification') + base='LootableBodies-'+version + names={'CHANGELOG.txt','SHA256SUMS',base+'.jar',base+'-sources.jar',base+'-javadoc.jar',base+'.pom'} + if set(record.get('files',{}))!=names or any(not re.fullmatch('[A-F0-9]{64}',value or '') for value in record['files'].values()):raise ValueError('Incomplete six-file declaration') + # This is an early CI declaration check, not an attestation of local paths. + # Publication still needs independently approved/rehashed local evidence. + +def check_source(root): + validate_workflow((root/'.github/workflows/native118.yml').read_text(encoding='utf-8')) + if 'feature/**' in(root/'.github/workflows/build.yml').read_text():raise ValueError('Copied legacy CI still catches modern feature branches') + if 'workflow_dispatch:' in(root/'.github/workflows/migration-matrix.yml').read_text():raise ValueError('Copied legacy migration workflow can still dispatch on a modern ref') + guard=(root/'settings.gradle').read_text() + for required in ("System.getenv('GITHUB_ACTIONS') == 'true'",'org.gradle.api.tasks.testing.Test','org.gradle.api.tasks.JavaExec','Run expensive verification locally before publishing'): + if required not in guard:raise ValueError('Missing Gradle hosted-execution guard') + runtime=(root/'gradle/runtime.gradle').read_text() + if "tasks.named('check') { dependsOn 'integrationTest' }" in runtime or "tasks.register('localRuntimeVerification')" not in runtime:raise ValueError('Game checks must remain explicit local gates') + policy=json.loads((root/'tools/qualification-policy.json').read_text()) + if policy['hosted']['full_java_suite'] or policy['hosted']['full_python_suite'] or policy['hosted']['gameplay_or_migration'] or policy['phase_consolidation']['enabled']:raise ValueError('Unvalidated expensive-test policy') + +if __name__=='__main__': + parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--require-qualified-manifest',action='store_true');args=parser.parse_args() + check_source(ROOT) + if args.require_qualified_manifest:require_qualified_manifest(json.loads((ROOT/'.github/release118.json').read_text())) + print('HOSTED_COMPILE_ARCHIVE_POLICY_OK') diff --git a/tools/local_case_reuse.py b/tools/local_case_reuse.py new file mode 100644 index 0000000..a9e4ab1 --- /dev/null +++ b/tools/local_case_reuse.py @@ -0,0 +1,82 @@ +"""Local normal-case reuse only; original process homes and receipts stay intact. + +This is not a hosted promotion contract. The sole allowed tool change is the +unexecuted patch_stamps body. Its signature and every other execution input +must match. Reused proof is always labelled and independently rehashed. +""" +import ast,json +from pathlib import Path +from native_runtime import digest,fingerprint +from native_evidence import audit_phase,audit_case_mods,verify_saved_audit + +DEPENDENCIES=('gradle.properties','src/integrationTest/resources/log4j2.xml', + 'tools/native_runtime.py','tools/runtime_diagnostics.py','tools/command_logs.py', + 'tools/prepared_worlds.py','tools/historical_workers.py','tools/release_identity.py', + 'tools/nbt_regions.py','tools/native118_persistence.py','tools/audit_runtime_logs.py', + 'tools/native_evidence.py','tools/native118_server_inputs.json', + 'tools/native118_generated_sdk.json','tools/native118_worker_budget.json') + +def normal_program(text): + tree=ast.parse(text) + matches=[n for n in tree.body if isinstance(n,ast.FunctionDef) and n.name=='patch_stamps'] + if len(matches)!=1:raise ValueError('Missing unambiguous stamp-only function') + matches[0].body=[ast.Pass()] + return ast.dump(tree,include_attributes=False) + +def compare_programs(old,new): + if normal_program(old)!=normal_program(new):raise ValueError('Normal execution program changed') + +def compare_inputs(old,new): + for key in ('base_source','main_sha256','probe_sha256','sdk_files','java_files'): + if old[key]!=new[key]:raise ValueError('Changed normal input: '+key) + left=old['frozen_files'];right=new['frozen_files'] + for name in (*DEPENDENCIES,'prepared-worlds.json'): + if name not in left or left[name]!=right.get(name):raise ValueError('Changed normal dependency: '+name) + +def audit_normal(home,details,identity): + from verify_world_upgrades import PHASES + from audit_runtime_logs import audit + if details['phases']!=PHASES or details['stamp_mutations'] or not details['source_unchanged']: + raise ValueError('Only one complete unchanged normal case is reusable') + mods=audit_case_mods(home,identity);receipts=[];logs=[] + for phase in PHASES: + record=audit_phase(home,phase);record['logs'].update(mods);receipts.append(record) + declared={Path(r['receipt']).resolve() for r in receipts} + if declared!={p.resolve() for p in home.glob('**/*.diagnostics/result.json')}:raise ValueError('Extra or missing normal receipts') + for path in home.rglob('*'): + if path.is_file() and 'crash-reports' in path.parts:raise ValueError('Normal crash report') + if path.is_file() and path.name.endswith(('.log','.log.gz')): + audit(path,native=True,fixture_logins=True);logs.append({'path':str(path),'sha256':digest(path)}) + if not logs:raise ValueError('Missing normal complete logs') + return {'receipts':receipts,'positive_complete_logs':logs} + +def prove(old_root,new_root,origin): + old_root=Path(old_root).resolve();new_root=Path(new_root).resolve() + old=json.loads((old_root/'inputs.json').read_text());new=json.loads((new_root/'inputs.json').read_text()) + for root,inputs in ((old_root,old),(new_root,new)): + if fingerprint(root/'frozen-inputs')!=inputs['frozen_files']:raise ValueError('Changed frozen inputs') + compare_inputs(old,new) + compare_programs((old_root/'frozen-inputs/tools/verify_world_upgrades.py').read_text(), + (new_root/'frozen-inputs/tools/verify_world_upgrades.py').read_text()) + from prepared_worlds import load + from verify_world_upgrades import ORIGINS + sources,_=load(new_root/'frozen-inputs/prepared-worlds.json',ORIGINS,[origin]) + source,seal=sources[origin] + case=origin+'-normal';result=old_root/'origins'/origin/'migration-results/result.json' + previous=json.loads(result.read_text());details=previous['cases'][case] + if details['origin_jar_sha256']!=seal['jar_sha256']:raise ValueError('Changed historical writer') + if (previous['candidate_sha256'],previous['probe_sha256'])!=(new['main_sha256'],new['probe_sha256']):raise ValueError('Changed executed candidate') + home=result.parent/case;before=fingerprint(home) + audit=audit_normal(home,details,previous) + if fingerprint(home)!=before or fingerprint(source)!=seal['world']:raise ValueError('Normal proof/source changed during audit') + return {'scope':'LOCAL normal case only; original successful receipts reused, not rerun', + 'origin':origin,'case':case,'home':str(home),'old_inputs':str(old_root/'inputs.json'), + 'old_inputs_sha256':digest(old_root/'inputs.json'),'new_inputs_sha256':digest(new_root/'inputs.json'), + 'old_result':str(result),'old_result_sha256':digest(result),'details':details, + 'home_files':before,'native_audit':audit,'reused_positive_jvms':10,'fresh_positive_jvms':0} + +def validate(proof,new_root,origin): + path=Path(proof).resolve();saved=json.loads(path.read_text()) + fresh=prove(Path(saved['old_inputs']).parent,new_root,origin) + if saved!=fresh or not verify_saved_audit(Path(saved['home']),saved['native_audit']):raise ValueError('Changed local reuse proof') + return saved diff --git a/tools/migration_ci.py b/tools/migration_ci.py index 5b00a04..28c62c6 100644 --- a/tools/migration_ci.py +++ b/tools/migration_ci.py @@ -143,7 +143,7 @@ def run_shard(project, java, shard, commit, total=12): if digest(project / name) != manifest['files'][name]['sha256']: raise ValueError('Prepared candidate/probe changed') args = SimpleNamespace(java=java, origins=origins, variants=['normal','stamps'], seeds=[seeds], - forge117_home=project/'build/native-server-distribution', prepared_sources=None, output=project/'build/migration-results', workers=2) + forge_home=project/'build/native-server-distribution', prepared_sources=None, output=project/'build/migration-results', workers=2) verify(args, project) abort_root = project / 'build/migration-abort';abort_root.mkdir(exist_ok=False) aborts = {} @@ -156,7 +156,7 @@ def run_shard(project, java, shard, commit, total=12): shutil.copytree(source, selected);changes = patch_stamps(selected, abort_root) with (abort_root / (name + '.log')).open('x') as log: subprocess.run([sys.executable,str(project/'tools/verify_upgrade_abort.py'),'--java',str(java), - '--forge-home',str(args.forge117_home),'--source-world',str(selected), + '--forge-home',str(args.forge_home),'--source-world',str(selected), '--output',str(abort_root/name)],cwd=project,stdout=log,stderr=subprocess.STDOUT,check=True,timeout=600) result = json.loads((abort_root/name/'result.json').read_text()) aborts[name] = {'conditions':result['cases'], 'stamp_mutations':changes} diff --git a/tools/native117_ci.py b/tools/native117_ci.py index 4288ae9..a9d6718 100644 --- a/tools/native117_ci.py +++ b/tools/native117_ci.py @@ -123,6 +123,8 @@ def scenario(group): (root/'complete.json').write_text(json.dumps({'group':group,'candidate':digest(PROJECT/'build/libs'/MAIN_JAR),'complete':True})+'\n') def main(): + if MOD_VERSION!='2.4.8.117011': + raise ValueError('Historical 1.17 CI controller is disabled for this target; use local qualification and the native118 build-only contract') p=argparse.ArgumentParser(description=__doc__);p.add_argument('mode',choices=['build','prepare','scenario']);p.add_argument('--group');a=p.parse_args() if a.mode=='build':build() elif a.mode=='prepare':prepare() diff --git a/tools/native118_build.py b/tools/native118_build.py new file mode 100644 index 0000000..72cd31a --- /dev/null +++ b/tools/native118_build.py @@ -0,0 +1,24 @@ +"""Check local-qualified release identities; no SDK installation or game launches.""" +import argparse,hashlib,json,re +from pathlib import Path +from release_identity import MOD_VERSION,MAIN_JAR + +def digest(path):return hashlib.sha256(path.read_bytes()).hexdigest().upper() + +def check_bundle(manifest,bundle): + record=json.loads(Path(manifest).read_text());bundle=Path(bundle) + if (record.get('version'),record.get('minecraft'),record.get('forge'))!=(MOD_VERSION,'1.18.2','40.3.12'): + raise ValueError('Wrong target release identity') + if record.get('local_qualification_complete') is not True:raise ValueError('Local gameplay qualification is incomplete') + if record.get('qualification_integrity_status')!='verified':raise ValueError('Local qualification evidence is on an integrity hold or unverified') + expected=record.get('files',{}) + names={'CHANGELOG.txt','SHA256SUMS',MAIN_JAR,MAIN_JAR[:-4]+'-sources.jar',MAIN_JAR[:-4]+'-javadoc.jar',MAIN_JAR[:-4]+'.pom'} + if set(expected)!=names or any(not re.fullmatch('[A-F0-9]{64}',v) for v in expected.values()):raise ValueError('Missing accepted six-file identities') + if not bundle.is_dir() or any(p.is_symlink() or not p.is_file() for p in bundle.iterdir()):raise ValueError('Invalid or aliased bundle') + actual={p.name:digest(p) for p in bundle.iterdir()} + if actual!=expected:raise ValueError('Built six-file bundle differs from local qualification') + return actual + +if __name__=='__main__': + parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--manifest',type=Path,default=Path('.github/release118.json'));parser.add_argument('--bundle',type=Path,default=Path('build/release/bundle'));args=parser.parse_args() + check_bundle(args.manifest,args.bundle);print('LOCAL_QUALIFIED_BUNDLE_MATCH') diff --git a/tools/native118_client.py b/tools/native118_client.py new file mode 100644 index 0000000..71554d0 --- /dev/null +++ b/tools/native118_client.py @@ -0,0 +1,166 @@ +"""Pinned native40 client preparation and descriptor-driven Java17 launch.""" +import hashlib +import json +import os +from pathlib import Path +import platform +import re +import zipfile + +BASE_VERSION = '1.18.2' +VERSION = '1.18.2-forge-40.3.12' +BASE_SHA1 = '334b33fcba3c9be4b7514624c965256535bd7eba' +FORGE_SHA256 = '270C29DEEE4B28627444BAE949873C030DD14DADBCFA83A210901C8B7986281F' + + +def descriptors(runtime): + runtime = Path(runtime).resolve() + base = runtime/'versions'/BASE_VERSION/(BASE_VERSION+'.json') + forge = runtime/'versions'/VERSION/(VERSION+'.json') + if not base.is_file() or hashlib.sha1(base.read_bytes()).hexdigest() != BASE_SHA1: + raise RuntimeError('Incorrect official native1.18.2 client descriptor') + if not forge.is_file() or hashlib.sha256(forge.read_bytes()).hexdigest().upper() != FORGE_SHA256: + raise RuntimeError('Incorrect installed native40 client descriptor') + return json.loads(base.read_text()), json.loads(forge.read_text()) + + +def prepare(runtime, output): + """Download only missing official client inputs into a NEW owned root. + + The installer SDK and shared Gradle cache remain read-only. Its generated + Forge modules are reused in place; no cache or installation is copied. + """ + from native_runtime import allowed, _download, digest, validate_client_paths + runtime, output = Path(runtime).resolve(), Path(output).resolve() + base, forge = descriptors(runtime) + output.mkdir(parents=True, exist_ok=False) + system = {'Windows':'windows', 'Darwin':'osx', 'Linux':'linux'}[platform.system()] + architecture = 'x86' if platform.architecture()[0] == '32bit' else 'amd64' + natives = output/'natives'; natives.mkdir() + modules, inputs = {}, {} + + def artifact(record, *, generated=False): + relative = Path(record['path']) + if relative.is_absolute() or '..' in relative.parts: + raise ValueError('Unsafe descriptor artifact path') + original = runtime/'libraries'/relative + target = original if original.is_file() else output/'libraries'/relative + if target == original or generated: + if not target.is_file() or hashlib.sha1(target.read_bytes()).hexdigest() != record['sha1']: + raise RuntimeError('Missing or changed installer input: '+str(original)) + else: + _download(record, target) + inputs[str(target)] = digest(target) + return target + + for metadata in (base, forge): + for library in metadata['libraries']: + if not allowed(library, system, architecture): + continue + downloads = library.get('downloads', {}) + if downloads.get('artifact'): + path = artifact(downloads['artifact'], generated=metadata is forge) + parts = library['name'].split(':') + modules[':'.join(parts[:2]+parts[3:])] = path + classifier = library.get('natives', {}).get(system) + if classifier: + classifier = classifier.replace('${arch}', '32' if architecture == 'x86' else '64') + archive = artifact(downloads['classifiers'][classifier]) + with zipfile.ZipFile(archive) as jar: + for entry in jar.infolist(): + if entry.is_dir() or entry.filename.startswith('META-INF/'): + continue + if Path(entry.filename).suffix.lower() not in ('.dll','.so','.dylib','.jnilib'): + continue + target = natives/Path(entry.filename).name + content = jar.read(entry) + if target.exists() and target.read_bytes() != content: + raise RuntimeError('Conflicting native client input: '+target.name) + target.write_bytes(content) + client = runtime/'versions'/BASE_VERSION/(BASE_VERSION+'.jar') + expected = base['downloads']['client']['sha1'] + if not client.is_file() or hashlib.sha1(client.read_bytes()).hexdigest() != expected: + raise RuntimeError('Missing or changed official client jar') + paths = list(modules.values())+[client] + validate_client_paths(paths) + for path in [client, *natives.iterdir(), runtime/'versions'/BASE_VERSION/(BASE_VERSION+'.json'), runtime/'versions'/VERSION/(VERSION+'.json')]: + inputs[str(path)] = digest(path) + record = {'version':VERSION, 'system':system, 'architecture':architecture, + 'sdk':str(runtime), 'library_directory':str(runtime/'libraries'), + 'classpath':[str(p) for p in paths], 'natives':str(natives), 'files':inputs} + (output/'inputs.json').write_text(json.dumps(record,indent=2)+'\n') + return record + + +def load(runtime, prepared): + from native_runtime import digest, validate_client_paths + base, forge = descriptors(runtime) + record = json.loads((Path(prepared)/'inputs.json').read_text()) + if record['version'] != VERSION or Path(record['sdk']) != Path(runtime).resolve(): + raise RuntimeError('Prepared client belongs to another SDK') + system = {'Windows':'windows', 'Darwin':'osx', 'Linux':'linux'}[platform.system()] + architecture = 'x86' if platform.architecture()[0] == '32bit' else 'amd64' + if (record['system'], record['architecture']) != (system, architecture): + raise RuntimeError('Prepared client belongs to another platform') + for name, expected in record['files'].items(): + if not Path(name).is_file() or digest(Path(name)) != expected: + raise RuntimeError('Changed prepared native client input: '+name) + paths = [Path(p) for p in record['classpath']] + if any(str(path) not in record['files'] for path in paths): + raise RuntimeError('Unsealed client classpath entry') + validate_client_paths(paths) + return base, forge, record + + +def expand(arguments, values, *, system, architecture, os_version): + result = [] + for argument in arguments: + if isinstance(argument, dict): + permitted = not argument.get('rules') + for rule in argument.get('rules', []): + target = rule.get('os', {}) + matches = target.get('name',system) == system and target.get('arch',architecture) == architecture + if 'version' in target: + matches = matches and re.search(target['version'], os_version) is not None + for feature, expected in rule.get('features', {}).items(): + matches = matches and {'is_demo_user':False,'has_custom_resolution':True}.get(feature,False) == expected + if matches: + permitted = rule['action'] == 'allow' + if not permitted: + continue + argument = argument['value'] + for part in argument if isinstance(argument,list) else [argument]: + rendered = re.sub(r'\$\{([^}]+)\}',lambda match:values[match[1]],part) + if '${' in rendered: + raise RuntimeError('Unexpanded launcher argument') + result.append(rendered) + return result + + +def command(java, home, runtime, assets, prepared, *, username, extra=()): + java, home, assets = Path(java).resolve(), Path(home).resolve(), Path(assets).resolve() + release = dict(line.split('=',1) for line in (java.parent.parent/'release').read_text().splitlines() if '=' in line) + if release.get('SEMANTIC_VERSION') != '"17.0.20.1+1"' or release.get('IMPLEMENTOR') != '"Eclipse Adoptium"': + raise RuntimeError('Native client requires exact Temurin17.0.20.1+1') + base, forge, record = load(runtime,prepared) + index = assets/'indexes'/(base['assetIndex']['id']+'.json') + if not index.is_file() or hashlib.sha1(index.read_bytes()).hexdigest() != base['assetIndex']['sha1']: + raise RuntimeError('Incorrect official native1.18 asset index') + values = {'version_name':VERSION,'library_directory':record['library_directory'], + 'classpath_separator':os.pathsep,'classpath':os.pathsep.join(record['classpath']), + 'natives_directory':record['natives'],'launcher_name':'LootableBodiesTests','launcher_version':'1', + 'auth_player_name':username,'game_directory':str(home),'assets_root':str(assets), + 'assets_index_name':base['assetIndex']['id'],'auth_uuid':'00000000-0000-4000-8000-000000000001', + # Native40 explicitly selects OfflineSocialInteractions for0. + 'auth_access_token':'0','user_type':'legacy','version_type':'release', + # Match native Main's explicit empty defaults for offline users. + 'clientid':'','auth_xuid':'', + 'resolution_width':'854','resolution_height':'480'} + options = dict(system=record['system'],architecture=record['architecture'],os_version=platform.version()) + # The inherited profile's game version differs from the actual vanilla jar + # filename. Forge's JVM ignoreList must exclude that precise base jar from + # the transforming module layer, which already contains the native SRG jar. + jvm_values = dict(values,version_name=BASE_VERSION) + jvm = expand(base['arguments']['jvm']+forge['arguments']['jvm'],jvm_values,**options) + game = expand(base['arguments']['game']+forge['arguments']['game'],values,**options) + return [str(java),'-Xmx2G',*jvm,*extra,forge['mainClass'],*game] diff --git a/tools/native118_generated_sdk.json b/tools/native118_generated_sdk.json new file mode 100644 index 0000000..c3b3655 --- /dev/null +++ b/tools/native118_generated_sdk.json @@ -0,0 +1,14 @@ +{ + "libraries/net/minecraft/server/1.18.2-20220404.173914/server-1.18.2-20220404.173914-srg.jar": { + "canonical_archive_sha256": "483126784D3736FC9863135F9409B5497B3A5C89F73F590A82875202BC148D29", + "timestamp_policy": "exact", + "entries": 4954, + "content_and_metadata_sha256": "12CDF8B4E0B7DCAE666D68E5FFD936EEF0EBE09EC9E3FAFE491FA1F8959A3A99" + }, + "libraries/net/minecraftforge/forge/1.18.2-40.3.12/forge-1.18.2-40.3.12-server.jar": { + "canonical_archive_sha256": "92DE04B82205209189D6E501516D0CB1EC72A97E9779A4F11D058940DE04C3C1", + "timestamp_policy": "exact", + "entries": 1289, + "content_and_metadata_sha256": "3AF4C998CB82E259F8E1371DD654874E0050AFE394DFBBB87C80A91A822D9374" + } +} diff --git a/tools/native118_persistence.py b/tools/native118_persistence.py new file mode 100644 index 0000000..6ffcaa9 --- /dev/null +++ b/tools/native118_persistence.py @@ -0,0 +1,40 @@ +"""Read-only native18 saved entity inspection, distinct from old-writer proof.""" +import re,struct +from nbt_regions import Tag,read_region + +def count_persisted(world,wave): + if wave not in (-1,0,1,2):raise ValueError('Invalid loaded wave') + def selected(region):return wave<0 or region.name==['r.0.0.mca','r.8.8.mca','r.16.16.mca'][wave] + names=('lootablebodies.Corpse','lootablebodies:corpse') + # A region can still contain unopened old terrain chunks. Inspect both + # actual layouts without relabelling untouched terrain as converted18. + for region in world.rglob('region/r.*.mca'): + if not selected(region):continue + for chunk in read_region(region).values(): + root=chunk['root'].value + level=root.get('Level') + entries=level.value.get('Entities') if level is not None and level.kind==10 else root.get('entities') + if entries is None:continue + if entries.kind!=9 or entries.value[0] not in (0,10):raise ValueError('Malformed terrain entity list') + if any(e.value.get('id',Tag(8,'')).value in names for e in entries.value[1]): + raise ValueError('Loaded corpse remained in terrain storage') + count=0;seen=set() + for region in world.rglob('entities/r.*.mca'): + if not selected(region):continue + match=re.fullmatch(r'r\.(-?[0-9]+)\.(-?[0-9]+)\.mca',region.name) + if match is None:raise ValueError('Unknown entity region name') + rx,rz=map(int,match.groups()) + for index,chunk in read_region(region).items(): + root=chunk['root'].value;position=root.get('Position');entities=root.get('Entities') + if root.get('DataVersion')!=Tag(3,2975):raise ValueError('Wrong native18 entity-chunk data version') + if position is None or position.kind!=11 or len(position.value)!=8 or struct.unpack('>ii',position.value)!=(rx*32+(index&31),rz*32+(index>>5)): + raise ValueError('Wrong native18 entity-chunk Position/index') + if entities is None or entities.kind!=9 or entities.value[0] not in (0,10):raise ValueError('Wrong native18 root Entities list') + for entity in entities.value[1]: + if entity.value.get('id',Tag(8,'')).value not in names:continue + if entity.value['id']!=Tag(8,'lootablebodies:corpse') or entity.value.get('LootableBodiesDataVersion')!=Tag(3,8):raise ValueError('Wrong native18 corpse identity/marker') + identity=entity.value.get('UUID') + if identity is None or identity.kind!=11 or len(identity.value)!=16:raise ValueError('Wrong native18 corpse UUID type') + if identity.value in seen:raise ValueError('Duplicate persisted corpse UUID') + seen.add(identity.value);count+=1 + return count diff --git a/tools/native118_server_inputs.json b/tools/native118_server_inputs.json new file mode 100644 index 0000000..3738bcf --- /dev/null +++ b/tools/native118_server_inputs.json @@ -0,0 +1,97 @@ +{ + "libraries/com/electronwill/night-config/core/3.6.4/core-3.6.4.jar": "1BC71C67B54BA9C8E5030B3DDAEF7DEE16AE43FEE6850368F76B0CFFEF819700", + "libraries/com/electronwill/night-config/toml/3.6.4/toml-3.6.4.jar": "3CB3D0EF3352E204CDA68215FBE41AFDC7F56425EF4FD8E84006964A656117D4", + "libraries/com/github/jponge/lzma-java/1.3/lzma-java-1.3.jar": "75CCC0E24D37ED5B8916E1BCFFAD1E067C2B1CFA21B635287F968DB5C3C1963A", + "libraries/com/github/oshi/oshi-core/5.8.5/oshi-core-5.8.5.jar": "FE16BD8836EECF3D152585C2151322273B68237D13F223E662E0DB959DD13680", + "libraries/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar": "766AD2A0783F2687962C8AD74CEECC38A28B9F72A2D085EE438B7813E928D0C7", + "libraries/com/google/code/gson/gson/2.10.1/gson-2.10.1.jar": "4241C14A7727C34FEEA6507EC801318A3D4A90F070E4525681079FB94EE4C593", + "libraries/com/google/code/gson/gson/2.8.9/gson-2.8.9.jar": "D3999291855DE495C94C743761B8AB5176CFEABE281A5AB0D8E8D45326FD703E", + "libraries/com/google/errorprone/error_prone_annotations/2.1.3/error_prone_annotations-2.1.3.jar": "03D0329547C13DA9E17C634D1049EA2EAD093925E290567E1A364FD6B1FC7FF8", + "libraries/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar": "A171EE4C734DD2DA837E4B16BE9DF4661AFAB72A41ADAF31EB84DFDAF936CA26", + "libraries/com/google/guava/guava/25.1-jre/guava-25.1-jre.jar": "6DB0C3A244C397429C2E362EA2837C3622D5B68BB95105D37C21C36E5BC70ABF", + "libraries/com/google/guava/guava/31.0.1-jre/guava-31.0.1-jre.jar": "D5BE94D65E87BD219FB3193AD1517BAA55A3B88FC91D21CF735826AB5AF087B9", + "libraries/com/google/j2objc/j2objc-annotations/1.1/j2objc-annotations-1.1.jar": "2994A7EB78F2710BD3D3BFB639B2C94E219CEDAC0D4D084D516E78C16DDDECF6", + "libraries/com/mojang/authlib/3.3.39/authlib-3.3.39.jar": "21132E3BDE5EEAC556437DA9580221931BB00DBF350B453F100526C9AA232FEE", + "libraries/com/mojang/brigadier/1.0.18/brigadier-1.0.18.jar": "EDC4926AA4B49010F6E7AC46EFD623FB38F9517344D26F6251D79A26A9738C0B", + "libraries/com/mojang/datafixerupper/4.1.27/datafixerupper-4.1.27.jar": "FEF342ED9E0BC30DE8BD900559E719EAC3585EE10C2961A4EBF19FC49A83054F", + "libraries/com/mojang/javabridge/1.2.24/javabridge-1.2.24.jar": "B5F8871A1799B36E27A5F2AD8A4B47DB39210031C967B794707B92E9E3F8598E", + "libraries/com/mojang/logging/1.0.0/logging-1.0.0.jar": "B2F5D129244F7A90973623CB683BECFCCD0E23B2AC3ADEC028F259F27B0A5400", + "libraries/com/nothome/javaxdelta/2.0.1/javaxdelta-2.0.1.jar": "A661DFCB749053FC26EADA2A0A8E619A8EAD1CA9EDA29E1698687AA2CDD80152", + "libraries/commons-io/commons-io/2.11.0/commons-io-2.11.0.jar": "961B2F6D87DBACC5D54ABF45AB7A6E2495F89B75598962D8C723CEA9BC210908", + "libraries/commons-io/commons-io/2.4/commons-io-2.4.jar": "CC6A41DC3EAACC9E440A6BD0D2890B20D36B4EE408FE2D67122F328BB6E01581", + "libraries/cpw/mods/bootstraplauncher/1.0.0/bootstraplauncher-1.0.0.jar": "BF09102181B8D9B03B536D3388463CC08C4044E4234D233515DAE84BD39920E4", + "libraries/cpw/mods/modlauncher/9.1.3/modlauncher-9.1.3.jar": "433C038B505EB37A963E810B105B96B4D073FCED389C68CBE332614A6B502C76", + "libraries/cpw/mods/securejarhandler/1.0.8/securejarhandler-1.0.8.jar": "69249634131934D346BA788FD099649B2B9277B002FCB612E86E43E18091F075", + "libraries/de/oceanlabs/mcp/mcp_config/1.18.2-20220404.173914/mcp_config-1.18.2-20220404.173914-mappings-merged.txt": "343C93E401A05B40F07B72586FE858605F6463AB6E2C9AC8D7EF571164704CBF", + "libraries/de/oceanlabs/mcp/mcp_config/1.18.2-20220404.173914/mcp_config-1.18.2-20220404.173914-mappings.txt": "8575F9746311C63C76E2FD71A71EA687991C49D5B51FC27911DA4A47873C0C9A", + "libraries/de/oceanlabs/mcp/mcp_config/1.18.2-20220404.173914/mcp_config-1.18.2-20220404.173914.zip": "F60527297DD4E81E3DA93DC95981E426A81D3B03457CF2F94AF8281E2DB5A8F2", + "libraries/de/siegmar/fastcsv/2.2.2/fastcsv-2.2.2.jar": "BFDA71ABCD7C2B62DB498DEBA24C14AEC0E9059A9C183477840AC1BAE9C98AD2", + "libraries/io/netty/netty-all/4.1.68.Final/netty-all-4.1.68.Final.jar": "21E9D45194EE99B1415CC85463555417E494BAF87C5D285877A7584328122F02", + "libraries/it/unimi/dsi/fastutil/8.5.6/fastutil-8.5.6.jar": "5021369BDC72E2CA24222D036C9B35C9733DC9C082A0891C4DBA5A583663F30B", + "libraries/net/java/dev/jna/jna/5.10.0/jna-5.10.0.jar": "E335C10679F743207D822C5F7948E930319835492575A9DBA6B94F8A3B96FCC8", + "libraries/net/java/dev/jna/jna/5.12.1/jna-5.12.1.jar": "91A814AC4F40D60DEE91D842E1A8AD874C62197984403D0E3C30D39E55CF53B3", + "libraries/net/java/dev/jna/jna-platform/5.10.0/jna-platform-5.10.0.jar": "1F71AFD977051BF0109EF5E3767D4E2AFD777BE894D89788CC0F38AD68F6A16F", + "libraries/net/java/dev/jna/jna-platform/5.12.1/jna-platform-5.12.1.jar": "8CE969116CAC95BD61B07A8D5E07174B352E63301473CAAC72C395E3C08488D2", + "libraries/net/jodah/typetools/0.8.3/typetools-0.8.3.jar": "F853E21F7DE6BD192F0CE09E64A6C8B0E154C9CF9F34E62F08D5E26F73CE3B19", + "libraries/net/minecraft/server/1.18.2/server-1.18.2.jar": "57BE9D1E35AA91CFDFA246ADB63A0EA11A946081E0464D08BC3D36651718A343", + "libraries/net/minecraft/server/1.18.2-20220404.173914/server-1.18.2-20220404.173914-extra.jar": "B4433A712425022263DBA8055107F66FCFB4EC423A5CA829ED36EE42C65ED345", + "libraries/net/minecraft/server/1.18.2-20220404.173914/server-1.18.2-20220404.173914-extra.jar.cache": "82AFA376E45B874ABD6E32BBAD41FF7FF6F5E5D10D207E460F1E721786D8C5A6", + "libraries/net/minecraft/server/1.18.2-20220404.173914/server-1.18.2-20220404.173914-mappings.txt": "2A674D9721824BEB424337DAB39A2EC3553BABF0FA4C75FDE66706C73C17539A", + "libraries/net/minecraft/server/1.18.2-20220404.173914/server-1.18.2-20220404.173914-slim.jar": "315F8692A7B03967515643A3D60113C4925FDFE4D021AB9F6B1B4EBF0F1B319D", + "libraries/net/minecraft/server/1.18.2-20220404.173914/server-1.18.2-20220404.173914-slim.jar.cache": "BD355DD93BDCD2A0EF04D34FA43D2BC0FC309638BC7048A5F80316303713BFD8", + "libraries/net/minecraft/server/1.18.2-20220404.173914/server-1.18.2-20220404.173914-srg.jar": "483126784D3736FC9863135F9409B5497B3A5C89F73F590A82875202BC148D29", + "libraries/net/minecraft/server/1.18.2-20220404.173914/server-1.18.2-20220404.173914-unpacked.jar": "ED066D092F6748B00EFFFA82D79F4EA248AD31972C089CE02D71F611CA2BF635", + "libraries/net/minecraftforge/accesstransformers/8.0.4/accesstransformers-8.0.4.jar": "3225E2A513F658DF686FE8B2AE5B4808525851A2C238CE65E38847CC4FFC6441", + "libraries/net/minecraftforge/binarypatcher/1.0.12/binarypatcher-1.0.12.jar": "84672EF03A51325CD50AA22A141D3302C4F25915F8BD9188C927AE4308DAB4AC", + "libraries/net/minecraftforge/coremods/5.2.5/coremods-5.2.5.jar": "B5D195D6B012E45ABFDF196D339256900513A484ECD3E086A478C514D2CD7DF7", + "libraries/net/minecraftforge/eventbus/5.0.3/eventbus-5.0.3.jar": "81F34DEA30F014AA45EC172370AE712476A6AA7B887428D9A85F9E2BD8EDA2F9", + "libraries/net/minecraftforge/fmlcore/1.18.2-40.3.12/fmlcore-1.18.2-40.3.12.jar": "2E06569E085A15DF7832EDD2358F702003169D1479FBE1623D98ADF7A6111435", + "libraries/net/minecraftforge/fmlloader/1.18.2-40.3.12/fmlloader-1.18.2-40.3.12.jar": "594934A6DE66087D1C2C19294AE7B4B14A10ED65FFA52F9ECB15D186AAEE561F", + "libraries/net/minecraftforge/forge/1.18.2-40.3.12/forge-1.18.2-40.3.12-server.jar": "92DE04B82205209189D6E501516D0CB1EC72A97E9779A4F11D058940DE04C3C1", + "libraries/net/minecraftforge/forge/1.18.2-40.3.12/forge-1.18.2-40.3.12-universal.jar": "D699E54A6A7BBE31EFB40D39C21CC2425DC0648590350BCC85A39943FF729DA0", + "libraries/net/minecraftforge/forge/1.18.2-40.3.12/unix_args.txt": "4735CBBD974A8204F1B199A941E347ED714BFEDE54C17EB94171BD84787A48D4", + "libraries/net/minecraftforge/forge/1.18.2-40.3.12/win_args.txt": "897DE75C036F1E5E2ED79F77271FB1CE8803A8A416107ACB339B43CA220B87CD", + "libraries/net/minecraftforge/ForgeAutoRenamingTool/0.1.25/ForgeAutoRenamingTool-0.1.25-all.jar": "48483F19EA0208A840CD563C08544EEB6E5C557C8B61684C6DF590C6E5175BB6", + "libraries/net/minecraftforge/forgespi/4.0.15-4.x/forgespi-4.0.15-4.x.jar": "AFBEA13ADEB1DEF87A358AD529ACB157506B34C3EC62D02033D584EA6A28BF14", + "libraries/net/minecraftforge/installertools/1.4.1/installertools-1.4.1.jar": "C475B48E225524F3358F569CA196FC1FE459575DBA45A9317618661E30C4ED1C", + "libraries/net/minecraftforge/JarJarFileSystems/0.3.19/JarJarFileSystems-0.3.19.jar": "BEAA1875DD433F2DD152395C3E3C52EF2BB99F20B74860A07D70F7C4626E56D4", + "libraries/net/minecraftforge/JarJarMetadata/0.3.19/JarJarMetadata-0.3.19.jar": "6141C710B5B7B673BD544740A31339F0754D7A794CC46B952B7EF7897B7CE265", + "libraries/net/minecraftforge/JarJarSelector/0.3.19/JarJarSelector-0.3.19.jar": "F7ACFBAFC9A715EE7D7CE2D539815B3EBCCEBC5064E0A7252C4A6A1C71D1E740", + "libraries/net/minecraftforge/jarsplitter/1.1.4/jarsplitter-1.1.4.jar": "7B7B2882921900B74CAD59008761CB18F7DA1A5BCA17CD7648251E0A65070067", + "libraries/net/minecraftforge/javafmllanguage/1.18.2-40.3.12/javafmllanguage-1.18.2-40.3.12.jar": "55B99FA20957E8A7B51983036C902E853DB9E66324A2092D58D59BFF471D6E61", + "libraries/net/minecraftforge/lowcodelanguage/1.18.2-40.3.12/lowcodelanguage-1.18.2-40.3.12.jar": "5A8C0E701E64A398BA785DBF91355E81138DEB6B613B09B2C587A893E2EE924D", + "libraries/net/minecraftforge/mclanguage/1.18.2-40.3.12/mclanguage-1.18.2-40.3.12.jar": "71DB1A38CEB2EF254A326AE5550BEAA9C8931D3CCF645161FFD22909053592C8", + "libraries/net/minecraftforge/srgutils/0.4.3/srgutils-0.4.3.jar": "94027D592A75D9437845C5C5A9B21708697E48EC2143B991BD264B4C7D2C0C51", + "libraries/net/minecraftforge/srgutils/0.4.9/srgutils-0.4.9.jar": "362DAEAD41B74BAA84B51A98E6BE9896C77CE15B37413CA1B367B01052F39ADE", + "libraries/net/minecraftforge/srgutils/0.5.6/srgutils-0.5.6.jar": "1AAF0178677961242C52E101C1BA8E1EE96DAD054295E20EB15B98F4E6FE2E3D", + "libraries/net/minecraftforge/unsafe/0.2.0/unsafe-0.2.0.jar": "946937E182AB3B518B5B88264F367E1F35AC12BC83ABED220490C60C717BE239", + "libraries/net/minecrell/terminalconsoleappender/1.2.0/terminalconsoleappender-1.2.0.jar": "912E432C9355C4B2025D788ED87FD72BB95542E2FC967AD6875FBBEB6E1C7A1C", + "libraries/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar": "DF26CC58F235F477DB07F753BA5A3AB243EBE5789D9F89ECF68DD62EA9A66C28", + "libraries/net/sf/jopt-simple/jopt-simple/6.0-alpha-3/jopt-simple-6.0-alpha-3.jar": "D440CEB839D6ADB5CFA81AA321813D60255E68A3E9CA564C91126599310ABDE5", + "libraries/org/antlr/antlr4-runtime/4.9.1/antlr4-runtime-4.9.1.jar": "A80502C3140AE7ACFBB1F57847C8EB1C101461A969215EC38C3F1EBDFF61AC29", + "libraries/org/apache/commons/commons-lang3/3.12.0/commons-lang3-3.12.0.jar": "D919D904486C037F8D193412DA0C92E22A9FA24230B9D67A57855C5C31C7E94E", + "libraries/org/apache/logging/log4j/log4j-api/2.17.0/log4j-api-2.17.0.jar": "AB9CADC80E234580E3F3C8C18644314FCCD4B3CD3F7085D4E934866CB561B95D", + "libraries/org/apache/logging/log4j/log4j-core/2.17.0/log4j-core-2.17.0.jar": "65C33DC9B24A5E5F6CACAE62680641582894749C7BF16C951032EF92F3E12A60", + "libraries/org/apache/logging/log4j/log4j-slf4j18-impl/2.17.0/log4j-slf4j18-impl-2.17.0.jar": "40BB460CE600AABF832BE01141D07ED439FE288140AAFAE4FDC391D682E9CF43", + "libraries/org/apache/maven/maven-artifact/3.6.3/maven-artifact-3.6.3.jar": "61B20DA4E1E3DB837630D0810A6A7A5AC9FB7EA31B0A55638062AAD6E479C09C", + "libraries/org/checkerframework/checker-qual/2.0.0/checker-qual-2.0.0.jar": "FC8441632F5FA5537492C9F026D1C8B1ADB6A7796F46031B04B4CC0622427995", + "libraries/org/codehaus/mojo/animal-sniffer-annotations/1.14/animal-sniffer-annotations-1.14.jar": "2068320BD6BAD744C3673AB048F67E30BEF8F518996FA380033556600669905D", + "libraries/org/jline/jline-reader/3.12.1/jline-reader-3.12.1.jar": "4D1037C7110027ED64816C510FAD2AFA0A5AA9BBC38FB9F0E772870823FCAD2A", + "libraries/org/jline/jline-terminal/3.12.1/jline-terminal-3.12.1.jar": "EB6115680F732562F0E8E6777F127DECE81E23B25015925F3C4A2A900D48CC9E", + "libraries/org/openjdk/nashorn/nashorn-core/15.4/nashorn-core-15.4.jar": "6F816E84DFD63A81D4EAA7829C08337BBAFF3EC683FF3BF6BBD90D017A00DC6F", + "libraries/org/ow2/asm/asm/9.2/asm-9.2.jar": "B9D4FE4D71938DF38839F0ECA42AAAA64CF8B313D678DA036F0CB3CA199B47F5", + "libraries/org/ow2/asm/asm/9.6/asm-9.6.jar": "3C6FAC2424DB3D4A853B669F4E3D1D9C3C552235E19A319673F887083C2303A1", + "libraries/org/ow2/asm/asm/9.7.1/asm-9.7.1.jar": "8CADD43AC5EB6D09DE05FAECCA38B917A040BB9139C7EDEB4CC81C740B713281", + "libraries/org/ow2/asm/asm-analysis/9.2/asm-analysis-9.2.jar": "878FBE521731C072D14D2D65B983B1BEAE6AD06FDA0007B6A8BAE81F73F433C4", + "libraries/org/ow2/asm/asm-analysis/9.7.1/asm-analysis-9.7.1.jar": "85B29371884BA31BB76EDF22323C2C24E172C3267A67152EBA3D1CCC2E041EF2", + "libraries/org/ow2/asm/asm-commons/9.2/asm-commons-9.2.jar": "BE4CE53138A238BB522CD781CF91F3BA5CE2F6CA93EC62D46A162A127225E0A6", + "libraries/org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar": "7AEFD0D5C0901701C69F7513FEDA765FB6BE33AF2CE7AA17C5781FC87657C511", + "libraries/org/ow2/asm/asm-commons/9.7.1/asm-commons-9.7.1.jar": "9A579B54D292AD9BE171D4313FD4739C635592C2B5AC3A459BBD1049CDDEC6A0", + "libraries/org/ow2/asm/asm-tree/9.2/asm-tree-9.2.jar": "AABF9BD23091A4EBFC109C1F3EE7CF3E4B89F6BA2D3F51C5243F16B3CFFAE011", + "libraries/org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar": "C43ECF17B539C777E15DA7B5B86553B377E2D39A683DE6285567D5283888E7EF", + "libraries/org/ow2/asm/asm-tree/9.7.1/asm-tree-9.7.1.jar": "9929881F59EB6B840E86D54570C77B59CE721D104E6DFD7A40978991C2D3B41F", + "libraries/org/ow2/asm/asm-util/9.7.1/asm-util-9.7.1.jar": "F885BE71B5C90556F5F1AD1C4F9276B29B96057C497D46666FE4DDBEC3CB43C6", + "libraries/org/slf4j/slf4j-api/1.8.0-beta4/slf4j-api-1.8.0-beta4.jar": "602B712329C84B4A83C40464F4FDFD0FE4238C53EF397139A867064739DBF4E0", + "libraries/org/spongepowered/mixin/0.8.5/mixin-0.8.5.jar": "CA15A907E4D1F6B38CEFED51D7DF96A83347DCFB0BC8CED53111519E991D887D", + "libraries/trove/trove/1.0.2/trove-1.0.2.jar": "AD68A86CFD0255A2BC0525CE11EC49640CAEA7E34FF91EB89C3193DCCCD77F05" +} diff --git a/tools/native118_worker_budget.json b/tools/native118_worker_budget.json new file mode 100644 index 0000000..27af9de --- /dev/null +++ b/tools/native118_worker_budget.json @@ -0,0 +1,29 @@ +{ + "minecraft": "1.18.2", + "forge": "40.3.12", + "java": "Temurin17.0.20.1+1", + "platform": "Windows amd64", + "qualified": true, + "reloads": 200, + "jvms": 4, + "per_process_deadline": 240, + "max_concurrent_workers": 2, + "visible_cpus": 24, + "native_preparation_workers": 1, + "jvm_args": [ + "-Dmax.bg.threads=1" + ], + "heap_test_args": [ + "-Xms256m", + "-Xmx1G" + ], + "gc": "G1 actual target default, 18 parallelGCThreads/5concurrentGCThreads observed", + "no_candidate": true, + "no_worlds": true, + "source_and_sdk_seals_unchanged": true, + "scope": "Historical copied-world harness only: native40-specific single preparation worker qualified by4fresh native-only JVMs/200real resource reloads. Ordinary/client launches retain native CPU/worker/G1 defaults. Original startup-timeout cause remains UNKNOWN; scheduling diagnostic reproduced native HashSet counter corruption and pending native reload.", + "qualification_evidence_sha256": "B024B7810EA541D3E6D1DDA4B9A2AC13971044A82A8EB1705D84FF01E87AEDD5", + "controller_sha256": "33D49827D74E1CF2B23F112D2EC56670238E8457596E8F65869BAF3DB4DAB001", + "native_race_diagnostic_sha256": "D78547A28C823B9D237A32EB5899140112F20E23BE7173B9A74FBDFE307496E2", + "original_timeout_cause_confirmed": false +} diff --git a/tools/native_evidence.py b/tools/native_evidence.py index f9e6f58..ff750b5 100644 --- a/tools/native_evidence.py +++ b/tools/native_evidence.py @@ -1,11 +1,12 @@ -"""Independently audit native17 local receipts, complete logs and diagnostic bytes. +"""Independently audit native18 local receipts, complete logs and diagnostic bytes. Default auditing binds the recorded cwd to the actual owned process home. -Relocated auditing is a separately tested download contract; hosted native17 +Relocated auditing is a separately tested download contract; hosted native18 execution and promotion remain disabled and have not been exercised. """ from pathlib import Path,PurePosixPath import hashlib,json +from native_runtime import FORGE,HISTORICAL_JVM_ARGS def digest(path):return hashlib.sha256(path.read_bytes()).hexdigest().upper() @@ -17,9 +18,18 @@ def audit_phase(home,phase,*,refusal=False,relocated=False): if receipt['timeout']!=(180 if refusal else 240):raise ValueError('Changed hosted deadline') if (receipt['returncode']==0)==refusal:raise ValueError('Unexpected hosted JVM exit') command=receipt['command'] - if command.count('-XX:ActiveProcessorCount=2')!=1:raise ValueError('Missing native worker budget') - if any(s.startswith('-XX:ActiveProcessorCount=') and s!='-XX:ActiveProcessorCount=2' for s in command): - raise ValueError('Conflicting native worker budget') + # Default-worker receipts remain their original sealed proof. The one + # independently qualified native40 override is limited to history/refusal + # phases; it cannot excuse another cap, duplicate flag, CPU or GC selection. + if any(s.startswith(('-XX:ActiveProcessorCount=','-XX:ParallelGCThreads=','-XX:ConcGCThreads=')) + or (s.startswith(('-XX:+Use','-XX:-Use')) and s.endswith('GC')) for s in command): + raise ValueError('Unqualified native40 worker or GC override') + worker_args=[s for s in command if s.startswith('-Dmax.bg.threads')] + if worker_args: + from verify_world_upgrades import PHASES + if (tuple(worker_args)!=HISTORICAL_JVM_ARGS or + not ((refusal and phase=='abort') or (not refusal and phase in PHASES))): + raise ValueError('Unqualified or out-of-scope native40 preparation-worker override') if relocated: # Downloaded artifacts retain their original machine cwd. Require the # complete case/condition suffix; all bytes are read only under home. @@ -29,12 +39,15 @@ def audit_phase(home,phase,*,refusal=False,relocated=False): elif Path(receipt['cwd']).resolve()!=home.resolve():raise ValueError('Receipt belongs to another actual process home') prefix='@libraries/net/minecraftforge/forge/' arguments=[part for part in command if part.startswith(prefix)] - expected={prefix+'1.17.1-37.1.1/'+name for name in ('win_args.txt','unix_args.txt')} if relocated else { - prefix+'1.17.1-37.1.1/'+('win_args.txt' if __import__('os').name=='nt' else 'unix_args.txt')} - if len(arguments)!=1 or arguments[0] not in expected or '--nogui' not in command:raise ValueError('Receipt did not launch native37 modules') - if not any(item.startswith('-Xlog:gc*,safepoint:file=') for item in command):raise ValueError('Missing native16 unified GC evidence') + version=FORGE.removeprefix('forge-') + expected={prefix+version+'/'+name for name in ('win_args.txt','unix_args.txt')} if relocated else { + prefix+version+'/'+('win_args.txt' if __import__('os').name=='nt' else 'unix_args.txt')} + if len(arguments)!=1 or arguments[0] not in expected or '--nogui' not in command:raise ValueError('Receipt did not launch native40 modules') + if not any(item.startswith('-Xlog:gc*,safepoint:file=') for item in command):raise ValueError('Missing native17 unified GC evidence') if not refusal and '-Dlootablebodies.phase='+phase not in command:raise ValueError('Receipt belongs to another phase') - if 'gc.txt' not in receipt['evidence']:raise ValueError('Missing GC evidence') + for name in ('gc.txt','initial-threads.txt','initial-heap-gc.txt'): + if name not in receipt['evidence'] or not (directory/name).is_file() or not (directory/name).stat().st_size: + raise ValueError('Missing mandatory native diagnostics: '+name) for name,sha in receipt['evidence'].items(): if Path(name).name!=name or not (directory/name).is_file() or digest(directory/name)!=sha: raise ValueError('Missing or changed hosted native evidence: '+name) diff --git a/tools/native_runtime.py b/tools/native_runtime.py index dc19c4f..37e7973 100644 --- a/tools/native_runtime.py +++ b/tools/native_runtime.py @@ -1,4 +1,4 @@ -"""Disposable official Forge 37 launches, separate from the development bootstrap.""" +"""Disposable official Forge 40 launches, separate from the development bootstrap.""" import hashlib import json import os @@ -10,19 +10,22 @@ import urllib.request import zipfile from command_logs import run_logged -from runtime_diagnostics import run_monitored - -FORGE = 'forge-1.17.1-37.1.1' -INSTALLER_SHA256 = '31F2319B1B9CA491AACCC020887240F8524A4FF2A8E0F8B795E17E889CBC043B' -SERVER_INPUTS = json.loads(Path(__file__).with_name('native117_server_inputs.json').read_text(encoding='utf-8')) -# Qualified by 200 actual packaged native37 resource reloads in four bounded -# JVMs, without this mod or a world. Ordinary gameplay retains native defaults. -WORKER_BUDGET_PROOF = json.loads(Path(__file__).with_name('native117_worker_budget.json').read_text(encoding='utf-8')) +from runtime_diagnostics import run_monitored,validate_logging_inputs + +FORGE = 'forge-1.18.2-40.3.12' +INSTALLER_SHA256 = 'DCFF493F6C2212BDF6AE087F98029CD317A79759AB1009DCE23E63E7DBA0E216' +SERVER_INPUTS = json.loads(Path(__file__).with_name('native118_server_inputs.json').read_text(encoding='utf-8')) +# Native40's copied-world history harness uses its separately qualified single +# preparation worker. Ordinary/client games retain native CPU/worker/GC defaults. +WORKER_BUDGET_PROOF = json.loads(Path(__file__).with_name('native118_worker_budget.json').read_text(encoding='utf-8')) if (WORKER_BUDGET_PROOF['minecraft'], WORKER_BUDGET_PROOF['forge'], WORKER_BUDGET_PROOF['reloads'], WORKER_BUDGET_PROOF['jvms'], - WORKER_BUDGET_PROOF['native_preparation_workers']) != ('1.17.1', '37.1.1', 200, 4, 1): - raise RuntimeError('Missing native37 worker-budget qualification') -HISTORICAL_JVM_ARGS = ('-XX:ActiveProcessorCount=2',) + WORKER_BUDGET_PROOF['qualified']) != ('1.18.2', '40.3.12', 200, 4, True): + raise RuntimeError('Missing native40 resource-policy qualification') +HISTORICAL_JVM_ARGS = tuple(WORKER_BUDGET_PROOF['jvm_args']) +if (HISTORICAL_JVM_ARGS != ('-Dmax.bg.threads=1',) or + WORKER_BUDGET_PROOF['native_preparation_workers'] != 1): + raise RuntimeError('Missing exact native40 historical preparation-worker qualification') def project_assets(project): @@ -46,16 +49,16 @@ def verify_server_inputs(distribution): returned hashes bind the actual SDK bytes prepared for every process. """ from native_sdk_contents import verify_generated_archive - generated=json.loads(Path(__file__).with_name('native117_generated_sdk.json').read_text()) + generated=json.loads(Path(__file__).with_name('native118_generated_sdk.json').read_text()) actual={} for name,expected in SERVER_INPUTS.items(): artifact=Path(distribution)/name - if artifact.is_symlink() or not artifact.is_file():raise FileNotFoundError('Missing native37 SDK input: '+name) + if artifact.is_symlink() or not artifact.is_file():raise FileNotFoundError('Missing native40 SDK input: '+name) value=digest(artifact) if value!=expected: seal=generated.get(name) if not seal or seal['canonical_archive_sha256']!=expected: - raise RuntimeError('Incorrect official Forge37 server input: '+name) + raise RuntimeError('Incorrect official Forge40 server input: '+name) verify_generated_archive(artifact,seal) if digest(artifact)!=value:raise RuntimeError('Native SDK changed during verification: '+name) actual[name]=value @@ -79,7 +82,7 @@ def prepare_server(home, distribution, candidate=None, probe=None): distribution = distribution.resolve() libraries = distribution / 'libraries' if not libraries.is_dir(): - raise FileNotFoundError('Incomplete official Forge 37 server installation') + raise FileNotFoundError('Incomplete official Forge 40 server installation') verify_server_inputs(distribution) home.mkdir(parents=True, exist_ok=False) (home / 'mods').mkdir() @@ -120,11 +123,11 @@ def run_java(java, home, phase, *, timeout=240, jvm_args=()): home = home.resolve() release = java.parent.parent / 'release' if not release.is_file(): - raise RuntimeError('Native runtime requires exact Temurin 16.0.2+7 JDK metadata') + raise RuntimeError('Native runtime requires exact Temurin 17.0.20.1+1 JDK metadata') version = dict(line.split('=', 1) for line in release.read_text(encoding='utf-8').splitlines() if '=' in line) - if (version.get('SEMANTIC_VERSION') != '"16.0.2+7"' or - version.get('IMPLEMENTOR') != '"Eclipse Foundation"'): - raise RuntimeError('Native runtime requires exact Temurin 16.0.2+7') + if (version.get('SEMANTIC_VERSION') != '"17.0.20.1+1"' or + version.get('IMPLEMENTOR') != '"Eclipse Adoptium"'): + raise RuntimeError('Native runtime requires exact Temurin 17.0.20.1+1') log = home / (phase + '.log') diagnostics = home / (phase + '.diagnostics') copies = [home / (phase + '.' + name) for name in ('latest.log', 'debug.log')] @@ -138,13 +141,22 @@ def run_java(java, home, phase, *, timeout=240, jvm_args=()): # Relative output avoids the drive-letter delimiter in unified logging. gc = diagnostics.relative_to(home) / 'gc.txt' arguments = 'win_args.txt' if os.name == 'nt' else 'unix_args.txt' - command = [str(java), '-Xlog:gc*,safepoint:file=' + gc.as_posix() + ':time,uptime,level,tags', - *jvm_args, '@libraries/net/minecraftforge/forge/1.17.1-37.1.1/' + arguments, '--nogui'] + command = [str(java), '-Xms256m', '-Xmx2G', '-Xlog:gc*,safepoint:file=' + gc.as_posix() + ':time,uptime,level,tags', + *jvm_args, '@libraries/net/minecraftforge/forge/1.18.2-40.3.12/' + arguments, '--nogui'] + validate_logging_inputs(command) + def initial_diagnostics(process): + # Short refusals and successful phases must retain real thread/heap + # evidence too. This callback remains inside the unchanged deadline. + for name, tool, option in [('initial-threads.txt',jstack,'-l'),('initial-heap-gc.txt',jstat,'-gc')]: + with (diagnostics/name).open('x',encoding='utf-8') as output: + subprocess.run([str(tool),option,str(process.pid)],stdout=output, + stderr=subprocess.STDOUT,timeout=8,check=True) try: completed = run_monitored(command, cwd=home, log=log, timeout=timeout, diagnostics=diagnostics, diagnostic_command=[str(jstack), '-l'], - metric_command=[str(jstat), '-gc']) + metric_command=[str(jstat), '-gc'], + process_started=initial_diagnostics) finally: # Failed and timed-out phases need their complete native logs too. for name, destination in zip(('latest.log', 'debug.log'), copies): @@ -172,14 +184,14 @@ def seed_flat_fixture(home, header, expected_sha256): version = data.value['DataVersion'] generator = data.value['WorldGenSettings'].value['dimensions'].value[ 'minecraft:overworld'].value['generator'].value['type'] - if version.kind != 3 or version.value != 2730 or generator.kind != 8 or generator.value != 'minecraft:flat': + if version.kind != 3 or version.value != 2975 or generator.kind != 8 or generator.value != 'minecraft:flat': raise ValueError('Wrong native version or generator') except (AttributeError, KeyError, ValueError) as error: - raise RuntimeError('Expected an explicit native2730 flat codec header') from error + raise RuntimeError('Expected an explicit native2975 flat codec header') from error world.mkdir() shutil.copy2(header, world/'level.dat') (home/'native-flat-header-input.json').write_text(json.dumps({ - 'sha256':expected_sha256, 'native_data_version':2730, 'generator':'minecraft:flat', + 'sha256':expected_sha256, 'native_data_version':2975, 'generator':'minecraft:flat', 'only_header_copied':True}, indent=2)+'\n', encoding='utf-8') @@ -199,9 +211,10 @@ def run_server(java, home, phase, *, timeout=240, extra=(), marker=None, message from audit_runtime_logs import audit audit(log,native=True) for name in ('latest.log', 'debug.log'): - runtime_log = home / 'logs' / name - if runtime_log.is_file(): - audit(runtime_log,native=True) + runtime_log = home / (phase+'.'+name) + if not runtime_log.is_file() or not runtime_log.stat().st_size: + raise FileNotFoundError('Missing complete native log: '+str(runtime_log)) + audit(runtime_log,native=True) return log @@ -214,7 +227,7 @@ def _download(artifact, destination): destination.parent.mkdir(parents=True, exist_ok=True) for address in (artifact['url'], artifact['url'] + '?source=lootablebodies-runtime'): try: - request = urllib.request.Request(address, headers={'User-Agent': 'LootableBodiesRuntimeTests/2.4.7.116051'}) + request = urllib.request.Request(address, headers={'User-Agent': 'LootableBodiesRuntimeTests/2.4.9.118021'}) with urllib.request.urlopen(request, timeout=60) as response: content = response.read() if hashlib.sha1(content).hexdigest() != expected: @@ -251,13 +264,13 @@ def validate_client_paths(paths): def client_inputs(runtime, prepared): - from native117_client import load + from native118_client import load base, forge, record = load(runtime, prepared) return base, forge, [Path(p) for p in record['classpath']], Path(record['natives']) def prepare_assets(runtime, cache=None): - raise RuntimeError('Use the pinned native1.17 prepareMinecraftAssets task; inherited cache-copy hydration is disabled') + raise RuntimeError('Use pinned native1.18 assets; inherited cache-copy hydration is disabled') def prepare_client(home, candidate=None, probe=None): @@ -274,45 +287,29 @@ def prepare_client(home, candidate=None, probe=None): def client_command(java, home, runtime, assets, *, username='LootableTest', extra=(), prepared=None): - from native117_client import command + from native118_client import command if prepared is None: - prepared=Path(__file__).resolve().parents[1]/'.gradle/native117-client-inputs' + prepared=Path(__file__).resolve().parents[1]/'.gradle/native118-client-inputs' return command(java,home,runtime,assets,prepared,username=username,extra=extra) -def prepare_vanilla_server(home): - """Stage the official pinned server where the native installer reads it.""" - from native117_client import BASE_VERSION,BASE_SHA1 - descriptor=home/'versions'/BASE_VERSION/(BASE_VERSION+'.json') - _download({'sha1':BASE_SHA1,'url':'https://piston-meta.mojang.com/v1/packages/'+BASE_SHA1+'/1.17.1.json'},descriptor) - metadata=json.loads(descriptor.read_text()) - if metadata['id']!=BASE_VERSION:raise RuntimeError('Wrong official server descriptor') - relative='libraries/net/minecraft/server/'+BASE_VERSION+'/server-'+BASE_VERSION+'.jar' - target=home/relative - _download(metadata['downloads']['server'],target) - if digest(target)!=SERVER_INPUTS[relative]:raise RuntimeError('Official vanilla server checksum mismatch') - return target - - def install(java, home, client=False): - """Install the pinned native37 distribution into a new owned directory.""" + """Install native40 locally; its installer owns the server downloads.""" from prepare_migration_inputs import fetch home=home.resolve();home.mkdir(parents=True,exist_ok=False) installer=home/(FORGE+'-installer.jar') - fetch('https://maven.minecraftforge.net/net/minecraftforge/forge/1.17.1-37.1.1/'+installer.name,installer,INSTALLER_SHA256) + fetch('https://maven.minecraftforge.net/net/minecraftforge/forge/1.18.2-40.3.12/'+installer.name,installer,INSTALLER_SHA256) if client: - from native117_client import BASE_VERSION,BASE_SHA1 + from native118_client import BASE_VERSION,BASE_SHA1 base=home/'versions'/BASE_VERSION/(BASE_VERSION+'.json') - _download({'sha1':BASE_SHA1,'url':'https://piston-meta.mojang.com/v1/packages/'+BASE_SHA1+'/1.17.1.json'},base) + _download({'sha1':BASE_SHA1,'url':'https://piston-meta.mojang.com/v1/packages/'+BASE_SHA1+'/1.18.2.json'},base) metadata=json.loads(base.read_text());_download(metadata['downloads']['client'],base.with_suffix('.jar')) (home/'launcher_profiles.json').write_text('{"profiles":{}}\n') - else: - prepare_vanilla_server(home) run_logged([str(java),'-Djava.awt.headless=true','-jar',installer.name, '--installClient' if client else '--installServer',str(home)], cwd=home,path=home/'install.log',timeout=600) if client: - from native117_client import prepare + from native118_client import prepare inputs=prepare(home,home/'prepared-inputs')['files'] else: inputs=verify_server_inputs(home) diff --git a/tools/owned_process.py b/tools/owned_process.py index 83a0267..498f212 100644 --- a/tools/owned_process.py +++ b/tools/owned_process.py @@ -5,16 +5,17 @@ import subprocess import threading import time -from runtime_diagnostics import run_monitored +from runtime_diagnostics import run_monitored,validate_logging_inputs class OwnedProcess: - def __init__(self,command,*,cwd,log,timeout,diagnostic_command,metric_command=None,console_input=False): + def __init__(self,command,*,cwd,log,timeout,diagnostic_command,metric_command=None,console_input=False,initial_diagnostics=None): self.cwd=Path(cwd).resolve();self.log=Path(log).resolve() if self.log.parent!=self.cwd.parent or self.log.exists(): raise ValueError('Group console alias must be a new file in the owned parent') self._stop=threading.Event();self._pool=ThreadPoolExecutor(max_workers=1) self._process=None;self._started=threading.Event() + self._initial_diagnostics=initial_diagnostics self._console=self.cwd/self.log.name self._future=self._pool.submit(run_monitored,command,cwd=self.cwd,log=self._console, timeout=timeout,diagnostics=self.cwd/(self.log.stem+'.diagnostics'), @@ -38,7 +39,9 @@ def poll(self): if not self._future.done():return None try:return self._future.result().returncode except BaseException:return 1 - def _publish(self,process):self._process=process;self._started.set() + def _publish(self,process): + self._process=process;self._started.set() + if self._initial_diagnostics is not None:self._initial_diagnostics(process) @property def stdin(self): while not self._started.wait(.01): @@ -55,17 +58,40 @@ def wait(self,timeout=None): return result.returncode except TimeoutError as error:raise subprocess.TimeoutExpired(str(self.log),timeout) from error finally: - if self._future.done():self._pool.shutdown(wait=True) + if self._future.done(): + if self._process is not None and self._process.stdin is not None:self._process.stdin.close() + self._pool.shutdown(wait=True) + + def reap(self,timeout=None): + """Join for cleanup, without treating an earlier failed monitor as live. + + Qualification still uses poll/wait and the immutable failed receipt. + A recorded deadline must not prevent other owned children being reaped. + """ + try:return self.wait(timeout=timeout) + except Exception: + if self._future.done() and self._process is not None: + code=self._process.poll() + if code is not None:return code + raise def native_process(command,*,cwd,log,timeout,console_input=False): + validate_logging_inputs(command) java=Path(command[0]).resolve();metadata=java.parent.parent/'release' values=dict(line.split('=',1) for line in metadata.read_text().splitlines() if '=' in line) - if values.get('SEMANTIC_VERSION')!='"16.0.2+7"' or values.get('IMPLEMENTOR')!='"Eclipse Foundation"': - raise RuntimeError('Group runtime requires exact Temurin16.0.2+7') + if values.get('SEMANTIC_VERSION')!='"17.0.20.1+1"' or values.get('IMPLEMENTOR')!='"Eclipse Adoptium"': + raise RuntimeError('Group runtime requires exact Temurin17.0.20.1+1') suffix='.exe' if java.suffix=='.exe' else '' stack=java.with_name('jstack'+suffix);metric=java.with_name('jstat'+suffix) if not stack.is_file() or not metric.is_file():raise FileNotFoundError('Missing pinned native JDK diagnostics') label=Path(log).stem + diagnostics=Path(cwd)/(label+'.diagnostics') + def initial(process): + # Mandatory even for short successful starts/refusals; original process + # deadline includes these bounded captures. + for name,tool,option in [('initial-threads.txt',stack,'-l'),('initial-heap-gc.txt',metric,'-gc')]: + with (diagnostics/name).open('x',encoding='utf-8') as output: + subprocess.run([str(tool),option,str(process.pid)],stdout=output,stderr=subprocess.STDOUT,timeout=8,check=True) launch=[command[0],'-Xlog:gc*,safepoint:file='+label+'.diagnostics/gc.txt:time,uptime,level,tags',*command[1:]] - return OwnedProcess(launch,cwd=cwd,log=log,timeout=timeout,diagnostic_command=[str(stack),'-l'],metric_command=[str(metric),'-gc'],console_input=console_input) + return OwnedProcess(launch,cwd=cwd,log=log,timeout=timeout,diagnostic_command=[str(stack),'-l'],metric_command=[str(metric),'-gc'],console_input=console_input,initial_diagnostics=initial) diff --git a/tools/prepare_native117_tools.py b/tools/prepare_native117_tools.py new file mode 100644 index 0000000..ec470c2 --- /dev/null +++ b/tools/prepare_native117_tools.py @@ -0,0 +1,41 @@ +"""Build a private native37 writer from exact released17 source, never target18.""" +import argparse,json,os,shutil +from pathlib import Path +from prepare_legacy_tools import prepare,digest +from command_logs import run_build_input_logged + +SOURCE='4a4a94cdf9b7f17add67cf22341c7f0ad6fcc808' +PUBLIC_MAIN='854A46D80B8599966E43B3EAED407CD1A99FE4FB019AEE9138FE2F98E8DDA9FA' +OUTPUT='native117-seed/lootablebodies-native117-world-fixtures-reobf.jar' + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('--output',type=Path,required=True) + p.add_argument('--java17-home',type=Path,required=True) + p.add_argument('--java16-home',type=Path,required=True) + p.add_argument('--java25-home',type=Path,required=True) + p.add_argument('--public-main',type=Path,required=True) + a=p.parse_args();project=Path(__file__).resolve().parents[1];output=a.output.resolve() + if digest(a.public_main)!=PUBLIC_MAIN:raise ValueError('Writer requires genuine public17 main854A bytes') + source=prepare(project,output,SOURCE) + # No sibling source/output and no SDK/cache copies. The common cache has + # exactly one writer while this isolated archived source compiles the probe. + env=dict(os.environ,JAVA_HOME=str(a.java17_home.resolve()),LOOTABLEBODIES_JAVA16_HOME=str(a.java16_home.resolve()),LOOTABLEBODIES_MAVENIZER_JAVA_HOME=str(a.java25_home.resolve())) + wrapper=source/('gradlew.bat' if os.name=='nt' else 'gradlew') + if os.name!='nt':wrapper.chmod(0o755) + writer_files={f.relative_to(project).as_posix():digest(f) for f in (project/'src/nativeSeed117').rglob('*') if f.is_file()} + (output/'writer-inputs.json').write_text(json.dumps({'source':SOURCE,'public_main_sha256':PUBLIC_MAIN,'files':writer_files,'init_sha256':digest(project/'gradle/native117-writer.init.gradle')},indent=2)+'\n') + run_build_input_logged([str(wrapper),'prepareNative117WorldFixtures','--init-script='+str(project/'gradle/native117-writer.init.gradle'),'-PhistoricalWriterSource='+str(project),'--no-daemon','--console=plain'],cwd=source,env=env,path=output/'build.log',timeout=1800) + artifact=source/'build'/OUTPUT + if not artifact.is_file() or not artifact.stat().st_size:raise FileNotFoundError(artifact) + if any(digest(project/name)!=sha for name,sha in writer_files.items()):raise ValueError('Historical writer source changed while compiling') + target=project/'build'/OUTPUT;target.parent.mkdir(parents=True,exist_ok=True) + if target.exists():raise FileExistsError(target) + shutil.copy2(artifact,target) + main=output/'public-main'/a.public_main.name;main.parent.mkdir();shutil.copy2(a.public_main,main) + if digest(main)!=PUBLIC_MAIN:raise ValueError('Copied public17 main changed') + record={'source':SOURCE,'public_main':str(main),'public_main_sha256':PUBLIC_MAIN,'artifact':OUTPUT,'sha256':digest(artifact),'writer_sources':writer_files,'production_java':'16.0.2+7','compiled_against':'released17 native37, not18','runtime_qualified':False} + (output/'tools.json').write_text(json.dumps(record,indent=2)+'\n') + print('Public17 historical writer compiled; runtime preparation remains separate',flush=True) + +if __name__=='__main__':main() diff --git a/tools/prepared_worlds.py b/tools/prepared_worlds.py index faa4478..ecb1adb 100644 --- a/tools/prepared_worlds.py +++ b/tools/prepared_worlds.py @@ -13,8 +13,8 @@ def unique(pairs): result[name]=value return result record=json.loads(path.read_text(encoding='utf-8'),object_pairs_hook=unique) - if record.get('schema')!=1 or record.get('target_data_version')!=2730: - raise ValueError('Wrong native17 prepared manifest schema/target') + if record.get('schema')!=1 or record.get('target_data_version')!=2975: + raise ValueError('Wrong native18 prepared manifest schema/target') origins=record.get('origins',{}) if set(origins)!=set(expected):raise ValueError('Prepared manifest must contain every exact origin') paths=[Path(details['world']).resolve() for details in origins.values()] diff --git a/tools/qualification-policy.json b/tools/qualification-policy.json new file mode 100644 index 0000000..04d4e98 --- /dev/null +++ b/tools/qualification-policy.json @@ -0,0 +1,69 @@ +{ + "schema": 1, + "target": "1.18.2", + "status": "Full functional qualification local; hosted compilation, archive/security audits and small workflow contracts only. Unvalidated phase consolidation remains disabled.", + "hosted": { + "allowed": [ + "workflow/wrapper contracts", + "production compilation", + "archive/metadata/byte audits", + "CodeQL security analysis" + ], + "native118_gradle_roots": [ + "assemble", + "verifyProductionRuntimeClasspath", + "prepareReleaseBundle" + ], + "fork_gradle_build": false, + "full_java_suite": false, + "full_python_suite": false, + "gameplay_or_migration": false, + "runtime_sdk_or_asset_install": false + }, + "local": { + "development": "Focused changed-behaviour regression first; batch cheap Java/Python checks. Full runtime matrix is not an edit/commit hook.", + "freeze": "One full Java suite and one full Python suite per exact relevant input closure; all functional gates once on sealed candidate inputs.", + "clean_builds": 2, + "second_clean_build": "Compile production and assemble/audit all six outputs; compare bytes to first build. Reuse unchanged first-build unit proof honestly, without a second JUnit/game suite.", + "seal_before_second_clean": [ + "main jar", + "packaged historical probe", + "native-only control", + "logging inputs", + "tool/SDK/JDK hashes", + "original prepared-world manifest" + ], + "historical_priority": [ + "recovery-116", + "recovery-115", + "recovery-117", + "original-110", + "released-117" + ], + "historical_origins": 47, + "historical_cases": 94, + "historical_phases_per_case": 10, + "historical_receipts_including_future_refusals": 1130, + "max_historical_workers": 2, + "shared_cache_gradle_writers": 1, + "visibility": "Keep both6200ticks and310seconds and connected restart; no shorter acceptance substitute.", + "respawns": "Keep64 at freeze;16 per mode are8 scenarios repeated under accumulated state, not16 distinct branches.", + "native_controls": "Reuse only unchanged no-mod control/SDK/JDK/flags/logging scope. Requalify after environment/control changes or diagnosed failure, not every mod edit.", + "evidence": "Rehash all receipts, original worlds, inputs and artifacts before expensive reuse/publication. Missing, changed or unreadable bytes block reuse. Never reconstruct a receipt or count reuse as a fresh run." + }, + "phase_consolidation": { + "enabled": false, + "proposal": [ + "Recover near inventory after checking the first cold near reload; retain recovered cold reload", + "Remove the empty corpse after all-wave return checks; retain final cold reload/no-resurrection" + ], + "requires": "Native disk-equivalence and deliberate loss/duplication/resurrection/deferred-chunk negative controls on next authorised run, then explicit new coverage/receipt schema", + "origins_and_cases_unchanged_if_validated": [ + 47, + 94 + ], + "positive_jvms_if_validated": 752, + "total_receipts_if_validated": 942, + "current_10_phase_contract_remains_authoritative": true + } +} diff --git a/tools/runtime_diagnostics.py b/tools/runtime_diagnostics.py index 590c819..7a58b9e 100644 --- a/tools/runtime_diagnostics.py +++ b/tools/runtime_diagnostics.py @@ -9,6 +9,32 @@ from pathlib import Path import subprocess import time +import xml.etree.ElementTree as ET +from urllib.parse import urlparse +from urllib.request import url2pathname + + +def validate_logging_inputs(command): + """Reject missing declared logging files before starting an owned JVM.""" + values=[str(value).split('=',1)[1] for value in command if str(value).startswith('-Dlog4j.configurationFile=')] + if len(values)>1:raise ValueError('Multiple explicit runtime logging configurations') + inputs={} + for value in values: + parsed=urlparse(value) + if parsed.scheme=='file': + if parsed.netloc not in ('','localhost'):raise ValueError('Logging configuration must be a local input') + path=Path(url2pathname(parsed.path)) + elif parsed.scheme and not (len(parsed.scheme)==1 and value[1:3] in (':\\',':/')): + raise ValueError('Logging configuration must be a local input') + else:path=Path(value) + if not path.is_file() or not path.stat().st_size:raise FileNotFoundError('Missing runtime logging input: '+str(path)) + data=path.read_bytes() + if path.suffix.lower()=='.xml': + try: + if ET.fromstring(data).tag!='Configuration':raise ValueError('Wrong logging configuration root') + except ET.ParseError as error:raise ValueError('Malformed runtime logging input: '+str(path)) from error + inputs[str(path.resolve())]=hashlib.sha256(data).hexdigest().upper() + return inputs def run_monitored(command, *, cwd, log, timeout, diagnostics, diff --git a/tools/test_graphical_split.py b/tools/test_graphical_split.py index 3b17082..4140817 100644 --- a/tools/test_graphical_split.py +++ b/tools/test_graphical_split.py @@ -2,6 +2,7 @@ import subprocess import tempfile import unittest +import json from unittest.mock import patch import verify_packaged_client as client @@ -52,3 +53,45 @@ def test_continuation_timeout_is_not_retried(self): with patch.object(client,'run_client',side_effect=[subprocess.CompletedProcess(['java'],0),subprocess.TimeoutExpired(['java'],180)]) as run: with self.assertRaises(subprocess.TimeoutExpired):client.run_default_phases(['java','main'],root) self.assertEqual(2,run.call_count) + + def test_failed_continuation_cannot_destroy_actual_first_phase_world(self): + with tempfile.TemporaryDirectory() as directory: + root=Path(directory);world=root/'saves/build-fixture';world.mkdir(parents=True) + (world/'level.dat').write_bytes(b'actual first header') + (world/'entities').mkdir();(world/'entities/r.0.0.mca').write_bytes(b'actual first region') + (root/'screenshots').mkdir();(root/'screenshots/first.png').write_bytes(b'image') + (root/'client-connected.pass').write_text('complete') + (root/'client.log').write_text('LOOTABLE_BODIES_CONNECTED_STAGE_PASS\n') + def run(command,home,label,timeout): + if label=='client-recovery': + (world/'level.dat').write_bytes(b'failed continuation saved new bytes') + (world/'entities/r.0.0.mca').unlink() + return subprocess.CompletedProcess(command,1) + return subprocess.CompletedProcess(command,0) + with patch.object(client,'run_client',side_effect=run) as launch: + result=client.run_default_phases(['java','main'],root) + self.assertEqual(1,result.returncode);self.assertEqual(2,launch.call_count) + frozen=root/'connected-world' + self.assertEqual(b'actual first header',(frozen/'level.dat').read_bytes()) + self.assertEqual(b'actual first region',(frozen/'entities/r.0.0.mca').read_bytes()) + receipt=json.loads((root/'client-handoff.json').read_text()) + self.assertEqual(receipt['saved_world'],client.fingerprint(frozen)) + self.assertEqual('connected-world',receipt['frozen_world']) + + def test_missing_or_damaged_frozen_world_prevents_continuation(self): + with tempfile.TemporaryDirectory() as directory: + root=Path(directory);world=root/'saves/build-fixture';world.mkdir(parents=True) + (world/'level.dat').write_bytes(b'actual header') + (root/'screenshots').mkdir();(root/'screenshots/first.png').write_bytes(b'image') + (root/'client-connected.pass').write_text('complete') + (root/'client.log').write_text('LOOTABLE_BODIES_CONNECTED_STAGE_PASS\n') + real_copy=client.shutil.copytree + def copy(source,target,*args,**kwargs): + result=real_copy(source,target,*args,**kwargs) + if Path(target).name=='connected-world':(Path(target)/'level.dat').write_bytes(b'changed') + return result + with patch.object(client,'run_client',return_value=subprocess.CompletedProcess(['java'],0)) as launch,\ + patch.object(client.shutil,'copytree',side_effect=copy): + with self.assertRaisesRegex(RuntimeError,'frozen'): + client.run_default_phases(['java','main'],root) + self.assertEqual(1,launch.call_count) diff --git a/tools/test_historical_runtime_budget.py b/tools/test_historical_runtime_budget.py index 9dfb80d..3e4acab 100644 --- a/tools/test_historical_runtime_budget.py +++ b/tools/test_historical_runtime_budget.py @@ -18,6 +18,7 @@ def launch(java, home, phase, **options): (home/'world').mkdir(exist_ok=True) (home/'world'/(phase+'.pass')).touch() (home/(phase+'.log')).write_text('LOOTABLE_BODIES_WORLD_UPGRADE_PASS phase='+phase+'\n') + for name in ['latest.log','debug.log']:(home/(phase+'.'+name)).write_text('Synthetic complete native log fixture\n') return subprocess.CompletedProcess([], 0) with tempfile.TemporaryDirectory() as directory, patch.object(native_runtime, 'run_java', launch): @@ -27,7 +28,8 @@ def launch(java, home, phase, **options): self.assertEqual(PHASES, [p for p, options in launches]) for phase, options in launches: self.assertEqual(240, options['timeout']) - self.assertIn('-XX:ActiveProcessorCount=2', options['jvm_args']) + self.assertEqual(['-Dmax.bg.threads=1'],[p for p in options['jvm_args'] if p.startswith('-Dmax.bg.threads=')]) + self.assertFalse(any(p.startswith(('-XX:ActiveProcessorCount','-XX:+Use')) for p in options['jvm_args'])) self.assertIn('-Dlootablebodies.phase='+phase, options['jvm_args']) def test_unsafe_refusals_use_the_same_budget_and_keep_their_shorter_deadline(self): @@ -35,7 +37,8 @@ def test_unsafe_refusals_use_the_same_budget_and_keep_their_shorter_deadline(sel run_abort(Path('java'), Path('owned')) options=launch.call_args.kwargs self.assertEqual(180, options['timeout']) - self.assertIn('-XX:ActiveProcessorCount=2', options['jvm_args']) + self.assertEqual(['-Dmax.bg.threads=1'],[p for p in options['jvm_args'] if p.startswith('-Dmax.bg.threads=')]) + self.assertFalse(any(p.startswith(('-XX:ActiveProcessorCount','-XX:+Use')) for p in options['jvm_args'])) self.assertNotIn('-Dlootablebodies.phase=abort', options['jvm_args']) def test_ordinary_packaged_game_keeps_the_default_native_processor_count(self): @@ -46,11 +49,12 @@ def launch(java, home, phase, **options): (home/'world').mkdir() (home/'world'/(phase+'.pass')).touch() (home/(phase+'.log')).write_text('ORDINARY_RUNTIME_COMPLETE\n') + for name in ['latest.log','debug.log']:(home/(phase+'.'+name)).write_text('Synthetic complete native log fixture\n') return subprocess.CompletedProcess([], 0) with tempfile.TemporaryDirectory() as directory, patch.object(native_runtime, 'run_java', launch): native_runtime.run_server(Path('java'), Path(directory), 'ordinary') - self.assertFalse(any(p.startswith('-XX:ActiveProcessorCount') for p in arguments)) + self.assertFalse(any(p.startswith(('-XX:ActiveProcessorCount','-Dmax.bg.threads','-XX:+Use')) for p in arguments)) if __name__ == '__main__': diff --git a/tools/test_hosted_policy.py b/tools/test_hosted_policy.py new file mode 100644 index 0000000..0df1a6a --- /dev/null +++ b/tools/test_hosted_policy.py @@ -0,0 +1,45 @@ +"""Prospective contracts. Deliberately not executed during the process review.""" +import json,unittest +from pathlib import Path +from hosted_policy import gradle_roots,validate_workflow,require_qualified_manifest,check_source + +class HostedPolicyTests(unittest.TestCase): + def test_hosted_roots_allow_archive_builds_and_reject_every_test_or_game_entry(self): + base='./gradlew assemble verifyProductionRuntimeClasspath prepareReleaseBundle ' + self.assertEqual(3,len(gradle_roots(base+'--no-daemon 2>&1 | tee build.log'))) + for root in ('test','check','build','nativeBoundaryTest','integrationTest','clientIntegrationTest','forgeControlServer','runServer',':test','--init-script=extra.gradle','-PrunGames=true'): + with self.subTest(root=root),self.assertRaises(ValueError):gradle_roots(base+root) + + def test_current_workflow_is_compile_archive_only(self): + root=Path(__file__).resolve().parents[1];check_source(root) + + def test_injected_python_games_full_suites_and_sdk_installers_are_rejected(self): + text=(Path(__file__).resolve().parents[1]/'.github/workflows/native118.yml').read_text() + for command in ('python3 -m unittest discover -s tools','python3 tools/verify_world_upgrades.py','python3 tools/native_runtime.py','python3 tools/prepare_native117_tools.py','xvfb-run ./gradlew runClient','python3 tools/new_game_runner.py','curl https://example.invalid/server.jar','java -jar server.jar'): + changed=text.replace(' chmod +x gradlew',' '+command+'\n chmod +x gradlew') + with self.subTest(command=command),self.assertRaises(ValueError):validate_workflow(changed) + + def test_missing_fork_guard_or_pipe_failure_guard_is_rejected(self): + text=(Path(__file__).resolve().parents[1]/'.github/workflows/native118.yml').read_text() + for removed in ("if: github.repository == 'MinecraftModDevelopmentMods/LootableBodies'",'set -euo pipefail','shell: bash'): + with self.subTest(removed=removed),self.assertRaises(ValueError):validate_workflow(text.replace(removed,'')) + + def test_manifest_boolean_cannot_override_an_integrity_hold(self): + version='2.4.9.118021';base='LootableBodies-'+version + names=['CHANGELOG.txt','SHA256SUMS',base+'.jar',base+'-sources.jar',base+'-javadoc.jar',base+'.pom'] + good={'version':version,'minecraft':'1.18.2','forge':'40.3.12','local_qualification_complete':True,'qualification_integrity_status':'verified','files':{name:'A'*64 for name in names}} + require_qualified_manifest(good) + for field,value in [('qualification_integrity_status','blocked_receipt_mismatch'),('qualification_integrity_status',None),('local_qualification_complete',False),('minecraft','1.17.1')]: + changed=dict(good);changed[field]=value + with self.subTest(field=field,value=value),self.assertRaises(ValueError):require_qualified_manifest(changed) + + def test_missing_or_extra_file_declaration_is_fatal(self): + version='2.4.9.118021';base='LootableBodies-'+version + good={'version':version,'minecraft':'1.18.2','forge':'40.3.12','local_qualification_complete':True,'qualification_integrity_status':'verified','files':{name:'A'*64 for name in ['CHANGELOG.txt','SHA256SUMS',base+'.jar',base+'-sources.jar',base+'-javadoc.jar',base+'.pom']}} + for extra in (False,True): + changed=json.loads(json.dumps(good)) + if extra:changed['files']['extra.jar']='B'*64 + else:changed['files'].pop(base+'.jar') + with self.subTest(extra=extra),self.assertRaises(ValueError):require_qualified_manifest(changed) + +if __name__=='__main__':unittest.main() diff --git a/tools/test_local_case_reuse.py b/tools/test_local_case_reuse.py new file mode 100644 index 0000000..0d76292 --- /dev/null +++ b/tools/test_local_case_reuse.py @@ -0,0 +1,25 @@ +import copy,unittest +from local_case_reuse import compare_programs,compare_inputs,DEPENDENCIES + +class LocalCaseReuseTests(unittest.TestCase): + def test_only_unexecuted_stamp_body_change_is_allowed(self): + old='def patch_stamps(world, disposable):\n return 1\ndef verify(a):\n return patch_stamps(a,a) if a.variant=="stamps" else []\n' + compare_programs(old,old.replace('return 1','return 2')) + for changed in (old.replace('world, disposable','world, disposable=None'),old.replace('else []','else [1]'), + old.replace('=="stamps"','=="normal"'),old+'\nexec("changed")\n',old.replace('def patch_stamps','def other')): + with self.subTest(changed=changed),self.assertRaises(ValueError):compare_programs(old,changed) + def test_each_runtime_candidate_writer_or_logging_input_change_refuses(self): + old={k:'exact' for k in ('base_source','main_sha256','probe_sha256','sdk_files','java_files')} + old['frozen_files']={name:'exact' for name in (*DEPENDENCIES,'prepared-worlds.json')} + compare_inputs(old,copy.deepcopy(old)) + for key in set(old)-{'frozen_files'}: + new=copy.deepcopy(old);new[key]='changed' + with self.subTest(key=key),self.assertRaises(ValueError):compare_inputs(old,new) + for name in old['frozen_files']: + new=copy.deepcopy(old);new['frozen_files'][name]='changed' + with self.subTest(name=name),self.assertRaises(ValueError):compare_inputs(old,new) + def test_missing_dependency_cannot_reuse(self): + old={k:'exact' for k in ('base_source','main_sha256','probe_sha256','sdk_files','java_files')};old['frozen_files']={} + with self.assertRaises(ValueError):compare_inputs(old,old) + +if __name__=='__main__':unittest.main() diff --git a/tools/test_logging_inputs.py b/tools/test_logging_inputs.py new file mode 100644 index 0000000..b75517c --- /dev/null +++ b/tools/test_logging_inputs.py @@ -0,0 +1,44 @@ +"""Missing local logging inputs cannot become marker-only native qualification.""" +import hashlib,tempfile,unittest +from pathlib import Path +from unittest.mock import patch +from runtime_diagnostics import validate_logging_inputs +from owned_process import native_process +import native_runtime + +class LoggingInputTest(unittest.TestCase): + def test_real_xml_file_is_bound_by_exact_hash(self): + with tempfile.TemporaryDirectory() as directory: + path=Path(directory)/'logging input.xml';path.write_text('') + self.assertEqual({str(path.resolve()):hashlib.sha256(path.read_bytes()).hexdigest().upper()},validate_logging_inputs(['java','-Dlog4j.configurationFile='+path.as_uri()])) + def test_missing_or_empty_config_stops_before_a_native_group_launch(self): + for empty in [False,True]: + with self.subTest(empty=empty),tempfile.TemporaryDirectory() as directory: + path=Path(directory)/'log4j2.xml' + if empty:path.touch() + with patch('owned_process.OwnedProcess') as launch,self.assertRaises(FileNotFoundError): + native_process(['java','-Dlog4j.configurationFile='+path.as_uri()],cwd=path.parent,log=path.parent/'run.log',timeout=240) + launch.assert_not_called() + def test_malformed_wrong_root_remote_or_duplicate_configs_are_fatal(self): + with tempfile.TemporaryDirectory() as directory: + path=Path(directory)/'log4j2.xml' + for text in ['','']: + path.write_text(text) + with self.assertRaises(ValueError):validate_logging_inputs(['java','-Dlog4j.configurationFile='+path.as_uri()]) + with self.assertRaises(ValueError):validate_logging_inputs(['java','-Dlog4j.configurationFile=https://example.invalid/log4j2.xml']) + path.write_text('');flag='-Dlog4j.configurationFile='+path.as_uri() + with self.assertRaises(ValueError):validate_logging_inputs(['java',flag,flag]) + def test_marker_without_both_complete_native_log_copies_cannot_pass(self): + import subprocess + for missing in ['latest.log','debug.log']: + with self.subTest(missing=missing),tempfile.TemporaryDirectory() as directory: + home=Path(directory) + def launch(*args,**kwargs): + (home/'world').mkdir();(home/'world/phase.pass').touch();(home/'phase.log').write_text('NATIVE_PASS\n') + for name in ['latest.log','debug.log']: + if name!=missing:(home/('phase.'+name)).write_text('native log\n') + return subprocess.CompletedProcess([],0) + with patch.object(native_runtime,'run_java',side_effect=launch),self.assertRaisesRegex(FileNotFoundError,'complete native log'): + native_runtime.run_server(Path('java'),home,'phase',message='NATIVE_PASS') + +if __name__=='__main__':unittest.main() diff --git a/tools/test_migration_ci.py b/tools/test_migration_ci.py index 694d8c4..bd62bbb 100644 --- a/tools/test_migration_ci.py +++ b/tools/test_migration_ci.py @@ -36,10 +36,13 @@ def test_six_shards_cover_every_origin_exactly_once(self): for value in [-1,6]: with self.assertRaises(ValueError):ci.partition(list(ORIGINS),value) - def test_all_760_phases_and_152_refusals_are_required(self): + def test_all_940_phases_and_188_refusals_retain_every_earlier_case(self): result = ci.aggregate(self.reports(), list(ORIGINS), 'a'*40) - self.assertEqual((76,760,152),(len(result['cases']),result['jvm_phases'],result['abort_cases'])) - native16=[name for name in result['cases'] if not name.startswith(('released-116-','recovery-116-','via116-'))] + self.assertEqual((94,940,188),(len(result['cases']),result['jvm_phases'],result['abort_cases'])) + native17=[name for name in result['cases'] if not name.startswith(('released-117-','recovery-117-','via117-'))] + self.assertEqual(76,len(native17));self.assertEqual(760,sum(len(result['cases'][name]['phases']) for name in native17)) + self.assertEqual(152,sum(len(result['aborts'][name]['conditions']) for name in native17)) + native16=[name for name in native17 if not name.startswith(('released-116-','recovery-116-','via116-'))] self.assertEqual(60,len(native16));self.assertEqual(600,sum(len(result['cases'][name]['phases']) for name in native16)) self.assertEqual(120,sum(len(result['aborts'][name]['conditions']) for name in native16)) earlier=[name for name in native16 if not name.startswith(('released-115-','recovery-115-','via115-'))] diff --git a/tools/test_native116_infrastructure.py b/tools/test_native116_infrastructure.py index 18ea517..51c8849 100644 --- a/tools/test_native116_infrastructure.py +++ b/tools/test_native116_infrastructure.py @@ -5,29 +5,35 @@ class Native116InfrastructureTest(unittest.TestCase): def test_native_distribution_and_every_server_input_use_the_target(self): - self.assertEqual('forge-1.17.1-37.1.1',runtime.FORGE) - self.assertEqual('31F2319B1B9CA491AACCC020887240F8524A4FF2A8E0F8B795E17E889CBC043B',runtime.INSTALLER_SHA256) - self.assertEqual(79,len(runtime.SERVER_INPUTS)) - self.assertEqual('35DB91BCD011CAD8B812BA165CD7F5AD934CEEEE9050621423F9E5D227D7F895', - runtime.SERVER_INPUTS['libraries/net/minecraftforge/forge/1.17.1-37.1.1/forge-1.17.1-37.1.1-server.jar']) - self.assertEqual('B0E2D21DBA2E7C55A7DD9BBF914ADA99B124692810158F970F8F7C20543C2311', - runtime.SERVER_INPUTS['libraries/net/minecraftforge/forge/1.17.1-37.1.1/win_args.txt']) + self.assertEqual('forge-1.18.2-40.3.12',runtime.FORGE) + self.assertEqual('DCFF493F6C2212BDF6AE087F98029CD317A79759AB1009DCE23E63E7DBA0E216',runtime.INSTALLER_SHA256) + self.assertEqual(95,len(runtime.SERVER_INPUTS)) + self.assertEqual('92DE04B82205209189D6E501516D0CB1EC72A97E9779A4F11D058940DE04C3C1', + runtime.SERVER_INPUTS['libraries/net/minecraftforge/forge/1.18.2-40.3.12/forge-1.18.2-40.3.12-server.jar']) + self.assertEqual('897DE75C036F1E5E2ED79F77271FB1CE8803A8A416107ACB339B43CA220B87CD', + runtime.SERVER_INPUTS['libraries/net/minecraftforge/forge/1.18.2-40.3.12/win_args.txt']) self.assertFalse(any('1.16.5' in path for path in runtime.SERVER_INPUTS)) def test_development_assets_use_the_native_index(self): text=(Path(__file__).resolve().parents[1]/'gradle/assets.gradle').read_text() - self.assertIn('indexes/1.17.json',text) + self.assertIn('indexes/1.18.json',text) self.assertNotIn('e9d0adb8f642abe422909ede50f651b2b58a3573',text) def test_every_released115_history_extends_the_complete_earlier_matrix(self): from verify_world_upgrades import ORIGINS,PHASES names={'released-115','recovery-115','via115-110','via115-111','via115-112','via115-113','via115-114'} - earlier={name for name in ORIGINS if not name.startswith(('released-116','recovery-116','via116-'))} + earlier={name for name in ORIGINS if not name.startswith(('released-116','recovery-116','via116-','released-117','recovery-117','via117-'))} self.assertTrue(names.issubset(earlier));self.assertEqual(30,len(earlier)) self.assertEqual(23,len(earlier-names));self.assertEqual(10,len(PHASES)) self.assertEqual('DC7798F3E165EC369D8092324C9F85F51D7E633824D1BD04D946A60ADA3DDDA7',ORIGINS['released-115'][2]) def test_public116_sources_extend_every_prior_origin_without_replacing_it(self): from verify_world_upgrades import ORIGINS names={'released-116','recovery-116',*(f'via116-{version}' for version in range(110,116))} - self.assertEqual(38,len(ORIGINS));self.assertTrue(names.issubset(ORIGINS)) + earlier={name for name in ORIGINS if not name.startswith(('released-117','recovery-117','via117-'))} + self.assertEqual(38,len(earlier));self.assertTrue(names.issubset(earlier)) self.assertEqual('E545CF725650E8A01C5863A4C92BCB2D23437D61C9441C2F5D27A5272EDDA229',ORIGINS['released-116'][2]) + def test_public117_adds_nine_origins_without_replacing_the_original38(self): + from verify_world_upgrades import ORIGINS + names={'released-117','recovery-117',*(f'via117-{version}' for version in range(110,117))} + self.assertEqual(47,len(ORIGINS));self.assertEqual(9,len(names));self.assertTrue(names.issubset(ORIGINS));self.assertEqual(38,len(set(ORIGINS)-names)) + self.assertEqual('854A46D80B8599966E43B3EAED407CD1A99FE4FB019AEE9138FE2F98E8DDA9FA',ORIGINS['released-117'][2]) if __name__=='__main__':unittest.main() diff --git a/tools/test_native117_client.py b/tools/test_native117_client.py index 618d7ff..fb88e13 100644 --- a/tools/test_native117_client.py +++ b/tools/test_native117_client.py @@ -4,15 +4,15 @@ import tempfile import unittest from unittest.mock import patch -import native117_client +import native118_client import native_runtime class Native117ClientTests(unittest.TestCase): def test_native_module_metadata_is_used_instead_of_legacy_classpath_only_launch(self): - with patch('native117_client.command',return_value=['native37-command']) as launch: + with patch('native118_client.command',return_value=['native40-command']) as launch: result=native_runtime.client_command(Path('java'),Path('home'),Path('sdk'),Path('assets'),prepared=Path('prepared'),username='Player',extra=['-Dprobe=true']) - self.assertEqual(['native37-command'],result) + self.assertEqual(['native40-command'],result) self.assertEqual(Path('prepared'),launch.call_args.args[4]) self.assertEqual('Player',launch.call_args.kwargs['username']) @@ -21,38 +21,39 @@ def test_feature_os_and_version_rules_do_not_enable_demo_or_wrong_platform_flags {'rules':[{'action':'allow','features':{'has_custom_resolution':True}}],'value':['--width','${width}']}, {'rules':[{'action':'allow','os':{'name':'windows','version':'^10\\.'}}],'value':'-Dos.version=10.0'}, {'rules':[{'action':'allow','os':{'name':'osx'}}],'value':'-XstartOnFirstThread'}] - self.assertEqual(['--width','854','-Dos.version=10.0'],native117_client.expand(arguments,{'width':'854'},system='windows',architecture='amd64',os_version='10.0.26200')) + self.assertEqual(['--width','854','-Dos.version=10.0'],native118_client.expand(arguments,{'width':'854'},system='windows',architecture='amd64',os_version='10.0.26200')) def test_unknown_placeholder_fails_before_a_process_can_be_started(self): with self.assertRaises(KeyError): - native117_client.expand(['${unrecognised}'],{},system='windows',architecture='amd64',os_version='10.0') + native118_client.expand(['${unrecognised}'],{},system='windows',architecture='amd64',os_version='10.0') def test_jvm_filters_the_actual_base_jar_while_game_keeps_its_forge_profile_version(self): with tempfile.TemporaryDirectory() as directory: root=Path(directory);java=root/'jdk/bin/java.exe';java.parent.mkdir(parents=True) - (java.parent.parent/'release').write_text('SEMANTIC_VERSION="16.0.2+7"\nIMPLEMENTOR="Eclipse Foundation"\n') - index=root/'assets/indexes/1.17.json';index.parent.mkdir(parents=True);index.write_bytes(b'{}') - base={'assetIndex':{'id':'1.17','sha1':hashlib.sha1(b'{}').hexdigest()},'arguments':{'jvm':['-cp','${classpath}'],'game':['--version','${version_name}','--accessToken','${auth_access_token}']}} + (java.parent.parent/'release').write_text('SEMANTIC_VERSION="17.0.20.1+1"\nIMPLEMENTOR="Eclipse Adoptium"\n') + index=root/'assets/indexes/1.18.json';index.parent.mkdir(parents=True);index.write_bytes(b'{}') + base={'assetIndex':{'id':'1.18','sha1':hashlib.sha1(b'{}').hexdigest()},'arguments':{'jvm':['-cp','${classpath}'],'game':['--version','${version_name}','--accessToken','${auth_access_token}','--clientId','${clientid}','--xuid','${auth_xuid}']}} forge={'arguments':{'jvm':['-DignoreList=forge-,${version_name}.jar'],'game':['--launchTarget','forgeclient']},'mainClass':'cpw.mods.bootstraplauncher.BootstrapLauncher'} - record={'library_directory':str(root/'sdk/libraries'),'classpath':[str(root/'1.17.1.jar')],'natives':str(root/'natives'),'system':'windows','architecture':'amd64'} - with patch('native117_client.load',return_value=(base,forge,record)): - result=native117_client.command(java,root/'home',root/'sdk',root/'assets',root/'prepared',username='Fixture') - self.assertIn('-DignoreList=forge-,1.17.1.jar',result) - self.assertEqual('1.17.1-forge-37.1.1',result[result.index('--version')+1]) + record={'library_directory':str(root/'sdk/libraries'),'classpath':[str(root/'1.18.2.jar')],'natives':str(root/'natives'),'system':'windows','architecture':'amd64'} + with patch('native118_client.load',return_value=(base,forge,record)): + result=native118_client.command(java,root/'home',root/'sdk',root/'assets',root/'prepared',username='Fixture') + self.assertIn('-DignoreList=forge-,1.18.2.jar',result) + self.assertEqual('1.18.2-forge-40.3.12',result[result.index('--version')+1]) self.assertEqual('0',result[result.index('--accessToken')+1]) + self.assertEqual('',result[result.index('--clientId')+1]);self.assertEqual('',result[result.index('--xuid')+1]) def test_unsealed_classpath_and_changed_native_bytes_are_rejected(self): with tempfile.TemporaryDirectory() as directory: root=Path(directory);library=root/'native.dll';library.write_bytes(b'original') - record={'version':native117_client.VERSION,'sdk':str(root.resolve()),'system':'windows','architecture':'amd64', + record={'version':native118_client.VERSION,'sdk':str(root.resolve()),'system':'windows','architecture':'amd64', 'files':{str(library):native_runtime.digest(library)},'classpath':[str(root/'unsealed.jar')]} (root/'inputs.json').write_text(json.dumps(record)) - with patch('native117_client.descriptors',return_value=({},{})),patch('native117_client.platform.system',return_value='Windows'),patch('native117_client.platform.architecture',return_value=('64bit','')): + with patch('native118_client.descriptors',return_value=({},{})),patch('native118_client.platform.system',return_value='Windows'),patch('native118_client.platform.architecture',return_value=('64bit','')): with self.assertRaisesRegex(RuntimeError,'Unsealed'): - native117_client.load(root,root) + native118_client.load(root,root) library.write_bytes(b'changed') with self.assertRaisesRegex(RuntimeError,'Changed prepared'): - native117_client.load(root,root) + native118_client.load(root,root) if __name__=='__main__':unittest.main() diff --git a/tools/test_native117_flat_header.py b/tools/test_native117_flat_header.py index b4cea8e..7e7a5a3 100644 --- a/tools/test_native117_flat_header.py +++ b/tools/test_native117_flat_header.py @@ -8,7 +8,7 @@ class Native117FlatHeaderTests(unittest.TestCase): - def header(self,root,version=2730,generator='minecraft:flat'): + def header(self,root,version=2975,generator='minecraft:flat'): settings=Tag(10,{'dimensions':Tag(10,{'minecraft:overworld':Tag(10,{ 'generator':Tag(10,{'type':Tag(8,generator)})})})}) data=encode('',Tag(10,{'Data':Tag(10,{'DataVersion':Tag(3,version),'WorldGenSettings':settings})})) @@ -22,7 +22,7 @@ def test_only_the_explicit_sealed_header_is_copied(self): self.assertEqual(before,(home/'world/level.dat').read_bytes());self.assertEqual(before,header.read_bytes()) self.assertEqual(['level.dat'],[p.name for p in (home/'world').iterdir()]) def test_wrong_hash_target_or_generator_refuses_before_creating_world(self): - for version,generator,bad_hash in [(2730,'minecraft:flat',True),(2586,'minecraft:flat',False),(2730,'minecraft:noise',False)]: + for version,generator,bad_hash in [(2975,'minecraft:flat',True),(2586,'minecraft:flat',False),(2730,'minecraft:flat',False),(2975,'minecraft:noise',False)]: with self.subTest(version=version,generator=generator,bad_hash=bad_hash),tempfile.TemporaryDirectory() as directory: root=Path(directory);header,digest=self.header(root,version,generator);home=self.home(root) with self.assertRaises(RuntimeError):seed_flat_fixture(home,header,'0'*64 if bad_hash else digest) @@ -34,5 +34,13 @@ def test_existing_world_is_never_overwritten(self): with self.assertRaises(FileExistsError):seed_flat_fixture(home,header,digest) self.assertEqual(b'existing world bytes',protected.read_bytes()) + def test_actual_native18_codec_header_is_a_sealed_current_target_input(self): + header=Path(__file__).parent/'fixtures/native118-flat-level.dat' + expected='A806A2751860CDB3D8566510D6A714435F36A6182C026825943BCC24838AC031' + self.assertEqual(expected,hashlib.sha256(header.read_bytes()).hexdigest().upper()) + with tempfile.TemporaryDirectory() as directory: + home=self.home(Path(directory));seed_flat_fixture(home,header,expected) + self.assertEqual(header.read_bytes(),(home/'world/level.dat').read_bytes()) + if __name__=='__main__':unittest.main() diff --git a/tools/test_native117_launch.py b/tools/test_native117_launch.py index 29a47b5..d0ab4f3 100644 --- a/tools/test_native117_launch.py +++ b/tools/test_native117_launch.py @@ -11,26 +11,27 @@ class Native117LaunchTests(unittest.TestCase): def test_posix_native_diagnostics_fixture_uses_the_posix_tools(self): with patch.object(native_runtime,'os',SimpleNamespace(name='posix')): - self.test_packaged_server_uses_installer_module_arguments_and_java16_gc() + self.test_packaged_server_uses_installer_module_arguments_and_java17_gc() - def jdk(self, root, version='16.0.2+7'): + def jdk(self, root, version='17.0.20.1+1'): suffix='.exe' if native_runtime.os.name=='nt' else '' java = root/('jdk/bin/java'+suffix) java.parent.mkdir(parents=True) for name in ('java', 'jstack', 'jstat'): (java.parent/(name+suffix)).touch() (java.parent.parent/'release').write_text( - 'SEMANTIC_VERSION="'+version+'"\nIMPLEMENTOR="Eclipse Foundation"\n') + 'SEMANTIC_VERSION="'+version+'"\nIMPLEMENTOR="Eclipse Adoptium"\n') return java - def test_packaged_server_uses_installer_module_arguments_and_java16_gc(self): + def test_packaged_server_uses_installer_module_arguments_and_java17_gc(self): with tempfile.TemporaryDirectory() as directory: home=Path(directory); java=self.jdk(home) with patch('native_runtime.run_monitored', return_value=subprocess.CompletedProcess([],0)) as run: native_runtime.run_java(java,home,'fixture',timeout=240) command=run.call_args.args[0] self.assertNotIn('-jar',command) - self.assertTrue(any(arg.startswith('@libraries/net/minecraftforge/forge/1.17.1-37.1.1/') for arg in command)) + self.assertIn('-Xmx2G',command) + self.assertTrue(any(arg.startswith('@libraries/net/minecraftforge/forge/1.18.2-40.3.12/') for arg in command)) gc=next((arg for arg in command if arg.startswith('-Xlog:')),None) self.assertIsNotNone(gc) self.assertIn('gc*,safepoint',gc) @@ -44,25 +45,28 @@ def test_runtime_rejects_a_different_jdk_before_launch(self): with tempfile.TemporaryDirectory() as directory: home=Path(directory); java=self.jdk(home,'17.0.1+12') with patch('native_runtime.run_monitored') as run: - with self.assertRaisesRegex(RuntimeError,'16.0.2'): + with self.assertRaisesRegex(RuntimeError,'17.0.20.1'): native_runtime.run_java(java,home,'fixture') run.assert_not_called() - def test_server_inputs_pin_native37_instead_of_the_old_launcher(self): - self.assertEqual('forge-1.17.1-37.1.1',native_runtime.FORGE) - for path in ('libraries/net/minecraftforge/forge/1.17.1-37.1.1/win_args.txt', - 'libraries/net/minecraftforge/forge/1.17.1-37.1.1/unix_args.txt', - 'libraries/net/minecraftforge/forge/1.17.1-37.1.1/forge-1.17.1-37.1.1-server.jar'): + def test_server_inputs_pin_native40_instead_of_the_old_launcher(self): + self.assertEqual('forge-1.18.2-40.3.12',native_runtime.FORGE) + for path in ('libraries/net/minecraftforge/forge/1.18.2-40.3.12/win_args.txt', + 'libraries/net/minecraftforge/forge/1.18.2-40.3.12/unix_args.txt', + 'libraries/net/minecraftforge/forge/1.18.2-40.3.12/forge-1.18.2-40.3.12-server.jar'): self.assertIn(path,native_runtime.SERVER_INPUTS) self.assertFalse(any('1.16.5' in path for path in native_runtime.SERVER_INPUTS)) - def test_historical_cpu_budget_requires_its_own_native37_validation(self): + def test_native40_historical_workers_have_their_own_fresh_qualification_binding(self): proof=native_runtime.WORKER_BUDGET_PROOF - self.assertEqual(('1.17.1','37.1.1',200,4,1),tuple(proof[key] for key in - ('minecraft','forge','reloads','jvms','native_preparation_workers'))) + self.assertEqual(('1.18.2','40.3.12',200,4,True),tuple(proof[key] for key in + ('minecraft','forge','reloads','jvms','qualified'))) self.assertTrue(proof['no_candidate'] and proof['no_worlds'] and proof['source_and_sdk_seals_unchanged']) - self.assertEqual(4,len(proof['receipts'])) - self.assertEqual(('-XX:ActiveProcessorCount=2',),native_runtime.HISTORICAL_JVM_ARGS) + self.assertRegex(proof['qualification_evidence_sha256'],r'^[A-F0-9]{64}$') + source=Path(__file__).resolve().parents[1]/'src/controlTest/java/buildcontrol/ResourceControl.java' + self.assertEqual(hashlib.sha256(source.read_bytes()).hexdigest().upper(),proof['controller_sha256']) + self.assertEqual(('-Dmax.bg.threads=1',),native_runtime.HISTORICAL_JVM_ARGS) + self.assertEqual(1,proof['native_preparation_workers']) def test_owned_server_does_not_trigger_native_bare_json_flat_settings_fallback(self): with tempfile.TemporaryDirectory() as directory: diff --git a/tools/test_native118_build.py b/tools/test_native118_build.py new file mode 100644 index 0000000..15bef29 --- /dev/null +++ b/tools/test_native118_build.py @@ -0,0 +1,46 @@ +import json,tempfile,unittest +from pathlib import Path +from native118_build import check_bundle +from native_runtime import digest +from release_identity import MOD_VERSION,MAIN_JAR + +class Native118BuildContractTests(unittest.TestCase): + def test_missing_changed_extra_and_unqualified_inputs_are_fatal(self): + with tempfile.TemporaryDirectory() as directory: + root=Path(directory);bundle=root/'bundle';bundle.mkdir();manifest=root/'manifest.json' + names=['CHANGELOG.txt','SHA256SUMS',MAIN_JAR,MAIN_JAR[:-4]+'-sources.jar',MAIN_JAR[:-4]+'-javadoc.jar',MAIN_JAR[:-4]+'.pom'] + for name in names:(bundle/name).write_bytes(name.encode()) + record={'version':MOD_VERSION,'minecraft':'1.18.2','forge':'40.3.12','local_qualification_complete':True,'qualification_integrity_status':'verified','files':{p.name:digest(p) for p in bundle.iterdir()}} + manifest.write_text(json.dumps(record));self.assertEqual(record['files'],check_bundle(manifest,bundle)) + for mutation in ['unqualified','integrity-hold','wrong-target','missing-file','changed-file','extra-file','missing-identity']: + with self.subTest(mutation=mutation): + changed=json.loads(json.dumps(record));extra=bundle/'extra' + if mutation=='unqualified':changed['local_qualification_complete']=False + elif mutation=='integrity-hold':changed['qualification_integrity_status']='blocked_receipt_mismatch' + elif mutation=='wrong-target':changed['minecraft']='1.17.1' + elif mutation=='missing-identity':del changed['files'][MAIN_JAR] + elif mutation=='missing-file':(bundle/MAIN_JAR).unlink() + elif mutation=='changed-file':(bundle/MAIN_JAR).write_bytes(b'changed') + else:extra.touch() + manifest.write_text(json.dumps(changed)) + with self.assertRaises(ValueError):check_bundle(manifest,bundle) + (bundle/MAIN_JAR).write_bytes(MAIN_JAR.encode()) + if extra.exists():extra.unlink() + def test_hosted_target_contains_no_game_or_sdk_command(self): + from pathlib import Path + import re + text=(Path(__file__).resolve().parents[1]/'.github/workflows/native118.yml').read_text() + commands='\n'.join(re.findall(r'(?m)^ run: \|\n((?: .*\n)+)',text)) + self.assertIn('./gradlew assemble verifyProductionRuntimeClasspath prepareReleaseBundle ',commands) + for forbidden in ('./gradlew test','./gradlew build','./gradlew check','unittest discover','verify_world_upgrades.py','native_runtime.py','verify_packaged','native117_ci.py','integrationTest','--seed-only'): + self.assertNotIn(forbidden,commands) + uploads=re.findall(r'(?m)^ uses: actions/upload-artifact@[^\n]+\n(.*?)(?=^ -|\Z)',text,re.S) + self.assertEqual(2,len(uploads));self.assertTrue(all('if-no-files-found: error' in s for s in uploads)) + independent=(Path(__file__).resolve().parents[1]/'.github/workflows/artifact-audit-1.18.yml').read_text() + self.assertIn('python3 -B tools/native118_build.py --bundle audited',independent) + self.assertIn('run-id: ${{ github.event.workflow_run.id }}',independent) + self.assertIn('if-no-files-found: error',independent) + for forbidden in ('./gradlew','actions/setup-java@','native_runtime.py','verify_packaged'): + self.assertNotIn(forbidden,independent) + +if __name__=='__main__':unittest.main() diff --git a/tools/test_native118_persistence.py b/tools/test_native118_persistence.py new file mode 100644 index 0000000..5ae39d4 --- /dev/null +++ b/tools/test_native118_persistence.py @@ -0,0 +1,66 @@ +"""Synthetic native18 codec shapes; no historical JVM qualification is claimed.""" +from pathlib import Path +import gzip,struct,tempfile,unittest +from nbt_regions import Tag,write_disposable_region,encode,read_region +from native118_persistence import count_persisted +from verify_world_upgrades import patch_stamps + +class Native118PersistenceTest(unittest.TestCase): + def body(self,number=1,marker=8):return Tag(10,{'id':Tag(8,'lootablebodies:corpse'),'LootableBodiesDataVersion':Tag(3,marker),'UUID':Tag(11,struct.pack('>iiii',0,0,0,number))}) + def chunk(self,bodies,version=2975,position=(8,8)): + return {'name':'','root':Tag(10,{'DataVersion':Tag(3,version),'Position':Tag(11,struct.pack('>ii',*position)),'Entities':Tag(9,(10,bodies))}),'compression':2,'timestamp':bytes(4)} + def write(self,world,chunk,dimension=''): + path=world/dimension/'entities/r.0.0.mca';path.parent.mkdir(parents=True,exist_ok=True);write_disposable_region(path,{264:chunk});return path + def test_native18_saved_entities_count_all_dimensions_without_mutation(self): + with tempfile.TemporaryDirectory() as directory: + world=Path(directory);paths=[self.write(world,self.chunk([self.body(i+1)]),dim) for i,dim in enumerate(['','DIM-1','DIM1'])] + before=[p.read_bytes() for p in paths];self.assertEqual(3,count_persisted(world,0));self.assertEqual(before,[p.read_bytes() for p in paths]) + def test_previous_marker_native_version_or_wrong_entity_chunk_position_is_fatal(self): + for marker,version,position in [(7,2975,(8,8)),(8,2730,(8,8)),(8,2975,(9,8))]: + with self.subTest(marker=marker,version=version,position=position),tempfile.TemporaryDirectory() as directory: + world=Path(directory);self.write(world,self.chunk([self.body(marker=marker)],version,position)) + with self.assertRaises(ValueError):count_persisted(world,0) + def test_duplicate_active_id_and_malformed_list_cannot_pass(self): + for duplicate in [True,False]: + with self.subTest(duplicate=duplicate),tempfile.TemporaryDirectory() as directory: + world=Path(directory);chunk=self.chunk([self.body(),self.body()] if duplicate else [self.body()]) + if not duplicate:chunk['root'].value['Entities']=Tag(8,'malformed') + self.write(world,chunk) + with self.assertRaises(ValueError):count_persisted(world,0) + def test_corpses_in_old_or_flattened_terrain_cannot_count_as_saved_native18(self): + for flattened in [False,True]: + with self.subTest(flattened=flattened),tempfile.TemporaryDirectory() as directory: + world=Path(directory);root={'entities':Tag(9,(10,[self.body()]))} if flattened else {'Level':Tag(10,{'Entities':Tag(9,(10,[self.body(1,7)]))})} + p=world/'region/r.0.0.mca';p.parent.mkdir();write_disposable_region(p,{264:{'name':'','root':Tag(10,root),'compression':2,'timestamp':bytes(4)}}) + with self.assertRaises(ValueError):count_persisted(world,0) + def test_stamp_damage_exercises_all_nine_native17_entity_chunks(self): + with tempfile.TemporaryDirectory() as directory: + root=Path(directory);world=root/'owned/world';world.mkdir(parents=True) + (world/'level.dat').write_bytes(gzip.compress(encode('',Tag(10,{'Data':Tag(10,{'DataVersion':Tag(3,2730)})})),mtime=0)) + paths=[] + for dimension,dim in enumerate(['','DIM-1','DIM1']): + for wave,coordinate in enumerate([8,256,512]): + path=world/dim/'entities'/('r.%d.%d.mca'%(coordinate//32,coordinate//32));path.parent.mkdir(parents=True,exist_ok=True) + index=(coordinate&31)+(coordinate&31)*32 + write_disposable_region(path,{index:self.chunk([self.body(wave*3+dimension+1,7)],2730,(coordinate,coordinate))});paths.append(path) + changes=patch_stamps(world,root) + self.assertEqual(list(range(9)),sorted(c['stamp_case'] for c in changes)) + self.assertEqual(4,next(c for c in changes if c['stamp_case']==6)['data_version_type']) + for path in paths: + for chunk in read_region(path).values():self.assertEqual(7,chunk['root'].value['Entities'].value[1][0].value['LootableBodiesDataVersion'].value) + def test_valid_future_entity_fixture_preserves_original_and_rejects_wrong_source(self): + from verify_upgrade_abort import inject_future_entity_version + for version in [2730,2975]: + with self.subTest(version=version),tempfile.TemporaryDirectory() as directory: + home=Path(directory);world=home/'world';path=self.write(world,self.chunk([self.body(marker=7)],version)) + original=path.read_bytes() + if version!=2730: + with self.assertRaises(ValueError):inject_future_entity_version(world) + self.assertEqual(original,path.read_bytes());self.assertFalse((home/'future-entity-original.mca').exists()) + else: + mutation=inject_future_entity_version(world) + self.assertEqual(original,(home/'future-entity-original.mca').read_bytes()) + self.assertEqual(Tag(3,2976),read_region(path)[264]['root'].value['DataVersion']) + self.assertEqual(7,mutation['corpse_marker']) + +if __name__=='__main__':unittest.main() diff --git a/tools/test_native37_platform_config.py b/tools/test_native37_platform_config.py index ed2dca7..f320460 100644 --- a/tools/test_native37_platform_config.py +++ b/tools/test_native37_platform_config.py @@ -1,31 +1,31 @@ import tempfile,unittest from pathlib import Path from native_runtime import fingerprint -from verify_upgrade_abort import allowed_native37_platform_additions +from verify_upgrade_abort import allowed_native40_platform_additions class Native37ConfigTest(unittest.TestCase): def target(self,root,text=None): path=root/'serverconfig/forge-server.toml';path.parent.mkdir() - path.write_bytes(text or (Path(__file__).parent/'fixtures/forge37-server-default.toml').read_bytes());return path + path.write_bytes(text or (Path(__file__).parent/'fixtures/forge40-server-default.toml').read_bytes());return path def old(self):return (Path(__file__).parent/'fixtures/forge36-server-default.toml').read_bytes() def test_exact_actual_native_default_addition(self): with tempfile.TemporaryDirectory() as directory: world=Path(directory);self.target(world);after=fingerprint(world) - self.assertEqual(['serverconfig/forge-server.toml'],allowed_native37_platform_additions({},after,world)) + self.assertEqual(['serverconfig/forge-server.toml'],allowed_native40_platform_additions({},after,world)) def test_exact_actual_native_spec_retirement_preserves_supported_choices(self): with tempfile.TemporaryDirectory() as directory: world=Path(directory);path=self.target(world,self.old().replace(b'fullBoundingBoxLadders = false',b'fullBoundingBoxLadders = true'));original=path.read_bytes();before=fingerprint(world) - path.write_bytes((Path(__file__).parent/'fixtures/forge37-server-default.toml').read_bytes().replace(b'fullBoundingBoxLadders = false',b'fullBoundingBoxLadders = true')) - self.assertEqual(['serverconfig/forge-server.toml'],allowed_native37_platform_additions(before,fingerprint(world),world,original)) + path.write_bytes((Path(__file__).parent/'fixtures/forge40-server-default.toml').read_bytes().replace(b'fullBoundingBoxLadders = false',b'fullBoundingBoxLadders = true')) + self.assertEqual(['serverconfig/forge-server.toml'],allowed_native40_platform_additions(before,fingerprint(world),world,original)) def test_supported_mutation_unknown_retirement_or_wrong_type_cannot_pass(self): for change in ['value','unknown','type']: with self.subTest(change=change),tempfile.TemporaryDirectory() as directory: world=Path(directory);original=self.old() if change=='unknown':original+=b'unknownFlag = true\n' if change=='type':original=original.replace(b'removeErroringEntities = false',b'removeErroringEntities = 0') - path=self.target(world,original);before=fingerprint(world);target=(Path(__file__).parent/'fixtures/forge37-server-default.toml').read_bytes() + path=self.target(world,original);before=fingerprint(world);target=(Path(__file__).parent/'fixtures/forge40-server-default.toml').read_bytes() if change=='value':target=target.replace(b'fullBoundingBoxLadders = false',b'fullBoundingBoxLadders = true') path.write_bytes(target) - self.assertEqual([],allowed_native37_platform_additions(before,fingerprint(world),world,original)) + self.assertEqual([],allowed_native40_platform_additions(before,fingerprint(world),world,original)) if __name__=='__main__':unittest.main() diff --git a/tools/test_native_evidence.py b/tools/test_native_evidence.py index d298728..b11fc34 100644 --- a/tools/test_native_evidence.py +++ b/tools/test_native_evidence.py @@ -7,14 +7,15 @@ class NativeEvidenceTest(unittest.TestCase): def fixture(self,home): phase='upgrade-near';directory=home/(phase+'.diagnostics');directory.mkdir() - (directory/'gc.txt').write_text('synthetic GC fixture') + for name in ['gc.txt','initial-threads.txt','initial-heap-gc.txt']: + (directory/name).write_text('synthetic '+name+' fixture') for name in [phase+'.log',phase+'.latest.log',phase+'.debug.log']: (home/name).write_text('LOOTABLE_BODIES_WORLD_UPGRADE_PASS phase='+phase+'\n') record={'timed_out':False,'returncode':0,'pid':123,'timeout':240,'cwd':str(home), - 'command':['fixture-java','-XX:ActiveProcessorCount=2','-Dlootablebodies.phase='+phase, + 'command':['fixture-java','-Dlootablebodies.phase='+phase, '-Xlog:gc*,safepoint:file=upgrade-near.diagnostics/gc.txt:time,uptime,level,tags', - '@libraries/net/minecraftforge/forge/1.17.1-37.1.1/'+('win_args.txt' if os.name=='nt' else 'unix_args.txt'),'--nogui'], - 'evidence':{'gc.txt':digest(directory/'gc.txt')},'captures':[],'metrics':[]} + '@libraries/net/minecraftforge/forge/1.18.2-40.3.12/'+('win_args.txt' if os.name=='nt' else 'unix_args.txt'),'--nogui'], + 'evidence':{name:digest(directory/name) for name in ['gc.txt','initial-threads.txt','initial-heap-gc.txt']},'captures':[],'metrics':[]} (directory/'result.json').write_text(json.dumps(record));return directory,record def test_complete_receipt_retains_all_logs_and_rehashes_every_native_file(self): with tempfile.TemporaryDirectory() as directory: @@ -24,7 +25,7 @@ def test_complete_receipt_retains_all_logs_and_rehashes_every_native_file(self): self.assertFalse(verify_saved_audit(home,proof)) with self.assertRaises(ValueError):audit_phase(home,'upgrade-near') def test_missing_receipt_log_or_declared_diagnostic_is_fatal(self): - for name in ['upgrade-near.diagnostics/result.json','upgrade-near.debug.log','upgrade-near.diagnostics/gc.txt']: + for name in ['upgrade-near.diagnostics/result.json','upgrade-near.debug.log','upgrade-near.diagnostics/gc.txt','upgrade-near.diagnostics/initial-threads.txt','upgrade-near.diagnostics/initial-heap-gc.txt']: with self.subTest(name=name),tempfile.TemporaryDirectory() as directory: home=Path(directory);self.fixture(home);(home/name).unlink() with self.assertRaises((ValueError,OSError)):audit_phase(home,'upgrade-near') @@ -63,7 +64,7 @@ def test_downloaded_linux_receipt_is_audited_independently_of_auditor_os(self): record['cwd']='/original/runner/build/migration-results/case' record['command']=[part.replace('/win_args.txt','/unix_args.txt') for part in record['command']] (folder/'result.json').write_text(json.dumps(record));audit_phase(home,'upgrade-near',relocated=True) - record['command'].append('@libraries/net/minecraftforge/forge/1.17.1-37.1.1/win_args.txt') + record['command'].append('@libraries/net/minecraftforge/forge/1.18.2-40.3.12/win_args.txt') (folder/'result.json').write_text(json.dumps(record)) with self.assertRaises(ValueError):audit_phase(home,'upgrade-near',relocated=True) @@ -79,4 +80,46 @@ def test_mod_aliases_cannot_hide_an_extra_loader_archive(self): with patch.object(Path,'is_symlink',lambda path:path.name=='unexpected.jar' or original(path)): with self.assertRaises(ValueError):audit_case_mods(home,report) + def test_old_processor_caps_worker_overrides_and_gc_selection_are_fatal(self): + for flag in ['-XX:ActiveProcessorCount=2','-Dmax.bg.threads=2','-Dmax.bg.threads=0','-XX:+UseSerialGC','-XX:+UseG1GC','-XX:ParallelGCThreads=2']: + with self.subTest(flag=flag),tempfile.TemporaryDirectory() as directory: + home=Path(directory);folder,record=self.fixture(home);record['command'].insert(1,flag) + (folder/'result.json').write_text(json.dumps(record)) + with self.assertRaises(ValueError):audit_phase(home,'upgrade-near') + + def test_exact_native40_serial_budget_is_scoped_and_duplicate_overrides_fail(self): + with tempfile.TemporaryDirectory() as directory: + home=Path(directory);folder,record=self.fixture(home) + record['command'].insert(1,'-Dmax.bg.threads=1') + (folder/'result.json').write_text(json.dumps(record)) + audit_phase(home,'upgrade-near') + record['command'].insert(1,'-Dmax.bg.threads=1') + (folder/'result.json').write_text(json.dumps(record)) + with self.assertRaises(ValueError):audit_phase(home,'upgrade-near') + + def test_serial_override_does_not_qualify_an_ordinary_game_receipt(self): + with tempfile.TemporaryDirectory() as directory: + home=Path(directory);folder,record=self.fixture(home) + folder.rename(home/'fresh.diagnostics');folder=home/'fresh.diagnostics' + for suffix in ('log','latest.log','debug.log'): + (home/('upgrade-near.'+suffix)).rename(home/('fresh.'+suffix)) + (home/('fresh.'+suffix)).write_text('LOOTABLE_BODIES_WORLD_UPGRADE_PASS phase=fresh\n') + record['command']=[p.replace('upgrade-near','fresh') for p in record['command']] + (folder/'result.json').write_text(json.dumps(record)) + audit_phase(home,'fresh') + record['command'].insert(1,'-Dmax.bg.threads=1') + (folder/'result.json').write_text(json.dumps(record)) + with self.assertRaises(ValueError):audit_phase(home,'fresh') + + def test_omitting_or_emptying_short_phase_diagnostics_cannot_qualify(self): + for name in ['initial-threads.txt','initial-heap-gc.txt']: + for empty in [False,True]: + with self.subTest(name=name,empty=empty),tempfile.TemporaryDirectory() as directory: + home=Path(directory);folder,record=self.fixture(home) + if empty: + (folder/name).write_bytes(b'');record['evidence'][name]=digest(folder/name) + else:del record['evidence'][name] + (folder/'result.json').write_text(json.dumps(record)) + with self.assertRaises(ValueError):audit_phase(home,'upgrade-near') + if __name__=='__main__':unittest.main() diff --git a/tools/test_native_server_preparation.py b/tools/test_native_server_preparation.py index d8f998e..580fb8f 100644 --- a/tools/test_native_server_preparation.py +++ b/tools/test_native_server_preparation.py @@ -1,36 +1,38 @@ -"""The installer must see exact prepared vanilla bytes before it starts.""" -import hashlib,json,tempfile,unittest +"""Forge owns server downloads; installed bytes are checked before gameplay.""" +import hashlib,tempfile,unittest from pathlib import Path from unittest.mock import patch import native_runtime as runtime -from native117_client import BASE_VERSION,BASE_SHA1 class VanillaServerPreparationTests(unittest.TestCase): - def fixture(self,home,*,changed=False): - payload=b'synthetic official server fixture' - relative='libraries/net/minecraft/server/1.17.1/server-1.17.1.jar' - server={'url':'https://fixture.invalid/server.jar','sha1':hashlib.sha1(payload).hexdigest()} - calls=[] - def download(record,path): - calls.append((record,path));path.parent.mkdir(parents=True,exist_ok=True) - if path.suffix=='.json': - self.assertEqual(BASE_SHA1,record['sha1']) - path.write_text(json.dumps({'id':BASE_VERSION,'downloads':{'server':server}})) - else: - self.assertEqual(home/relative,path);self.assertEqual(server,record) - path.write_bytes(b'changed fixture' if changed else payload) - return relative,payload,calls,download - def test_prepared_server_uses_the_exact_installer_path_and_both_pins(self): + def fixture(self,home,payload=b'exact installed native input'): + relative='libraries/net/minecraft/server/1.18.2/server-1.18.2.jar' + expected=hashlib.sha256(b'exact installed native input').hexdigest().upper() + def installed(command,**options): + self.assertIn('--installServer',command) + self.assertEqual(home,options['cwd']) + self.assertFalse((home/'versions').exists(),'Server path prefetched a separate vanilla descriptor') + target=home/relative;target.parent.mkdir(parents=True);target.write_bytes(payload) + return {relative:expected},installed + def test_installer_owns_server_download_without_separate_vanilla_prefetch(self): with tempfile.TemporaryDirectory() as directory: - home=Path(directory);relative,payload,calls,download=self.fixture(home) - with patch.object(runtime,'_download',side_effect=download),patch.object(runtime,'SERVER_INPUTS',{relative:hashlib.sha256(payload).hexdigest().upper()}): - self.assertEqual(home/relative,runtime.prepare_vanilla_server(home)) - self.assertEqual(2,len(calls));self.assertEqual(payload,(home/relative).read_bytes()) - def test_wrong_vanilla_bytes_refuse_before_the_installer_can_run(self): + home=Path(directory)/'new-sdk';pins,installed=self.fixture(home) + with patch('prepare_migration_inputs.fetch'),patch.object(runtime,'_download') as prefetch,patch.object(runtime,'run_logged',side_effect=installed) as installer,patch.object(runtime,'SERVER_INPUTS',pins): + self.assertEqual(home.resolve(),runtime.install(Path('fixture-java'),home)) + prefetch.assert_not_called();self.assertEqual(1,installer.call_count) + self.assertTrue((home/'runtime-inputs.json').is_file()) + def test_wrong_installed_vanilla_bytes_refuse_before_gameplay_without_installer_retry(self): with tempfile.TemporaryDirectory() as directory: - home=Path(directory)/'new-sdk';relative,payload,calls,download=self.fixture(home,changed=True) - with patch.object(runtime,'_download',side_effect=download),patch.object(runtime,'SERVER_INPUTS',{relative:hashlib.sha256(payload).hexdigest().upper()}),patch('prepare_migration_inputs.fetch'),patch.object(runtime,'run_logged') as installer: - with self.assertRaisesRegex(RuntimeError,'vanilla server checksum mismatch'):runtime.install(Path('fixture-java'),home) - installer.assert_not_called() + home=Path(directory)/'new-sdk';pins,installed=self.fixture(home,b'changed bytes') + with patch('prepare_migration_inputs.fetch'),patch.object(runtime,'_download') as prefetch,patch.object(runtime,'run_logged',side_effect=installed) as installer,patch.object(runtime,'SERVER_INPUTS',pins): + with self.assertRaisesRegex(RuntimeError,'Incorrect official Forge40 server input'):runtime.install(Path('fixture-java'),home) + prefetch.assert_not_called();self.assertEqual(1,installer.call_count) + self.assertFalse((home/'runtime-inputs.json').exists()) + def test_missing_installed_input_is_fatal(self): + with tempfile.TemporaryDirectory() as directory: + home=Path(directory)/'new-sdk';pins,_=self.fixture(home) + with patch('prepare_migration_inputs.fetch'),patch.object(runtime,'_download') as prefetch,patch.object(runtime,'run_logged') as installer,patch.object(runtime,'SERVER_INPUTS',pins): + with self.assertRaises(FileNotFoundError):runtime.install(Path('fixture-java'),home) + prefetch.assert_not_called();self.assertEqual(1,installer.call_count) if __name__=='__main__':unittest.main() diff --git a/tools/test_owned_process.py b/tools/test_owned_process.py index ae05004..1f08701 100644 --- a/tools/test_owned_process.py +++ b/tools/test_owned_process.py @@ -1,12 +1,14 @@ from pathlib import Path import json +import os +import shutil import subprocess import sys import tempfile import time import unittest from unittest.mock import patch -from owned_process import OwnedProcess +from owned_process import OwnedProcess,native_process class OwnedProcessTest(unittest.TestCase): @@ -35,6 +37,36 @@ def test_deadline_failure_still_keeps_a_failed_receipt_and_complete_stdout(self) record=json.loads((root/'home/client.diagnostics/result.json').read_text()) self.assertTrue(record['timed_out']);self.assertNotEqual(0,record['returncode']) self.assertIn('before deadline',(root/'client.log').read_text()) + def test_group_cleanup_reaps_later_children_after_an_already_recorded_deadline(self): + evidence=os.environ.get('LOOTABLEBODIES_UNIT_EVIDENCE') + if evidence:Path(evidence).mkdir(parents=True,exist_ok=True) + root=Path(tempfile.mkdtemp(prefix='owned-group-reap-',dir=evidence));children=[];passed=False + print('OWNED_GROUP_REAP_FIXTURE='+str(root)) + try: + for name in ('expired','other'):(root/name).mkdir() + expired=self.start(root/'expired','import time; print("expired child"); time.sleep(30)',.2);children.append(expired) + other=self.start(root/'other','import time; print("other child"); time.sleep(30)',30);children.append(other) + with self.assertRaises(subprocess.TimeoutExpired):expired.wait(timeout=5) + for child in children: + if child.poll() is None:child.terminate() + # The old cleanup API rethrows the already-recorded first deadline + # here and never joins the later member of the group. + codes=[getattr(child,'reap',child.wait)(timeout=5) for child in children] + self.assertTrue(all(code!=0 for code in codes)) + self.assertTrue(all(child._process.poll() is not None for child in children)) + first=json.loads((root/'expired/home/client.diagnostics/result.json').read_text()) + second=json.loads((root/'other/home/client.diagnostics/result.json').read_text()) + self.assertTrue(first['timed_out']);self.assertEqual(.2,first['timeout']) + self.assertEqual('owned_group_abort',second['stopped_reason']);self.assertFalse(second['timed_out']) + with self.assertRaises(subprocess.TimeoutExpired):expired.wait(timeout=5) + passed=True + finally: + for child in children: + child.terminate() + try:child.wait(timeout=5) + except subprocess.TimeoutExpired:pass + if passed:shutil.rmtree(root) + else:print('RETAINED_FAILED_OWNED_GROUP='+str(root)) def test_console_alias_failure_does_not_leave_an_unowned_running_child(self): with tempfile.TemporaryDirectory() as directory: root=Path(directory) @@ -56,5 +88,38 @@ def test_owned_console_stop_uses_the_same_monitored_child(self): p.stdin.write('stop\n');p.stdin.flush();self.assertEqual(0,p.wait(timeout=5)) self.assertEqual('ready\nstop\n',(root/'server.log').read_text()) + def test_short_process_keeps_initial_evidence_inside_original_deadline(self): + with tempfile.TemporaryDirectory() as directory: + root=Path(directory);home=root/'home';home.mkdir() + def initial(process): + self.assertIsNone(process.poll()) + for name in ['initial-threads.txt','initial-heap-gc.txt']: + (home/'short.diagnostics'/name).write_text('synthetic callback test pid='+str(process.pid)) + process=OwnedProcess([sys.executable,'-u','-c','import time; time.sleep(.1)'],cwd=home, + log=root/'short.log',timeout=5,diagnostic_command=[sys.executable,'-c','print("capture")'],initial_diagnostics=initial) + self.assertEqual(0,process.wait(timeout=5)) + receipt=json.loads((home/'short.diagnostics/result.json').read_text()) + self.assertEqual(5,receipt['timeout']) + for name in ['initial-threads.txt','initial-heap-gc.txt']:self.assertIn(name,receipt['evidence']) + + def test_initial_diagnostic_failure_reaps_runtime_and_preserves_failure(self): + with tempfile.TemporaryDirectory() as directory: + root=Path(directory);home=root/'home';home.mkdir() + def initial(process):raise RuntimeError('injected initial diagnostic failure') + process=OwnedProcess([sys.executable,'-u','-c','import time; time.sleep(30)'],cwd=home, + log=root/'failure.log',timeout=5,diagnostic_command=[sys.executable,'-c','print("capture")'],initial_diagnostics=initial) + with self.assertRaisesRegex(RuntimeError,'initial diagnostic'):process.wait(timeout=5) + receipt=json.loads((home/'failure.diagnostics/result.json').read_text()) + self.assertIsNotNone(receipt['returncode']);self.assertNotEqual(0,receipt['returncode']);self.assertFalse(receipt['timed_out']) + + def test_previous_runtime_and_wrong_patch_refuse_before_group_launch(self): + for version,vendor in [('16.0.2+7','Eclipse Foundation'),('17.0.20+8','Eclipse Adoptium')]: + with self.subTest(version=version),tempfile.TemporaryDirectory() as directory: + root=Path(directory);java=root/'jdk/bin/java.exe';java.parent.mkdir(parents=True) + (java.parent.parent/'release').write_text('SEMANTIC_VERSION="'+version+'"\nIMPLEMENTOR="'+vendor+'"\n') + with patch('owned_process.OwnedProcess') as launch,self.assertRaisesRegex(RuntimeError,'17.0.20.1'): + native_process([str(java)],cwd=root,log=root/'runtime.log',timeout=5) + launch.assert_not_called() + if __name__=='__main__':unittest.main() diff --git a/tools/test_prepared_worlds.py b/tools/test_prepared_worlds.py index 59ecf63..5600c2a 100644 --- a/tools/test_prepared_worlds.py +++ b/tools/test_prepared_worlds.py @@ -11,7 +11,7 @@ def fixture(self,root): records={name:{'world':str(world.resolve()),'files':fingerprint(world),'minecraft':mc,'mod_version':version,'jar_sha256':sha} for name,(mc,version,sha) in expected.items()} second=root/'second';second.mkdir();(second/'level.dat').write_bytes(b'separate synthetic source') records['two'].update(world=str(second.resolve()),files=fingerprint(second)) - path=root/'inputs.json';record={'schema':1,'target_data_version':2730,'origins':records};path.write_text(json.dumps(record)) + path=root/'inputs.json';record={'schema':1,'target_data_version':2975,'origins':records};path.write_text(json.dumps(record)) return path,record,expected,world def test_exact_subset_reuses_original_paths_and_seals(self): with tempfile.TemporaryDirectory() as directory: @@ -23,7 +23,7 @@ def test_missing_extra_or_wrong_target_manifest_is_fatal(self): path,record,expected,world=self.fixture(Path(directory)) if change=='missing':record['origins'].pop('two') elif change=='extra':record['origins']['extra']=record['origins']['one'] - else:record['target_data_version']=2586 + else:record['target_data_version']=2730 path.write_text(json.dumps(record)) with self.assertRaises(ValueError):load(path,expected,['one']) def test_missing_added_or_changed_source_bytes_are_fatal(self): diff --git a/tools/test_runtime_diagnostics.py b/tools/test_runtime_diagnostics.py index fc4e9aa..d956d9c 100644 --- a/tools/test_runtime_diagnostics.py +++ b/tools/test_runtime_diagnostics.py @@ -6,6 +6,7 @@ import tempfile import time import unittest +import os,shutil from unittest.mock import patch from runtime_diagnostics import run_monitored @@ -44,22 +45,30 @@ def replace(path, destination): process.wait() def run_child(self, home, code, *, timeout=1.2, collector=None, metric=None): - return run_monitored( - [sys.executable, '-u', '-c', code], cwd=home, - log=home/'phase.log', timeout=timeout, - diagnostics=home/'phase.diagnostics', - diagnostic_command=collector or [sys.executable, '-c', - 'import sys; print("THREAD_CAPTURE_TARGET="+sys.argv[1], flush=True)'], - metric_command=metric) + try: + return run_monitored( + [sys.executable, '-u', '-c', code], cwd=home, + log=home/'phase.log', timeout=timeout, + diagnostics=home/'phase.diagnostics', + diagnostic_command=collector or [sys.executable, '-c', + 'import sys; print("THREAD_CAPTURE_TARGET="+sys.argv[1], flush=True)'], + metric_command=metric) + except BaseException: + if os.environ.get('LOOTABLEBODIES_UNIT_EVIDENCE'): + shutil.copytree(home,Path(os.environ['LOOTABLEBODIES_UNIT_EVIDENCE'])/self._testMethodName) + raise def test_heap_metrics_are_captured_even_if_a_gc_log_has_no_events(self): with tempfile.TemporaryDirectory() as directory: home = Path(directory) with self.assertRaises(subprocess.TimeoutExpired): self.run_child(home, 'import time; time.sleep(30)', metric=[ - sys.executable, '-c', 'from pathlib import Path; import sys,time; ' - 'seen=Path("heap-seen"); time.sleep(30) if seen.exists() else None; ' - 'seen.touch(); print("HEAP_TARGET="+sys.argv[1],flush=True)']) + sys.executable, '-S', '-c', 'import os,sys,time; ' + 'time.sleep(30) if os.path.exists("heap-seen") else None; ' + 'open("heap-seen","w").close(); print("HEAP_TARGET="+sys.argv[1],flush=True); os._exit(0)']) + # The first fake collector's only task is its flushed output. Avoid + # unrelated interpreter shutdown consuming its original0.24s budget. + # Later collectors still really block and must be killed on time. report = json.loads((home/'phase.diagnostics/result.json').read_text()) self.assertTrue(report['metrics']) self.assertTrue(any(m['returncode']==0 for m in report['metrics'])) @@ -100,11 +109,23 @@ def test_live_evidence_survives_timeout_even_with_a_pass_marker(self): self.assertTrue(report['timed_out']) self.assertIsNotNone(report['returncode']) self.assertTrue(report['captures']) + # Keep the complete negative fixture when requested. A collector + # launched at93.75% can legitimately be stopped by the SAME fixed + # deadline on a busy machine; that must remain a failed attempt. + if os.environ.get('LOOTABLEBODIES_UNIT_EVIDENCE'): + target=Path(os.environ['LOOTABLEBODIES_UNIT_EVIDENCE'])/'premature-marker-timeout' + shutil.copytree(home,target) + self.assertTrue(any(c['returncode']==0 for c in report['captures'])) for capture in report['captures']: self.assertLess(capture['started_seconds'], 1.2) - self.assertEqual(0, capture['returncode']) - self.assertIn('THREAD_CAPTURE_TARGET='+str(report['pid']), - (home/'phase.diagnostics'/capture['file']).read_text()) + if capture['returncode']==0: + self.assertIn('THREAD_CAPTURE_TARGET='+str(report['pid']), + (home/'phase.diagnostics'/capture['file']).read_text()) + else: + self.assertTrue(capture['timed_out']) + self.assertIn(capture['stopped_reason'],('runtime_deadline','capture_deadline')) + self.assertIsNotNone(capture['returncode']) + self.assertIn(capture['file'],report['evidence']) self.assertIn('STARTED', (home/'phase.log').read_text()) def test_a_stuck_collector_cannot_extend_the_runtime_or_survive_it(self): @@ -127,11 +148,10 @@ def test_failed_attach_is_retained_and_does_not_mask_the_runtime_timeout(self): home = Path(directory) with self.assertRaises(subprocess.TimeoutExpired): self.run_child(home, 'import time; time.sleep(30)', - collector=[sys.executable, '-c', - 'from pathlib import Path; import sys,time; ' - 'seen=Path("attach-seen"); ' - 'time.sleep(30) if seen.exists() else None; ' - 'seen.touch(); print("attach refused", flush=True); sys.exit(9)']) + collector=[sys.executable, '-S', '-c', + 'import os,sys,time; ' + 'time.sleep(30) if os.path.exists("attach-seen") else None; ' + 'open("attach-seen","w").close(); print("attach refused", flush=True); os._exit(9)']) report = json.loads((home/'phase.diagnostics/result.json').read_text()) self.assertTrue(report['timed_out']) self.assertTrue(any(c['returncode'] == 9 for c in report['captures'])) @@ -141,8 +161,9 @@ def test_failed_attach_is_retained_and_does_not_mask_the_runtime_timeout(self): self.assertTrue(all(c['returncode'] == 9 or (c['timed_out'] and c['returncode'] is not None) for c in report['captures'])) - self.assertIn('attach refused', - (home/'phase.diagnostics'/report['captures'][0]['file']).read_text()) + for capture in report['captures']: + if capture['returncode']==9: + self.assertIn('attach refused',(home/'phase.diagnostics'/capture['file']).read_text()) def test_nonzero_exit_preserves_complete_output_without_a_retry(self): with tempfile.TemporaryDirectory() as directory: diff --git a/tools/verify_multiplayer.py b/tools/verify_multiplayer.py index 87bdedd..659266c 100644 --- a/tools/verify_multiplayer.py +++ b/tools/verify_multiplayer.py @@ -58,7 +58,7 @@ def main(): (server/'server.properties').write_text(properties+'view-distance=4\n') commands={'server':[str(java),'-Xmx1G','-Dlootablebodies.multiplayer.server=true', '-Dlog4j.configurationFile='+(project/'src/integrationTest/resources/log4j2.xml').as_uri(), - '-Dlootablebodies.multiplayer.sync='+str(shared),'@libraries/net/minecraftforge/forge/1.17.1-37.1.1/'+('win_args.txt' if os.name=='nt' else 'unix_args.txt'),'--nogui']} + '-Dlootablebodies.multiplayer.sync='+str(shared),'@libraries/net/minecraftforge/forge/'+FORGE.removeprefix('forge-')+'/'+('win_args.txt' if os.name=='nt' else 'unix_args.txt'),'--nogui']} for role in ('A','B'): commands['client'+role]=client_command(project,java,args.forge_client_home.resolve(),root/('client'+role),None,shared,port,role,args.hidden_agent,prepared=args.client_inputs.resolve(),assets=args.assets.resolve() if args.assets else None) processes={};logs=[] diff --git a/tools/verify_native_respawn_control.py b/tools/verify_native_respawn_control.py index 2b4ee5d..28493e0 100644 --- a/tools/verify_native_respawn_control.py +++ b/tools/verify_native_respawn_control.py @@ -1,4 +1,4 @@ -"""Exercise native37 delayed respawns/FIFO packets without Lootable Bodies.""" +"""Exercise native40 delayed respawns/FIFO packets without Lootable Bodies.""" import argparse,json,socket,subprocess,time,os,re from pathlib import Path from audit_runtime_logs import audit, forge36_dead_player_movement_blocks @@ -43,7 +43,7 @@ def main(): commands={'server':[str(args.java.resolve()),'-Xmx1G','-Dlootablebodies.control.tcpServer=true', '-Dlootablebodies.control.deaths=true', '-Dlog4j.configurationFile='+(project/'src/integrationTest/resources/log4j2.xml').as_uri(), - '@libraries/net/minecraftforge/forge/1.17.1-37.1.1/'+('win_args.txt' if os.name=='nt' else 'unix_args.txt'),'--nogui'], + '@libraries/net/minecraftforge/forge/'+FORGE.removeprefix('forge-')+'/'+('win_args.txt' if os.name=='nt' else 'unix_args.txt'),'--nogui'], 'client':client_command(args.java.resolve(),root/'client',args.forge_client_home.resolve(), args.assets.resolve() if args.assets else project_assets(project),username='ControlOnly',extra=extra,prepared=args.client_inputs.resolve())} processes={};logs=[] @@ -77,9 +77,9 @@ def start(name): server=(root/'server.log').read_text(errors='replace') batches=[int(value) for value in re.findall(r'NATIVE_FIFO_DELAY_RELEASE packets=([0-9]+)',client)] if server.count('NATIVE_DEATH ')!=8 or len(batches)!=8 or sum(batches)<=0: - raise RuntimeError('Native37 control did not exercise all eight real delayed respawn/FIFO actions') - result={'lootable_bodies_installed':False,'respawns':8,'native37_delayed_fifo_batches':batches, - 'legacy36_error_matcher_used':False,'native37_complete_logs_clean':True, + raise RuntimeError('Native40 control did not exercise all eight real delayed respawn/FIFO actions') + result={'lootable_bodies_installed':False,'respawns':8,'native40_delayed_fifo_batches':batches, + 'legacy36_error_matcher_used':False,'native40_complete_logs_clean':True, 'control_jar_sha256':digest(probe),'native_outbound_packets_delayed_fifo':True,'log_audits':reports} (root/'result.json').write_text(json.dumps(result,indent=2)+'\n') print('NATIVE_RESPAWN_CONTROL_PASS',json.dumps(result),flush=True) diff --git a/tools/verify_nbt_recovery.py b/tools/verify_nbt_recovery.py index 81e6a0a..9993b3b 100644 --- a/tools/verify_nbt_recovery.py +++ b/tools/verify_nbt_recovery.py @@ -17,12 +17,16 @@ def main(): run(java,seed,'nbt-seed');sealed=fingerprint(seed/'world') home=root/'candidate';prepare(home,distribution,main,probe);shutil.copytree(seed/'world',home/'world') far=home/'world/region/r.8.8.mca';deferred=digest(far) - run(java,home,'nbt-reload');assert digest(far)==deferred,'Unopened malformed region changed' + entity_far=home/'world/entities/r.8.8.mca' + if not entity_far.is_file():raise FileNotFoundError('Missing native18 deferred malformed entity region') + deferred_entities=digest(entity_far) + run(java,home,'nbt-reload');assert digest(far)==deferred and digest(entity_far)==deferred_entities,'Unopened malformed terrain/entity region changed' for phase in ('nbt-deferred','nbt-deferred-repeat','nbt-return','nbt-return-reload'):run(java,home,phase) expected=['nbt-reload','nbt-deferred','nbt-deferred-repeat','nbt-return','nbt-return-reload'] assert (home/'world/nbt-world-checks.txt').read_text().splitlines()==['PASS '+s+' exact_items_raw_records_identity' for s in expected] assert fingerprint(seed/'world')==sealed and digest(baseline)==original and digest(main)==candidate,'A sealed source/candidate changed' report={'candidate_sha256':candidate,'baseline_sha256':original,'sealed_world_unchanged':True,'unopened_malformed_region_unchanged':deferred,'checks':expected,'raw_records_per_corpse_before_return':5,'raw_records_per_corpse_after_return':4,'raw_scalar_records_per_corpse':4,'raw_health_attribute_records_per_corpse':5,'raw_effect_hurt_records_per_corpse':3,'raw_geometry_records_per_corpse':2,'raw_rotation_fall_records_per_corpse':2,'raw_metadata_records_per_corpse':16,'source_world':sealed} + report['unopened_malformed_entity_region_unchanged']=deferred_entities (root/'result.json').write_text(json.dumps(report,indent=2)+'\n');print('PACKAGED_NBT_RECOVERY_PASS',flush=True) if __name__=='__main__':main() diff --git a/tools/verify_normal_server.py b/tools/verify_normal_server.py index 7afc9ba..021c742 100644 --- a/tools/verify_normal_server.py +++ b/tools/verify_normal_server.py @@ -43,7 +43,7 @@ def main(): prepare(home,args.forge_home.resolve(),jar) command=[str(args.java.resolve()),'-Xmx1G', '-Dlog4j.configurationFile='+(project/'src/integrationTest/resources/log4j2.xml').as_uri(), - '@libraries/net/minecraftforge/forge/1.17.1-37.1.1/'+('win_args.txt' if os.name=='nt' else 'unix_args.txt'),'--nogui'];cwd=home + '@libraries/net/minecraftforge/forge/'+FORGE.removeprefix('forge-')+'/'+('win_args.txt' if os.name=='nt' else 'unix_args.txt'),'--nogui'];cwd=home seed_flat_fixture(home,args.flat_header,args.flat_header_sha256) results=[] for phase in ['fresh','reload']: @@ -72,7 +72,7 @@ def main(): remaining=whole_deadline-time.monotonic() if remaining<=0:raise subprocess.TimeoutExpired(command,210) receipt=subprocess.run([str(binary),option,str(pid)],stdout=stream,stderr=subprocess.STDOUT,timeout=min(10,remaining)) - if receipt.returncode or not target.stat().st_size:raise RuntimeError('Actual native16 diagnostic failed: '+str(target)) + if receipt.returncode or not target.stat().st_size:raise RuntimeError('Actual native17 diagnostic failed: '+str(target)) diagnostics[target.name]=digest(target) process.stdin.write('stop\n');process.stdin.flush() remaining=whole_deadline-time.monotonic() diff --git a/tools/verify_packaged.py b/tools/verify_packaged.py index db4f628..4bd59cf 100644 --- a/tools/verify_packaged.py +++ b/tools/verify_packaged.py @@ -1,4 +1,4 @@ -"""Verify the exact production jar in disposable native Forge 37 servers.""" +"""Verify the exact production jar in disposable native Forge 40 servers.""" import argparse import json from pathlib import Path diff --git a/tools/verify_packaged_client.py b/tools/verify_packaged_client.py index 5cfe61d..e9cf3c4 100644 --- a/tools/verify_packaged_client.py +++ b/tools/verify_packaged_client.py @@ -1,4 +1,4 @@ -"""Run the exact production jar and isolated verifier in official Forge 37.""" +"""Run the exact production jar and isolated verifier in official Forge 40.""" import argparse import hashlib import json @@ -8,18 +8,24 @@ from audit_runtime_logs import audit from native_runtime import project_assets,prepare_client,client_command,fingerprint from release_identity import MAIN_JAR -from runtime_diagnostics import run_monitored +from runtime_diagnostics import run_monitored,validate_logging_inputs def run_client(command,root,label,timeout): + validate_logging_inputs(command) diagnostics=root/(label+'.diagnostics') gc='-Xlog:gc*,safepoint:file='+label+'.diagnostics/gc.txt:time,uptime,level,tags' launch=[command[0],gc,*command[1:]] java=Path(command[0]);suffix='.exe' if java.suffix=='.exe' else '' + def initial_diagnostics(process): + for name,tool,option in [('initial-threads.txt','jstack','-l'),('initial-heap-gc.txt','jstat','-gc')]: + with (diagnostics/name).open('x',encoding='utf-8') as output: + subprocess.run([str(java.with_name(tool+suffix)),option,str(process.pid)],stdout=output, + stderr=subprocess.STDOUT,timeout=8,check=True) try: return run_monitored(launch,cwd=root,log=root/(label+'.log'),timeout=timeout, diagnostics=diagnostics,diagnostic_command=[str(java.with_name('jstack'+suffix)),'-l'], - metric_command=[str(java.with_name('jstat'+suffix)),'-gc']) + metric_command=[str(java.with_name('jstat'+suffix)),'-gc'],process_started=initial_diagnostics) finally: for name in ('latest.log','debug.log'): log=root/'logs'/name @@ -36,7 +42,8 @@ def run_default_phases(command,root): """Continue the same saved world at the connected/drop boundary. Both processes keep the original 180s bound. The first complete log set - and images are retained before the second process can replace live files. + and images, plus the actual saved world bytes, are retained before the + second process can replace live files. A hash list cannot recover bytes. """ first=[command[0],'-Dlootablebodies.clientStopBeforeDrops=true',*command[1:]] result=run_client(first,root,'client',180) @@ -50,7 +57,11 @@ def run_default_phases(command,root): if not images.is_dir() or not list(images.glob('*.png')): raise RuntimeError('Missing first graphical phase images') shutil.copytree(images,root/'connected-screenshots') + frozen=root/'connected-world' + shutil.copytree(world,frozen) + if fingerprint(frozen)!=sealed:raise RuntimeError('Incomplete or damaged frozen graphical world') (root/'client-handoff.json').write_text(json.dumps({'saved_world':sealed, + 'frozen_world':frozen.name, 'first_log_sha256':hashlib.sha256(console.read_bytes()).hexdigest().upper(), 'process_deadlines':[180,180],'regenerated_world':False},indent=2)+'\n') if fingerprint(world)!=sealed:raise RuntimeError('World changed before continuation') diff --git a/tools/verify_upgrade_abort.py b/tools/verify_upgrade_abort.py index 40b9771..c8a4a15 100644 --- a/tools/verify_upgrade_abort.py +++ b/tools/verify_upgrade_abort.py @@ -74,19 +74,19 @@ def allowed_native_session_lock(before, after, world, original=None): return [name] if actual==marker+original[3:] else [] -def allowed_native37_platform_additions(before,after,world,original_config=None): - """Exact pinned Forge37 spec corrections, proved with the actual native spec.""" +def allowed_native40_platform_additions(before,after,world,original_config=None): + """Exact pinned Forge40 spec corrections, proved with the actual native spec.""" name='serverconfig/forge-server.toml' if name not in after:return [] actual=(world/name).read_bytes() if digest(world/name)!=after[name]:return [] try: - defaults=tomllib.loads((Path(__file__).parent/'fixtures/forge37-server-default.toml').read_text()) + defaults=tomllib.loads((Path(__file__).parent/'fixtures/forge40-server-default.toml').read_text()) upgraded=tomllib.loads(actual.decode('utf-8')) if name not in before:return [name] if same_typed_values(defaults,upgraded) else [] if original_config is None or before[name]!=__import__('hashlib').sha256(original_config).hexdigest().upper():return [] previous=tomllib.loads(original_config.decode('utf-8'));server=previous['server'] - retired={'removeErroringEntities':bool,'removeErroringTileEntities':bool, + retired={'removeErroringTileEntities':bool, 'logCascadingWorldGeneration':bool,'fixVanillaCascading':bool, 'dimensionUnloadQueueDelay':int,'clumpingThreshold':int} for key,kind in retired.items(): @@ -122,6 +122,32 @@ def inject_unsafe_journal(world, case): journal.write_bytes(b'cannot create a journal directory here') +def inject_future_entity_version(world): + """One copied genuine public17 chunk, valid future Int; never touch source.""" + from nbt_regions import Tag,read_region,write_disposable_region + import struct + path=world/'entities/r.0.0.mca' + chunks=read_region(path) + candidates=[] + for index,chunk in chunks.items(): + root=chunk['root'].value + entries=root.get('Entities') + if entries is None or entries.kind!=9 or entries.value[0] not in (0,10):raise ValueError('Malformed genuine entity list') + bodies=[e for e in entries.value[1] if e.value.get('id',Tag(8,'')).value=='lootablebodies:corpse'] + if bodies:candidates.append((index,root,bodies)) + if len(candidates)!=1:raise ValueError('Expected one genuine near public17 corpse chunk') + index,root,bodies=candidates[0] + if root.get('DataVersion')!=Tag(3,2730) or any(e.value.get('LootableBodiesDataVersion')!=Tag(3,7) for e in bodies): + raise ValueError('Future-version control requires genuine native17 writer data') + before=digest(path);original=world.parent/'future-entity-original.mca' + if original.exists():raise ValueError('Future fixture original already exists') + shutil.copy2(path,original) + root['DataVersion']=Tag(3,2976);write_disposable_region(path,chunks) + return {'region':path.relative_to(world).as_posix(),'chunk_index':index,'source_version':2730, + 'injected_version':2976,'nbt_type':'Int','original_sha256':before,'mutated_sha256':digest(path), + 'original_copy':str(original),'corpse_marker':7} + + def run_abort(java, home): from native_runtime import run_java, HISTORICAL_JVM_ARGS logging=Path(__file__).resolve().parents[1]/'src/integrationTest/resources/log4j2.xml' @@ -136,6 +162,8 @@ def main(): parser.add_argument('--forge-home', type=Path, required=True) parser.add_argument('--source-world', type=Path, required=True) parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--conditions',nargs='+',choices=['damaged-index','blocked-journal','future-entity-version'], + default=['damaged-index','blocked-journal']) args = parser.parse_args() project = Path(__file__).resolve().parents[1] candidate = project / 'build/libs' / MAIN_JAR @@ -145,12 +173,14 @@ def main(): root = args.output.resolve() root.mkdir(parents=True, exist_ok=False) report = {'candidate_sha256': digest(candidate), 'cases': {}} - for case in ['damaged-index', 'blocked-journal']: + if len(set(args.conditions))!=len(args.conditions):raise ValueError('Duplicate refusal condition') + for case in args.conditions: home = root / case prepare(home, args.forge_home.resolve(), candidate, probe) shutil.copytree(source, home / 'world') world = home / 'world' - inject_unsafe_journal(world, case) + mutation=inject_future_entity_version(world) if case=='future-entity-version' else None + if mutation is None:inject_unsafe_journal(world, case) before = source_files(world) config = world/'serverconfig/forge-server.toml' original_config = config.read_bytes() if config.is_file() else None @@ -160,12 +190,14 @@ def main(): text = log.read_text(encoding='utf-8', errors='replace') if "Lootable Bodies could not preserve this world's corpses" not in text: raise RuntimeError('Missing explicit preservation abort: ' + str(log)) + if mutation and 'Saved entity chunk comes from a newer Minecraft version' not in text: + raise RuntimeError('Missing actual typed future entity-version refusal') if any(token in text for token in ['Preparing start region', 'Preparing spawn area', 'LOOTABLE_BODIES_RUNTIME_PASS', 'Done (']): raise RuntimeError('World loading continued after unsafe preservation') after = source_files(world) raw_after = dict(after) - platform_additions = allowed_native37_platform_additions(before, after, world, original_config) + platform_additions = allowed_native40_platform_additions(before, after, world, original_config) lock_updates=allowed_native_session_lock(before,after,world,original_lock) for name in platform_additions+lock_updates: if name in before: after[name] = before[name] @@ -191,6 +223,7 @@ def main(): 'stock_forge_config_created': [name for name in platform_additions if name not in before], 'stock_forge_config_updated': [name for name in platform_additions if name in before]} report['cases'][case]['native_session_lock_updates']=lock_updates + if mutation:report['cases'][case]['fixture_mutation']=mutation print('UPGRADE_ABORT_PASS', case, flush=True) (root / 'result.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') diff --git a/tools/verify_visibility.py b/tools/verify_visibility.py index 7e6a4c7..fcccffa 100644 --- a/tools/verify_visibility.py +++ b/tools/verify_visibility.py @@ -70,7 +70,7 @@ def main(): commands={'server':[str(java),'-Xmx1G','-Dlootablebodies.'+args.mode+'=true', '-Dlog4j.configurationFile='+(project/'src/integrationTest/resources/log4j2.xml').as_uri(), '-Dlootablebodies.multiplayer.sync='+str(shared),'-Dlootablebodies.respawn.cases='+str(args.cases), - '@libraries/net/minecraftforge/forge/1.17.1-37.1.1/'+('win_args.txt' if os.name=='nt' else 'unix_args.txt'),'--nogui']} + '@libraries/net/minecraftforge/forge/'+FORGE.removeprefix('forge-')+'/'+('win_args.txt' if os.name=='nt' else 'unix_args.txt'),'--nogui']} if args.source_server: commands['server'].insert(2,'-Dlootablebodies.visibility.reload=true') if args.simultaneous: commands['server'].insert(2,'-Dlootablebodies.respawn.simultaneous=true') for role in ['A','B']: @@ -104,8 +104,8 @@ def start(name): for process in processes.values(): if process.poll() is None: process.terminate() for process in processes.values(): - try: process.wait(timeout=10) - except subprocess.TimeoutExpired: process.kill();process.wait() + try: process.reap(timeout=10) + except subprocess.TimeoutExpired: process.kill();process.reap(timeout=10) for log in logs: log.close() if not (shared/(args.mode+'.pass')).is_file(): raise RuntimeError('Missing server result') write_receipts(home) diff --git a/tools/verify_world_upgrades.py b/tools/verify_world_upgrades.py index b26fce6..1ed11df 100644 --- a/tools/verify_world_upgrades.py +++ b/tools/verify_world_upgrades.py @@ -55,6 +55,17 @@ 'via116-114': ORIGINS['released-114'], 'via116-115': ORIGINS['released-115'], }) +ORIGINS.update({ + 'released-117': ('1.17.1','2.4.8.117011','854A46D80B8599966E43B3EAED407CD1A99FE4FB019AEE9138FE2F98E8DDA9FA'), + 'recovery-117': ('1.17.1','2.4.8.117011','854A46D80B8599966E43B3EAED407CD1A99FE4FB019AEE9138FE2F98E8DDA9FA'), + 'via117-110': ORIGINS['original-110'], + 'via117-111': ORIGINS['released-111'], + 'via117-112': ORIGINS['released-112'], + 'via117-113': ORIGINS['released-113'], + 'via117-114': ORIGINS['released-114'], + 'via117-115': ORIGINS['released-115'], + 'via117-116': ORIGINS['released-116'], +}) FORGE = {'1.12.2': 'forge-1.12.2-14.23.5.2859','1.10.2': 'forge-1.10.2-12.18.3.2511', '1.11.2': 'forge-1.11.2-13.20.1.2588'} FORGE_SHA = {'1.12.2': 'BD8314729D787E35164476D75AFB337AFB9783917E4F6FA1F77F049ADBA22F2D','1.10.2': '809C2EA5B4D4F3E5A294D3A326744F20FEC412A1263E708297D00F512E377542', '1.11.2': 'B6DFF35B5CCB0E6353FB89B5330FE930DB8FBE8B63787C9FC2F70196BD108998'} @@ -176,7 +187,7 @@ def main(): parser.add_argument('--forge114-home', type=Path) parser.add_argument('--forge115-home', type=Path) parser.add_argument('--forge116-home', type=Path) - parser.add_argument('--forge117-home', type=Path) + parser.add_argument('--forge-home','--forge117-home',dest='forge_home',type=Path) for origin in ORIGINS: if origin.startswith('recovery-') or origin.startswith('via'): continue @@ -185,7 +196,7 @@ def main(): parser.add_argument('--output', type=Path, required=True) parser.add_argument('--seed-only', action='store_true') parser.add_argument('--seeds', nargs='+', type=Path) - parser.add_argument('--prepared-sources', type=Path, help='Exact sealed38-origin paths; reuse original writer worlds') + parser.add_argument('--prepared-sources', type=Path, help='Exact sealed47-origin paths; reuse original writer worlds') parser.add_argument('--variants', nargs='+', choices=['normal', 'stamps'], default=['normal', 'stamps']) parser.add_argument('--control-report', type=Path) args = parser.parse_args() @@ -473,18 +484,26 @@ def patch_stamps(world, disposable): if not world.resolve().is_relative_to(disposable.resolve()): raise RuntimeError('Fixture mutation outside disposable output') changes = [] - for path in sorted(world.rglob('region/*.mca')): + for path in sorted(p for p in world.rglob('*.mca') if p.parent.name in ('region','entities')): chunks = read_region(path) changed = False for chunk in chunks.values(): root = chunk['root'].value - level = root['Level'].value - bodies = [e for e in level.get('Entities', Tag(9, (10, []))).value[1] + if path.parent.name=='entities': + position=root.get('Position') + if position is None or position.kind!=11 or len(position.value)!=8:raise ValueError('Malformed source entity-chunk Position') + x,z=struct.unpack('>ii',position.value);entries=root.get('Entities',Tag(9,(10,[]))) + else: + level=root['Level'].value if 'Level' in root else root + x,z=level['xPos'].value,level['zPos'].value;entries=level.get('Entities',level.get('entities',Tag(9,(10,[])))) + if entries.kind!=9 or entries.value[0] not in (0,10):raise ValueError('Malformed source entities list') + bodies = [e for e in entries.value[1] if e.value.get('id', Tag(8, '')).value in ('lootablebodies.Corpse', 'lootablebodies:corpse')] if not bodies: continue dimension = -1 if 'DIM-1' in path.parts else 1 if 'DIM1' in path.parts else 0 - wave = 0 if level['xPos'].value == 8 else 1 if level['xPos'].value == 256 else 2 + if x not in (8,256,512) or z!=x:raise ValueError('Unexpected historical corpse chunk') + wave = (8,256,512).index(x) kind = wave * 3 + {0: 0, -1: 1, 1: 2}[dimension] root.pop('DataVersion', None) root.pop('ForgeDataVersion', None) @@ -500,15 +519,22 @@ def patch_stamps(world, disposable): root['DataVersion'] = Tag(3, 1343) root['ForgeDataVersion'] = Tag(10, {'minecraft': Tag(3, 1343), 'lootablebodies': Tag(3, 2)}) elif kind == 6: - root['DataVersion'] = Tag(3, 2147483647) + # Native17 separate entity chunks have an unambiguous typed + # version boundary. A valid future Int must refuse before world + # construction; that negative is exercised separately. This + # positive variant exercises a malformed Long stamp instead. + root['DataVersion'] = Tag(4 if path.parent.name=='entities' else 3, 2147483647) elif kind == 7: root['DataVersion'] = Tag(3, 0) elif kind == 8: root['DataVersion'] = Tag(2, 512) changed = True - changes.append({'region': path.relative_to(world).as_posix(), 'chunk': [level['xPos'].value, level['zPos'].value], 'stamp_case': kind}) + changes.append({'region': path.relative_to(world).as_posix(), 'chunk': [x,z], 'stamp_case': kind, + 'data_version_type':root['DataVersion'].kind if 'DataVersion' in root else None}) if changed: write_disposable_region(path, chunks) + if sorted(change['stamp_case'] for change in changes)!=list(range(9)): + raise ValueError('Missing or duplicate historical stamp cases') path = world / 'level.dat' name, level = decode(gzip.decompress(path.read_bytes())) level.value['Data'].value['DataVersion'] = Tag(8, 'damaged world stamp') @@ -535,8 +561,8 @@ def verify(args, project): if not args.seeds and not prepared: raise ValueError('--prepared-sources or --seeds is required for the upgrade matrix') if prepared and args.seeds:raise ValueError('Choose one unambiguous source route') - if not args.forge117_home: - raise ValueError('--forge117-home is required for the native17 upgrade matrix') + if not args.forge_home: + raise ValueError('--forge-home is required for the native18 upgrade matrix') root = args.output.resolve() root.mkdir(parents=True, exist_ok=False) candidate = project / 'build/libs' / MAIN_JAR @@ -571,7 +597,7 @@ def run_origin(origin): name = origin + '-' + variant home = root / name from native_runtime import prepare_server - prepare_server(home,args.forge117_home.resolve(),candidate,probe) + prepare_server(home,args.forge_home.resolve(),candidate,probe) shutil.copytree(source, home / 'world') world = home / 'world' historical_markers=preserve_historical_markers(home) @@ -580,15 +606,15 @@ def run_origin(origin): raise RuntimeError('Expected exactly one sealed fixture player') shutil.copy2(player_files[0], world / 'upgrade-player-source.nbt') changes = patch_stamps(world, root) if variant == 'stamps' else [] - distant = {str(path.relative_to(world)): digest(path) for path in world.rglob('region/r.*.mca') - if path.name in ('r.8.8.mca', 'r.16.16.mca')} - if len(distant) != 6: + distant = {str(path.relative_to(world)): digest(path) for path in world.rglob('*.mca') + if path.parent.name in ('region','entities') and path.name in ('r.8.8.mca', 'r.16.16.mca')} + if sum('region' in Path(name).parts for name in distant)!=6: raise RuntimeError('Missing distant region fixtures') phases = [] for phase in PHASES: run_upgrade(args.java.resolve(), home, phase) wave = 0 if phase.startswith('upgrade-near') else 1 if phase.startswith('upgrade-far') else 2 if phase == 'upgrade-late' else -1 - from native117_persistence import count_persisted + from native118_persistence import count_persisted persisted = count_persisted(world,wave) if persisted < (3 if wave >= 0 else 8): raise RuntimeError('Missing persisted corpses after world save') From fa9b74edf376606221097e38d6722e3c18503212 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Mon, 2 Nov 2026 14:07:57 +0000 Subject: [PATCH 2/3] Use the qualified Java build in CI --- .github/scripts/test_release_workflow.py | 7 +++++++ .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/deploy-release.yml | 8 ++++---- .github/workflows/native118.yml | 2 +- tools/hosted_policy.py | 5 +++++ tools/test_hosted_policy.py | 4 ++++ 6 files changed, 22 insertions(+), 6 deletions(-) diff --git a/.github/scripts/test_release_workflow.py b/.github/scripts/test_release_workflow.py index 53f1b1d..ad9fc4f 100644 --- a/.github/scripts/test_release_workflow.py +++ b/.github/scripts/test_release_workflow.py @@ -310,6 +310,13 @@ def test_118_staging_downloads_exact_inputs_without_build_tests_or_sdk(self): legacy=workflow.split('- name: Build, test and audit the release',1)[1].split(' - name:',1)[0] self.assertIn("minecraft_version != '1.18.2'",legacy) + def test_118_setup_java_notation_keeps_the_qualified_runtime(self): + text=WORKFLOW.read_text() + pins=[line for line in text.splitlines() if 'java-version:' in line and '17.0.20' in line] + self.assertEqual(4,len(pins)) + self.assertTrue(all('17.0.20+101' in line and '17.0.20.1+1' not in line for line in pins)) + self.assertIn("'17.0.20.1+1' if native18",script('Validate identity, exact commit and successful checks')) + def test_identity_changes_are_rejected(self): for mutation in [{'mod_id': 'other'}, {'minecraft_version': '1.10.2'}, {'mod_license': 'CC0-1.0'}]: with self.subTest(mutation=mutation), self.assertRaises(SystemExit): diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 7fd5b0f..904436c 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -41,7 +41,7 @@ jobs: uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin - java-version: '17.0.20.1+1' + java-version: '17.0.20+101' - name: Pin production JDK run: | diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index 45ecf5e..7af606f 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -213,13 +213,13 @@ jobs: uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin - java-version: ${{ needs.preflight.outputs.minecraft_version == '1.18.2' && '17.0.20.1+1' || needs.preflight.outputs.minecraft_version == '1.17.1' && '16.0.2+7' || '8.0.502+7' }} + java-version: ${{ needs.preflight.outputs.minecraft_version == '1.18.2' && '17.0.20+101' || needs.preflight.outputs.minecraft_version == '1.17.1' && '16.0.2+7' || '8.0.502+7' }} - name: Install Gradle Java if: needs.preflight.outputs.minecraft_version != '1.18.2' uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin - java-version: ${{ needs.preflight.outputs.minecraft_version == '1.18.2' && '17.0.20.1+1' || '17.0.1+12' }} + java-version: ${{ needs.preflight.outputs.minecraft_version == '1.18.2' && '17.0.20+101' || '17.0.1+12' }} - name: Pin the production toolchain if: needs.preflight.outputs.minecraft_version != '1.18.2' run: | @@ -371,12 +371,12 @@ jobs: uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin - java-version: ${{ needs.preflight.outputs.minecraft_version == '1.18.2' && '17.0.20.1+1' || needs.preflight.outputs.minecraft_version == '1.17.1' && '16.0.2+7' || '8.0.502+7' }} + java-version: ${{ needs.preflight.outputs.minecraft_version == '1.18.2' && '17.0.20+101' || needs.preflight.outputs.minecraft_version == '1.17.1' && '16.0.2+7' || '8.0.502+7' }} - name: Install Gradle Java uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin - java-version: ${{ needs.preflight.outputs.minecraft_version == '1.18.2' && '17.0.20.1+1' || '17.0.1+12' }} + java-version: ${{ needs.preflight.outputs.minecraft_version == '1.18.2' && '17.0.20+101' || '17.0.1+12' }} - name: Pin the production toolchain run: | if [ '${{ needs.preflight.outputs.minecraft_version }}' = '1.18.2' ]; then diff --git a/.github/workflows/native118.yml b/.github/workflows/native118.yml index 7134c59..fba2536 100644 --- a/.github/workflows/native118.yml +++ b/.github/workflows/native118.yml @@ -48,7 +48,7 @@ jobs: - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin - java-version: '17.0.20.1+1' + java-version: '17.0.20+101' # Full Java/Python suites, games, IDE consumers and SDK installs stay local. # Fork pushes run policy checks only. MMD builds independently check bytes. - name: Compile and audit release archives diff --git a/tools/hosted_policy.py b/tools/hosted_policy.py index a53710a..b346909 100644 --- a/tools/hosted_policy.py +++ b/tools/hosted_policy.py @@ -19,6 +19,8 @@ def gradle_roots(command): return roots def validate_workflow(text): + if "java-version: '17.0.20+101'" not in text or any('java-version:' in line and '17.0.20.1+1' in line for line in text.splitlines()): + raise ValueError('Use the setup-java SemVer identifier for the exact qualified JDK') commands='\n'.join(line.strip() for line in text.splitlines() if line.startswith(' ') and not line.strip().startswith('#')) for forbidden in ('unittest discover','-m unittest','native_runtime.py','native117_ci.py','verify_world_upgrades.py','verify_packaged','prepare_native','xvfb-run','--seed-only'): if forbidden in commands:raise ValueError('Expensive hosted command: '+forbidden) @@ -71,6 +73,9 @@ def require_qualified_manifest(record): def check_source(root): validate_workflow((root/'.github/workflows/native118.yml').read_text(encoding='utf-8')) + for workflow in ('codeql-analysis.yml','deploy-release.yml'): + if any('java-version:' in line and '17.0.20.1+1' in line for line in (root/'.github/workflows'/workflow).read_text(encoding='utf-8').splitlines()): + raise ValueError('Non-SemVer JDK input in '+workflow) if 'feature/**' in(root/'.github/workflows/build.yml').read_text():raise ValueError('Copied legacy CI still catches modern feature branches') if 'workflow_dispatch:' in(root/'.github/workflows/migration-matrix.yml').read_text():raise ValueError('Copied legacy migration workflow can still dispatch on a modern ref') guard=(root/'settings.gradle').read_text() diff --git a/tools/test_hosted_policy.py b/tools/test_hosted_policy.py index 0df1a6a..d88b56b 100644 --- a/tools/test_hosted_policy.py +++ b/tools/test_hosted_policy.py @@ -4,6 +4,10 @@ from hosted_policy import gradle_roots,validate_workflow,require_qualified_manifest,check_source class HostedPolicyTests(unittest.TestCase): + def test_raw_jdk_version_is_rejected_before_java_preparation(self): + text=(Path(__file__).resolve().parents[1]/'.github/workflows/native118.yml').read_text() + with self.assertRaises(ValueError):validate_workflow(text.replace('17.0.20+101','17.0.20.1+1')) + def test_hosted_roots_allow_archive_builds_and_reject_every_test_or_game_entry(self): base='./gradlew assemble verifyProductionRuntimeClasspath prepareReleaseBundle ' self.assertEqual(3,len(gradle_roots(base+'--no-daemon 2>&1 | tee build.log'))) From 6a0ad391c30a07f3ffb4f799d303676467513b32 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Mon, 2 Nov 2026 14:34:43 +0000 Subject: [PATCH 3/3] Make release archives consistent across platforms --- .github/release118.json | 6 +++--- build.gradle | 21 +++++++++++++++++++++ gradle/audit.gradle | 20 ++++++++++++++++---- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/.github/release118.json b/.github/release118.json index 1f92c1f..2870a69 100644 --- a/.github/release118.json +++ b/.github/release118.json @@ -5,11 +5,11 @@ "local_qualification_complete": true, "files": { "CHANGELOG.txt": "272160F79F5B4B58206FB258CF6C33C062E950DE8784E29B59A1B10DD2B8F319", - "LootableBodies-2.4.9.118021-javadoc.jar": "C902CC4E0BE65BA4732406855A32A12CFB8A8BE1D855B22905E3203880C6C12D", - "LootableBodies-2.4.9.118021-sources.jar": "712A96FF6BAE8421EC836BEAA07BE44926DE2DEA2FA3EAA6221AC89282018A84", + "LootableBodies-2.4.9.118021-javadoc.jar": "3E66A160D1E5E02F040107A53C8772B36AEE2A67D9F2F6CCAB0092B7A9E8D695", + "LootableBodies-2.4.9.118021-sources.jar": "23CAC620238DBF0C64525769631CF582BD2BD07F923944FCE128FEB2FFC63466", "LootableBodies-2.4.9.118021.jar": "34EF41EFCE4666A684EA202DAF8712162A1C403A90120BA11EF0260533FB690F", "LootableBodies-2.4.9.118021.pom": "051FD00F47E53B7F7B6B30CC907B1AA662864D8911FDB0B0C8326987E8F88988", - "SHA256SUMS": "E1E0833F632C6BF794CB13940A34F411355191C903ACAD37C424289BDAE0CFC8" + "SHA256SUMS": "B09D84DDE620B3D10984788331298F443001EAAF4DBC0B4444D36805331685E4" }, "qualification_integrity_status": "verified" } diff --git a/build.gradle b/build.gradle index c4b6e76..85d628f 100644 --- a/build.gradle +++ b/build.gradle @@ -111,6 +111,23 @@ tasks.withType(Javadoc).configureEach { options.addStringOption('Xdoclint:none', '-quiet') options.addBooleanOption('notimestamp', true) } +// Windows JDKs use redirect files for these notices; Linux JDKs use links. +// Package the complete java.base notices on both platforms. +tasks.named('javadoc', Javadoc) { + def legalNames = ['ADDITIONAL_LICENSE_INFO', 'ASSEMBLY_EXCEPTION', 'LICENSE'] + inputs.files(productionJavadoc.map { tool -> + legalNames.collect { new File(tool.metadata.installationPath.asFile, "legal/java.base/${it}") } + }).withPropertyName('completeJdkLegalNotices') + doLast { + File jdk = javadocTool.get().metadata.installationPath.asFile + legalNames.each { name -> + String notice = new File(jdk, "legal/java.base/${name}").getText('UTF-8').replace('\r\n', '\n') + if (notice.length() < 100 || notice.startsWith('Please see ')) + throw new GradleException("Incomplete JDK legal notice ${name}") + new File(destinationDir, "legal/${name}").setText(notice, 'UTF-8') + } + } +} tasks.withType(AbstractArchiveTask).configureEach { preserveFileTimestamps = false reproducibleFileOrder = true @@ -242,6 +259,10 @@ ext.releaseJar = renamer.classes(tasks.named('jar', Jar)) { filesMatching(archiveTextPatterns, normalizeArchiveText) } } +tasks.named('sourcesJar', Jar) { + inputs.property('accessTransformerLineEndings', 'lf') + filesMatching('META-INF/accesstransformer.cfg', normalizeArchiveText) +} // Java 17 writes this extensionless index with the host line separator. Keep // the 1.18 documentation bytes identical on Windows and Linux. tasks.named('javadocJar', Jar) { diff --git a/gradle/audit.gradle b/gradle/audit.gradle index d373def..87ccdaf 100644 --- a/gradle/audit.gradle +++ b/gradle/audit.gradle @@ -88,10 +88,22 @@ ext.auditReleaseArchives = { File directory -> 'cyano/lootable/entities/EntityLootableBody.class')).bytes, 'ISO-8859-1') if (!entity.contains('m_7378_') || !entity.contains('m_7380_')) throw new GradleException('Production jar does not have runtime SRG mappings') - } else if (index == 1 && !names.contains('cyano/lootable/LootableBodies.java')) { - throw new GradleException('Sources jar does not contain production sources') - } else if (index == 2 && !names.contains('index.html')) { - throw new GradleException('Javadoc jar has no index') + } else if (index == 1) { + if (!names.contains('cyano/lootable/LootableBodies.java')) + throw new GradleException('Sources jar does not contain production sources') + def entry = zip.getEntry('META-INF/accesstransformer.cfg') + if (!entry || zip.getInputStream(entry).getText('UTF-8').contains('\r')) + throw new GradleException('Source access transformer must use LF line endings') + } else if (index == 2) { + if (!names.contains('index.html')) throw new GradleException('Javadoc jar has no index') + File jdk = tasks.named('javadoc', Javadoc).get().javadocTool.get().metadata.installationPath.asFile + ['ADDITIONAL_LICENSE_INFO', 'ASSEMBLY_EXCEPTION', 'LICENSE'].each { noticeName -> + String complete = new File(jdk, "legal/java.base/${noticeName}").getText('UTF-8').replace('\r\n', '\n') + def entry = zip.getEntry("legal/${noticeName}") + if (complete.length() < 100 || complete.startsWith('Please see ') || !entry || + zip.getInputStream(entry).getText('UTF-8') != complete) + throw new GradleException("Missing/changed complete JDK legal notice ${noticeName}") + } } } }