mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-11-01 08:50:06 +00:00
Compare commits
314
Commits
No files matched your search
@@ -1,87 +1,78 @@
|
||||
name: Iris Bug Report
|
||||
description: File a bug report for Iris
|
||||
name: Bug report
|
||||
description: Something in Iris isn't working.
|
||||
labels: [bug]
|
||||
type: Bug
|
||||
assignees: [SwiftSwamp]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to fill this out!
|
||||
If this does not work for you, feel free to use the [blank](https://github.com/VolmitSoftware/Iris/issues/new) format.
|
||||
Make sure you're on the [latest release](https://github.com/VolmitSoftware/Iris/releases/latest) and that it hasn't [already been reported](https://github.com/VolmitSoftware/Iris/issues?q=is%3Aissue).
|
||||
|
||||
The Iris plugin supports Minecraft 26.1.2 through 26.3 on Paper, Purpur, Leaf, Canvas, Folia, and Spigot. The Fabric, Forge, and NeoForge builds support 26.3. Both need Java 25.
|
||||
- type: textarea
|
||||
id: how
|
||||
id: issue
|
||||
attributes:
|
||||
label: Problem
|
||||
description: Please give a text description of how you reached the problem
|
||||
value: |
|
||||
1. Install Iris...
|
||||
2. Do this...
|
||||
3. Do that...
|
||||
4. Observe the error...
|
||||
label: What issue are you having?
|
||||
description: Include any error message you saw.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what
|
||||
id: steps
|
||||
attributes:
|
||||
label: Solution
|
||||
description: Explain where you think the problem comes from (optional)
|
||||
placeholder: The code to place a is missing b and c...
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1. Start a server with Iris installed
|
||||
2. Change ... in the config
|
||||
3. Run /iris ...
|
||||
4. ... happens instead of ...
|
||||
validations:
|
||||
required: false
|
||||
required: true
|
||||
- type: input
|
||||
id: diagnostics
|
||||
attributes:
|
||||
label: Debug dump or latest log file
|
||||
description: Run `/iris debug dump upload=true` and paste the link. If Iris won't start, upload `logs/latest.log` to [mclo.gs](https://mclo.gs) and paste that link instead.
|
||||
placeholder: https://mclo.gs/...
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: plugin-version
|
||||
attributes:
|
||||
label: "Iris version"
|
||||
description: Run `/iris debug version` (`/iris version` on Fabric, Forge, and NeoForge).
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Which server platform is Iris running on? Pick the loader or server software, not the launcher.
|
||||
label: Server software
|
||||
options:
|
||||
- Paper
|
||||
- Purpur
|
||||
- Leaf
|
||||
- Canvas
|
||||
- Folia
|
||||
- Spigot / CraftBukkit
|
||||
- Fabric
|
||||
- Forge
|
||||
- NeoForge
|
||||
- Other (describe under Problem)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: mcversion
|
||||
attributes:
|
||||
label: Minecraft Version
|
||||
description: What version of Minecraft is the server on? Iris 4.x supports 26.1.2 to 26.3 on Bukkit-family servers and 26.3 on Fabric, Forge and NeoForge; older versions are not supported.
|
||||
options:
|
||||
- '26.3'
|
||||
- '26.2'
|
||||
- '26.1.2'
|
||||
- "Paper"
|
||||
- "Purpur"
|
||||
- "Leaf"
|
||||
- "Canvas"
|
||||
- "Folia"
|
||||
- "Spigot"
|
||||
- "Fabric"
|
||||
- "Forge"
|
||||
- "NeoForge"
|
||||
- Other (unsupported)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: loaderversion
|
||||
- type: dropdown
|
||||
id: minecraft
|
||||
attributes:
|
||||
label: Platform / Loader Version
|
||||
description: Exact server or loader build (see console). For example "Paper 26.3-133", "Fabric Loader 0.19.5", "NeoForge 26.3.0.33-beta".
|
||||
placeholder: DO NOT SAY "LATEST"
|
||||
label: Minecraft version
|
||||
options:
|
||||
- "26.3"
|
||||
- "26.2"
|
||||
- "26.1.2"
|
||||
- Other (unsupported)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: irisversion
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Iris Version
|
||||
description: What version of Iris are you running? (see console)
|
||||
placeholder: DO NOT SAY "LATEST"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: logs
|
||||
attributes:
|
||||
label: Log
|
||||
description: Paste a full log. Always use [mclogs](https://mclo.gs) Or [Pastebin](https://pastebin.com/). Must not be a crash report. Must be a full log. Must not be a screenshot of a log.
|
||||
placeholder: https://mslog.gs/...
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
id: thanks
|
||||
attributes:
|
||||
value: "Thank you for filling out the form! We will be with you soon. Please do not ask support to review your report."
|
||||
label: Anything else?
|
||||
description: Other plugins involved, screenshots, or videos.
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Help and questions
|
||||
url: https://volmitsoftware.com/discord
|
||||
about: Ask setup and configuration questions on the Volmit Discord.
|
||||
- name: "Iris documentation"
|
||||
url: https://docs.volmitsoftware.com/iris
|
||||
about: Installation, commands, permissions, and configuration.
|
||||
@@ -1,46 +1,28 @@
|
||||
name: Iris Feature Request
|
||||
description: File a feature request for Iris. If you want to report a bug this is not the place.
|
||||
labels: [ feature ]
|
||||
name: Suggestion
|
||||
description: Suggest a new feature or a change to Iris.
|
||||
labels: [enhancement]
|
||||
type: Feature
|
||||
assignees: [SwiftSwamp]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to fill this out!
|
||||
If this does not work for you, feel free to use the [blank](https://github.com/VolmitSoftware/Iris/issues/new) format.
|
||||
- type: dropdown
|
||||
id: arc
|
||||
Check the [existing suggestions](https://github.com/VolmitSoftware/Iris/issues?q=is%3Aissue+label%3Aenhancement) first and add relevant information to the existing issue.
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Adding, Removing, or Changing
|
||||
description: What are you doing
|
||||
options:
|
||||
- Adding
|
||||
- Removing
|
||||
- Changing
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: atype
|
||||
attributes:
|
||||
label: Type of Modification
|
||||
description: What is it for?
|
||||
value: I want to ...
|
||||
label: What are your suggestions for new features or changes?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: desc
|
||||
id: proposal
|
||||
attributes:
|
||||
label: What are you trying to modify
|
||||
description: Give as detailed of a description as you can for the modification that you want done (include pictures if applicable)
|
||||
value: The way I would implement this is ...
|
||||
label: How should it work?
|
||||
description: Provide as much detail as possible on what you would like changed or added.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternative
|
||||
id: extra
|
||||
attributes:
|
||||
label: Alternatives
|
||||
description: What alternatives have you considered?
|
||||
value: If this could not be implemented I would ...
|
||||
- type: markdown
|
||||
id: thanks
|
||||
attributes:
|
||||
value: "Thank you for filling out the form! We will be with you soon. Please do not ask support to review your report."
|
||||
label: Anything else?
|
||||
description: Any additional information such as screenshots, examples, etc.
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Translation
|
||||
description: A message in Iris is wrong, missing, or unclear.
|
||||
labels: [bug]
|
||||
type: Bug
|
||||
assignees: [SwiftSwamp]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
The language files are in [`core/src/main/resources/languages/`](https://github.com/VolmitSoftware/Iris/tree/HEAD/core/src/main/resources/languages).
|
||||
|
||||
You can create a pull request to modify the files directly.
|
||||
- type: dropdown
|
||||
id: language
|
||||
attributes:
|
||||
label: Language
|
||||
options:
|
||||
- English (en_US)
|
||||
- German (de_DE)
|
||||
- Spanish (es_ES)
|
||||
- Finnish (fi_FI)
|
||||
- French (fr_FR)
|
||||
- Hebrew (he_IL)
|
||||
- Italian (it_IT)
|
||||
- Japanese (ja-JP)
|
||||
- Korean (ko_KR)
|
||||
- Lithuanian (lt_LT)
|
||||
- Dutch (nl_NL)
|
||||
- Polish (pl_PL)
|
||||
- Portuguese (pt_PT)
|
||||
- Russian (ru_RU)
|
||||
- Turkish (tr_TR)
|
||||
- Vietnamese (vi_VI)
|
||||
- Chinese, Simplified (zh_CN)
|
||||
- Chinese, Traditional (zh_TW)
|
||||
- Another language (name it below)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: current
|
||||
attributes:
|
||||
label: Current text
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: suggested
|
||||
attributes:
|
||||
label: Suggested text
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: where
|
||||
attributes:
|
||||
label: Where does it appear?
|
||||
description: The command used or message key where it is appearing.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "Iris",
|
||||
"platform": "modded",
|
||||
"versionProperty": "irisVersion",
|
||||
"betaTag": "modded-beta",
|
||||
"targets": "gradle/mod-targets.json",
|
||||
"volmlibRef": "master"
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
|
||||
def read_config(root: Path) -> dict:
|
||||
return json.loads((root / '.github/release.json').read_text())
|
||||
|
||||
|
||||
def targets(root: Path, config: dict) -> list[dict[str, str]]:
|
||||
if config['platform'] == 'bukkit':
|
||||
return [{'id': 'bukkit', 'loader': 'bukkit', 'minecraft': ''}]
|
||||
manifest: Path = root / config['targets']
|
||||
if manifest.is_file():
|
||||
versions: dict = json.loads(manifest.read_text())
|
||||
entries: list[tuple[str, list[str]]] = [(version, ['fabric', 'forge', 'neoforge']) for version in versions]
|
||||
else:
|
||||
entries = []
|
||||
for path in sorted(manifest.glob('*.json')):
|
||||
target: dict = json.loads(path.read_text())
|
||||
if path.stem != target['minecraft']:
|
||||
raise ValueError(f'Minecraft manifest does not match {path.name}')
|
||||
entries.append((target['minecraft'], list(target['loaders'])))
|
||||
matrix: list[dict[str, str]] = []
|
||||
for version, loaders in entries:
|
||||
if not re.fullmatch(r'[0-9]+(?:\.[0-9]+)+', version):
|
||||
raise ValueError(f'Invalid Minecraft version: {version}')
|
||||
if set(loaders) != {'fabric', 'forge', 'neoforge'}:
|
||||
raise ValueError(f'Incomplete loader matrix for {version}')
|
||||
for loader in sorted(loaders):
|
||||
matrix.append({'id': f'{loader}-{version}', 'loader': loader, 'minecraft': version})
|
||||
if not matrix:
|
||||
raise ValueError('Release matrix is empty')
|
||||
return matrix
|
||||
|
||||
|
||||
def plan(root: Path, config: dict, event: dict, environment: dict) -> dict:
|
||||
branch: str = event.get('repository', {}).get('default_branch', '')
|
||||
if (environment.get('GITHUB_EVENT_NAME') != 'push' or event.get('deleted')
|
||||
or not branch or event.get('ref') != f'refs/heads/{branch}'):
|
||||
return {'channel': 'none'}
|
||||
commit: dict = event.get('head_commit') or {}
|
||||
message: str = commit.get('message', '')
|
||||
beta: bool = 'B+' in message
|
||||
stable: bool = 'V+' in message
|
||||
if beta and stable:
|
||||
raise ValueError('Use exactly one release marker: B+ or V+')
|
||||
if not beta and not stable:
|
||||
return {'channel': 'none'}
|
||||
sha: str = environment['GITHUB_SHA']
|
||||
if event.get('after') != sha or commit.get('id') != sha or not re.fullmatch(r'[a-f0-9]{40}', sha):
|
||||
raise ValueError('Release commit does not match the pushed HEAD')
|
||||
key: str = config['versionProperty']
|
||||
properties: str = (root / 'gradle.properties').read_text()
|
||||
values: list[str] = re.findall(rf'^{re.escape(key)}\s*=\s*(\S+)\s*$', properties, re.MULTILINE)
|
||||
if len(values) != 1 or not re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)', values[0]):
|
||||
raise ValueError(f'{key} must declare one stable major.minor.patch version')
|
||||
run: int = int(environment['GITHUB_RUN_NUMBER'])
|
||||
attempt: int = int(environment['GITHUB_RUN_ATTEMPT'])
|
||||
if run < 1 or attempt < 1:
|
||||
raise ValueError('Invalid workflow run identity')
|
||||
version: str = f'{values[0]}-beta.{run}.{attempt}' if beta else values[0]
|
||||
return {
|
||||
'channel': 'beta' if beta else 'stable',
|
||||
'version': version,
|
||||
'version_property': key,
|
||||
'tag': config['betaTag'] if beta else f'v{version}',
|
||||
'sha': sha,
|
||||
'run': run,
|
||||
'attempt': attempt,
|
||||
'matrix': {'include': targets(root, config)},
|
||||
'volmlib_ref': config['volmlibRef'],
|
||||
'wormholes_ref': config.get('wormholesRef', ''),
|
||||
}
|
||||
|
||||
|
||||
def collect_artifacts(directory: Path, release: dict) -> dict[str, bytes]:
|
||||
expected: dict[str, dict] = {f"release-{entry['id']}": entry for entry in release['matrix']['include']}
|
||||
if {path.name for path in directory.iterdir()} != set(expected):
|
||||
raise ValueError('Downloaded artifacts do not match the complete release matrix')
|
||||
assets: dict[str, bytes] = {}
|
||||
descriptors: dict[str, str] = {'bukkit': 'plugin.yml', 'fabric': 'fabric.mod.json',
|
||||
'forge': 'META-INF/mods.toml', 'neoforge': 'META-INF/neoforge.mods.toml'}
|
||||
for name, target in expected.items():
|
||||
folder: Path = directory / name
|
||||
paths: list[Path] = list(folder.iterdir()) if folder.is_dir() and not folder.is_symlink() else []
|
||||
if len(paths) != 1 or paths[0].suffix != '.jar' or paths[0].is_symlink() or not paths[0].is_file():
|
||||
raise ValueError(f'{name} must contain exactly one runtime jar')
|
||||
path: Path = paths[0]
|
||||
loader: str = target['loader']
|
||||
suffix: str = re.escape(release['version']) + r'-bukkit\.jar'
|
||||
if loader != 'bukkit':
|
||||
display: str = {'fabric': 'Fabric', 'forge': 'Forge', 'neoforge': 'NeoForge'}[loader]
|
||||
suffix = (r'v' + re.escape(release['version']) + r' \[' + display + r'\] '
|
||||
+ re.escape(target['minecraft']) + r'\+[^/\s]+\.jar')
|
||||
if not re.fullmatch(r'[A-Za-z][A-Za-z0-9]*' + ('-' if loader == 'bukkit' else ' ') + suffix, path.name):
|
||||
raise ValueError(f'Artifact does not match the release target: {path.name}')
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
entries: set[str] = set(archive.namelist())
|
||||
descriptor: str = descriptors[target['loader']]
|
||||
if descriptor not in entries:
|
||||
raise ValueError(f'{path.name} is missing {descriptor}')
|
||||
excluded: set[str] = set(descriptors.values()) - {descriptor}
|
||||
if 'velocity-plugin.json' in entries or entries.intersection(excluded):
|
||||
raise ValueError(f'{path.name} contains an excluded platform')
|
||||
contents: str = archive.read(descriptor).decode()
|
||||
if loader == 'bukkit':
|
||||
match: re.Match | None = re.search(r'^version:\s*[\'\"]?([^\s\'\"]+)', contents, re.MULTILINE)
|
||||
version: str | None = match[1] if match else None
|
||||
elif loader == 'fabric':
|
||||
metadata: dict = json.loads(contents)
|
||||
version = metadata.get('version')
|
||||
if metadata.get('depends', {}).get('minecraft') not in (target['minecraft'], '~' + target['minecraft']):
|
||||
raise ValueError(f'{path.name} declares a different Minecraft target')
|
||||
else:
|
||||
metadata = tomllib.loads(contents)
|
||||
mods: list[dict] = metadata.get('mods', [])
|
||||
version = mods[0].get('version') if len(mods) == 1 else None
|
||||
dependencies: list[dict] = metadata.get('dependencies', {}).get(mods[0].get('modId'), []) if len(mods) == 1 else []
|
||||
minecraft: list[str] = [entry.get('versionRange') for entry in dependencies if entry.get('modId') == 'minecraft']
|
||||
if minecraft != ['[' + target['minecraft'] + ']']:
|
||||
raise ValueError(f'{path.name} declares a different Minecraft target')
|
||||
if version != release['version']:
|
||||
raise ValueError(f'{path.name} declares a different product version')
|
||||
asset_name: str = re.sub(r'[^A-Za-z0-9._-]+', '-', path.name)
|
||||
if asset_name in assets:
|
||||
raise ValueError(f'Duplicate release asset: {asset_name}')
|
||||
assets[asset_name] = path.read_bytes()
|
||||
checksums: str = ''.join(f'{hashlib.sha256(data).hexdigest()} {name}\n' for name, data in sorted(assets.items()))
|
||||
assets[f"SHA256SUMS-{release['version']}.txt"] = checksums.encode()
|
||||
return assets
|
||||
|
||||
|
||||
class GitHub:
|
||||
def __init__(self, repository: str, token: str):
|
||||
if not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', repository):
|
||||
raise ValueError('Invalid GitHub repository')
|
||||
self.repository = repository
|
||||
self.token = token
|
||||
|
||||
def request(self, method: str, path: str, payload: dict | bytes | None = None, missing: bool = False) -> dict | list | None:
|
||||
url: str = path if path.startswith('https://uploads.github.com/') else f'https://api.github.com/repos/{self.repository}/{path}'
|
||||
binary: bool = isinstance(payload, bytes)
|
||||
data: bytes | None = payload if binary else json.dumps(payload).encode() if payload is not None else None
|
||||
request: urllib.request.Request = urllib.request.Request(url, data=data, method=method, headers={
|
||||
'Authorization': f'Bearer {self.token}',
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2026-03-10',
|
||||
'Content-Type': 'application/octet-stream' if binary else 'application/json',
|
||||
'User-Agent': 'Volmit-release',
|
||||
})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
body: bytes = response.read()
|
||||
return json.loads(body) if body else None
|
||||
except urllib.error.HTTPError as error:
|
||||
if missing and error.code == 404:
|
||||
return None
|
||||
raise RuntimeError(f'GitHub {method} {urllib.parse.urlsplit(url).path}: HTTP {error.code}') from error
|
||||
|
||||
def tag_commit(self, tag: str) -> str | None:
|
||||
reference: dict | None = self.request('GET', f'git/ref/tags/{tag}', missing=True)
|
||||
if reference is None:
|
||||
return None
|
||||
target: dict = reference['object']
|
||||
for _ in range(8):
|
||||
if target['type'] == 'commit':
|
||||
return target['sha']
|
||||
if target['type'] != 'tag':
|
||||
break
|
||||
target = self.request('GET', f"git/tags/{target['sha']}")['object']
|
||||
raise ValueError(f'{tag} does not resolve to a commit')
|
||||
|
||||
def assets(self, release_id: int) -> list[dict]:
|
||||
result: list[dict] = []
|
||||
page: int = 1
|
||||
while True:
|
||||
batch: list[dict] = self.request('GET', f'releases/{release_id}/assets?per_page=100&page={page}')
|
||||
result.extend(batch)
|
||||
if len(batch) < 100:
|
||||
return result
|
||||
page += 1
|
||||
|
||||
|
||||
def resolve_dependency(repository: str, reference: str, token: str) -> str:
|
||||
commit: dict = GitHub(repository, token).request('GET', f'commits/{urllib.parse.quote(reference, safe="")}')
|
||||
sha: str = commit['sha']
|
||||
if not re.fullmatch(r'[a-f0-9]{40}', sha):
|
||||
raise ValueError(f'{repository} did not resolve to a commit')
|
||||
return sha
|
||||
|
||||
|
||||
def release_metadata(existing: dict) -> dict:
|
||||
match: re.Match | None = re.search(r'<!-- release: (.+) -->', existing.get('body') or '')
|
||||
if match is None:
|
||||
raise ValueError('Existing release has no publication identity; refusing to overwrite it')
|
||||
return json.loads(match[1])
|
||||
|
||||
|
||||
def verify_assets(existing: list[dict], assets: dict[str, bytes]) -> bool:
|
||||
return {entry['name']: entry.get('digest') for entry in existing} == {
|
||||
name: 'sha256:' + hashlib.sha256(data).hexdigest() for name, data in assets.items()}
|
||||
|
||||
|
||||
def publish(github: GitHub, config: dict, release: dict, assets: dict[str, bytes]) -> str:
|
||||
tag: str = release['tag']
|
||||
beta: bool = release['channel'] == 'beta'
|
||||
existing: dict | None = github.request('GET', f'releases/tags/{tag}', missing=True)
|
||||
commit: str | None = github.tag_commit(tag)
|
||||
if not beta and commit is not None and commit != release['sha']:
|
||||
raise ValueError(f'{tag} already points to another commit')
|
||||
if existing is not None:
|
||||
metadata: dict = release_metadata(existing)
|
||||
if bool(existing['prerelease']) != beta:
|
||||
raise ValueError('Existing release belongs to a different release channel')
|
||||
if beta and (metadata['run'], metadata['attempt']) > (release['run'], release['attempt']):
|
||||
return 'Skipped older beta run'
|
||||
if not beta and metadata['sha'] != release['sha']:
|
||||
raise ValueError('Stable release belongs to another commit')
|
||||
if not existing['draft'] and commit == release['sha'] and metadata['version'] == release['version']:
|
||||
if verify_assets(github.assets(existing['id']), assets):
|
||||
return 'Release already published with identical artifacts'
|
||||
if not beta:
|
||||
raise ValueError('Published release artifacts differ; refusing to overwrite them')
|
||||
if existing.get('immutable'):
|
||||
raise ValueError('Rolling beta requires mutable releases in repository settings')
|
||||
if not beta and not existing['draft']:
|
||||
raise ValueError('Stable release is already published')
|
||||
elif beta and commit is not None:
|
||||
raise ValueError('Beta tag exists without a release; refusing to move it')
|
||||
identity: dict = {key: release[key] for key in ('version', 'sha', 'run', 'attempt', 'volmlib_ref', 'wormholes_ref') if key in release}
|
||||
platform: str = 'Modded' if config['platform'] == 'modded' else 'CraftBukkit'
|
||||
body: str = (f"{platform} jars for {config['name']} {release['version']}.\n\n"
|
||||
f"Commit: `{release['sha']}`\n\n"
|
||||
f"<!-- release: {json.dumps(identity, sort_keys=True)} -->")
|
||||
attributes: dict = {
|
||||
'tag_name': tag, 'target_commitish': release['sha'],
|
||||
'name': f"{config['name']} {platform} {'Beta' if beta else release['version']}",
|
||||
'body': body, 'prerelease': beta, 'make_latest': 'false',
|
||||
}
|
||||
if existing is None:
|
||||
existing = github.request('POST', 'releases', {**attributes, 'draft': True})
|
||||
elif beta:
|
||||
existing = github.request('PATCH', f"releases/{existing['id']}", {**attributes, 'draft': True})
|
||||
uploaded: list[dict] = github.assets(existing['id'])
|
||||
by_name: dict[str, dict] = {entry['name']: entry for entry in uploaded}
|
||||
for name, data in assets.items():
|
||||
digest: str = 'sha256:' + hashlib.sha256(data).hexdigest()
|
||||
if name in by_name:
|
||||
if by_name[name].get('state') == 'starter':
|
||||
github.request('DELETE', f"releases/assets/{by_name[name]['id']}")
|
||||
elif by_name[name].get('digest') != digest:
|
||||
raise ValueError(f'Existing asset differs: {name}')
|
||||
else:
|
||||
continue
|
||||
upload_url: str = existing['upload_url'].split('{')[0] + '?' + urllib.parse.urlencode({'name': name})
|
||||
receipt: dict = github.request('POST', upload_url, data)
|
||||
if receipt.get('digest') != digest or receipt['name'] != name:
|
||||
raise ValueError(f'Upload verification failed: {name}')
|
||||
for entry in uploaded:
|
||||
if entry['name'] not in assets:
|
||||
github.request('DELETE', f"releases/assets/{entry['id']}")
|
||||
commit = github.tag_commit(tag)
|
||||
if commit is None:
|
||||
github.request('POST', 'git/refs', {'ref': f'refs/tags/{tag}', 'sha': release['sha']})
|
||||
elif commit != release['sha']:
|
||||
if not beta:
|
||||
raise ValueError('Stable tag changed during publication')
|
||||
github.request('PATCH', f'git/refs/tags/{tag}', {'sha': release['sha'], 'force': True})
|
||||
if not beta and config['platform'] == 'bukkit':
|
||||
latest: dict | None = github.request('GET', 'releases/latest', missing=True)
|
||||
match: re.Match | None = re.match(r'^v?(\d+)\.(\d+)\.(\d+)(?:$|-)', latest['tag_name']) if latest else None
|
||||
if latest is None or (match and tuple(map(int, release['version'].split('.'))) > tuple(map(int, match.groups()))):
|
||||
attributes['make_latest'] = 'true'
|
||||
github.request('PATCH', f"releases/{existing['id']}", {**attributes, 'draft': False})
|
||||
return f"Published {tag} at {release['sha']}"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser: argparse.ArgumentParser = argparse.ArgumentParser()
|
||||
parser.add_argument('command', choices=['plan', 'publish'])
|
||||
parser.add_argument('--artifacts', type=Path)
|
||||
arguments: argparse.Namespace = parser.parse_args()
|
||||
root: Path = Path(__file__).resolve().parent.parent
|
||||
config: dict = read_config(root)
|
||||
event: dict = json.loads(Path(os.environ['GITHUB_EVENT_PATH']).read_text())
|
||||
environment: dict = dict(os.environ)
|
||||
if arguments.command == 'publish':
|
||||
environment['GITHUB_RUN_ATTEMPT'] = environment['RELEASE_ATTEMPT']
|
||||
release: dict = plan(root, config, event, environment)
|
||||
if arguments.command == 'plan':
|
||||
if release['channel'] != 'none':
|
||||
release['volmlib_ref'] = resolve_dependency('VolmitSoftware/VolmLib', config['volmlibRef'], environment['GH_TOKEN'])
|
||||
if release['wormholes_ref']:
|
||||
release['wormholes_ref'] = resolve_dependency('VolmitSoftware/Wormholes', config['wormholesRef'], environment['GH_TOKEN'])
|
||||
with Path(os.environ['GITHUB_OUTPUT']).open('a') as output:
|
||||
for key, value in release.items():
|
||||
output.write(f'{key}={json.dumps(value, separators=(",", ":")) if isinstance(value, dict) else value}\n')
|
||||
print(f"Release channel: {release['channel']}")
|
||||
elif release['channel'] != 'none':
|
||||
if arguments.artifacts is None:
|
||||
raise ValueError('Publication requires the complete artifact directory')
|
||||
for key in ('volmlib_ref', 'wormholes_ref'):
|
||||
if release[key]:
|
||||
resolved: str = environment[f'RELEASE_{key.upper()}']
|
||||
if not re.fullmatch(r'[a-f0-9]{40}', resolved):
|
||||
raise ValueError(f'{key} is not a resolved dependency commit')
|
||||
release[key] = resolved
|
||||
github: GitHub = GitHub(os.environ['GITHUB_REPOSITORY'], os.environ['GH_TOKEN'])
|
||||
print(publish(github, config, release, collect_artifacts(arguments.artifacts, release)))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (ValueError, RuntimeError) as error:
|
||||
print(f'::error::{error}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,718 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import urllib.parse
|
||||
import zipfile
|
||||
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location('release', Path(__file__).with_name('release.py'))
|
||||
release = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(release)
|
||||
|
||||
SHA = 'a' * 40
|
||||
OTHER_SHA = 'b' * 40
|
||||
|
||||
|
||||
def digest(data: bytes) -> str:
|
||||
return 'sha256:' + hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
class FakeGitHub:
|
||||
def __init__(self) -> None:
|
||||
self.tags: dict[str, str] = {}
|
||||
self.releases: dict[int, dict] = {}
|
||||
self.uploads: dict[int, dict[str, dict]] = {}
|
||||
self.writes: list[tuple[str, str]] = []
|
||||
self.latest: dict | None = None
|
||||
self.fail_upload: str | None = None
|
||||
self.fail_delete: int | None = None
|
||||
self.invalid_receipt: bool = False
|
||||
self.next_asset: int = 1
|
||||
|
||||
def seed(self, publication: dict, assets: dict[str, bytes], draft: bool = False,
|
||||
immutable: bool = False) -> dict:
|
||||
identity: dict = {key: publication[key] for key in ('sha', 'version', 'run', 'attempt')}
|
||||
record: dict = {
|
||||
'id': len(self.releases) + 1,
|
||||
'tag_name': publication['tag'],
|
||||
'body': '<!-- release: ' + json.dumps(identity) + ' -->',
|
||||
'draft': draft,
|
||||
'prerelease': publication['channel'] == 'beta',
|
||||
'immutable': immutable,
|
||||
}
|
||||
record['upload_url'] = f"https://uploads.github.com/repos/example/product/releases/{record['id']}/assets{{?name,label}}"
|
||||
self.releases[record['id']] = record
|
||||
self.uploads[record['id']] = {}
|
||||
for name, data in assets.items():
|
||||
self.add_asset(record['id'], name, data)
|
||||
self.tags[publication['tag']] = publication['sha']
|
||||
return record
|
||||
|
||||
def add_asset(self, identifier: int, name: str, data: bytes) -> dict:
|
||||
receipt: dict = {'id': self.next_asset, 'name': name, 'digest': digest(data)}
|
||||
self.next_asset += 1
|
||||
self.uploads[identifier][name] = receipt
|
||||
return copy.deepcopy(receipt)
|
||||
|
||||
def tag_commit(self, tag: str) -> str | None:
|
||||
return self.tags.get(tag)
|
||||
|
||||
def assets(self, identifier: int) -> list[dict]:
|
||||
return copy.deepcopy(list(self.uploads[identifier].values()))
|
||||
|
||||
def request(self, method: str, path: str, payload: dict | bytes | None = None,
|
||||
missing: bool = False) -> dict | list | None:
|
||||
if method != 'GET':
|
||||
self.writes.append((method, path))
|
||||
if method == 'GET' and path.startswith('releases/tags/'):
|
||||
tag: str = path.removeprefix('releases/tags/')
|
||||
return copy.deepcopy(next((record for record in self.releases.values()
|
||||
if record['tag_name'] == tag), None))
|
||||
if method == 'GET' and path == 'releases/latest':
|
||||
return copy.deepcopy(self.latest)
|
||||
if method == 'POST' and path == 'releases':
|
||||
identifier: int = len(self.releases) + 1
|
||||
record: dict = copy.deepcopy(payload)
|
||||
record.update({'id': identifier, 'immutable': False,
|
||||
'upload_url': f'https://uploads.github.com/repos/example/product/releases/{identifier}/assets{{?name,label}}'})
|
||||
self.releases[identifier] = record
|
||||
self.uploads[identifier] = {}
|
||||
return copy.deepcopy(record)
|
||||
if method == 'POST' and path.startswith('https://uploads.github.com/'):
|
||||
address: urllib.parse.SplitResult = urllib.parse.urlsplit(path)
|
||||
name: str = urllib.parse.parse_qs(address.query)['name'][0]
|
||||
if name == self.fail_upload:
|
||||
raise RuntimeError('Upload unavailable')
|
||||
identifier: int = int(address.path.split('/')[-2])
|
||||
receipt: dict = self.add_asset(identifier, name, payload)
|
||||
if self.invalid_receipt:
|
||||
receipt['digest'] = digest(b'corrupted')
|
||||
return receipt
|
||||
if method == 'POST' and path == 'git/refs':
|
||||
tag: str = payload['ref'].removeprefix('refs/tags/')
|
||||
if tag in self.tags:
|
||||
raise RuntimeError('Tag already exists')
|
||||
self.tags[tag] = payload['sha']
|
||||
return {'object': {'type': 'commit', 'sha': payload['sha']}}
|
||||
if method == 'PATCH' and path.startswith('git/refs/tags/'):
|
||||
self.tags[path.removeprefix('git/refs/tags/')] = payload['sha']
|
||||
return None
|
||||
if method == 'DELETE' and path.startswith('releases/assets/'):
|
||||
identifier: int = int(path.rsplit('/', 1)[1])
|
||||
if identifier == self.fail_delete:
|
||||
raise RuntimeError('Delete unavailable')
|
||||
for uploaded in self.uploads.values():
|
||||
for name, entry in list(uploaded.items()):
|
||||
if entry['id'] == identifier:
|
||||
del uploaded[name]
|
||||
return None
|
||||
raise AssertionError(f'Unknown asset: {identifier}')
|
||||
if method == 'PATCH' and path.startswith('releases/'):
|
||||
identifier: int = int(path.rsplit('/', 1)[1])
|
||||
self.releases[identifier].update(copy.deepcopy(payload))
|
||||
if payload.get('make_latest') == 'true':
|
||||
self.latest = copy.deepcopy(self.releases[identifier])
|
||||
return copy.deepcopy(self.releases[identifier])
|
||||
raise AssertionError(f'Unexpected API request: {method} {path}')
|
||||
|
||||
|
||||
class ReleaseTestCase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
temporary: tempfile.TemporaryDirectory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root: Path = Path(temporary.name)
|
||||
self.config: dict = {'name': 'Example', 'platform': 'bukkit', 'versionProperty': 'exampleVersion',
|
||||
'betaTag': 'bukkit-beta', 'volmlibRef': 'c' * 40}
|
||||
(self.root / 'gradle.properties').write_text('exampleVersion=1.2.3\n')
|
||||
self.event: dict = {'repository': {'default_branch': 'main'}, 'ref': 'refs/heads/main',
|
||||
'after': SHA, 'head_commit': {'id': SHA, 'message': 'V+ Publish'}}
|
||||
self.environment: dict = {'GITHUB_EVENT_NAME': 'push', 'GITHUB_SHA': SHA,
|
||||
'GITHUB_RUN_NUMBER': '17', 'GITHUB_RUN_ATTEMPT': '2'}
|
||||
network = patch.object(release.urllib.request, 'urlopen', side_effect=AssertionError('Network is forbidden'))
|
||||
network.start()
|
||||
self.addCleanup(network.stop)
|
||||
|
||||
def publication(self, channel: str = 'stable', **changes) -> dict:
|
||||
result: dict = {
|
||||
'channel': channel, 'version': '1.2.3-beta.17.2' if channel == 'beta' else '1.2.3',
|
||||
'tag': 'bukkit-beta' if channel == 'beta' else 'v1.2.3', 'sha': SHA, 'run': 17, 'attempt': 2,
|
||||
'matrix': {'include': [{'id': 'bukkit', 'loader': 'bukkit', 'minecraft': ''}]},
|
||||
}
|
||||
result.update(changes)
|
||||
return result
|
||||
|
||||
def matrix(self, versions: tuple[str, ...] = ('1.20.1', '1.21.1')) -> list[dict]:
|
||||
return [{'id': f'{loader}-{version}', 'loader': loader, 'minecraft': version}
|
||||
for version in versions for loader in ('fabric', 'forge', 'neoforge')]
|
||||
|
||||
def artifact_tree(self, publication: dict, extra: tuple[str, ...] = ()) -> Path:
|
||||
directory: Path = self.root / 'artifacts'
|
||||
directory.mkdir(exist_ok=True)
|
||||
descriptors: dict[str, str] = {'bukkit': 'plugin.yml', 'fabric': 'fabric.mod.json',
|
||||
'forge': 'META-INF/mods.toml', 'neoforge': 'META-INF/neoforge.mods.toml'}
|
||||
for target in publication['matrix']['include']:
|
||||
folder: Path = directory / f"release-{target['id']}"
|
||||
folder.mkdir()
|
||||
loader: str = target['loader']
|
||||
if loader == 'bukkit':
|
||||
filename: str = f"Example-{publication['version']}-bukkit.jar"
|
||||
metadata: str = f"name: Example\nversion: {publication['version']}\nmain: example.Plugin\n"
|
||||
else:
|
||||
display: str = {'fabric': 'Fabric', 'forge': 'Forge', 'neoforge': 'NeoForge'}[loader]
|
||||
filename = f"Example v{publication['version']} [{display}] {target['minecraft']}+1.0.0.jar"
|
||||
if loader == 'fabric':
|
||||
metadata = json.dumps({'schemaVersion': 1, 'id': 'example', 'version': publication['version'],
|
||||
'depends': {'minecraft': '~' + target['minecraft']}})
|
||||
else:
|
||||
metadata = (f"modLoader = 'javafml'\nloaderVersion = '[1,)'\nlicense = 'MIT'\n"
|
||||
f"[[mods]]\nmodId = 'example'\nversion = '{publication['version']}'\n"
|
||||
f"[[dependencies.example]]\nmodId = 'minecraft'\n"
|
||||
f"versionRange = '[{target['minecraft']}]'\n")
|
||||
path: Path = folder / filename
|
||||
with zipfile.ZipFile(path, 'w') as archive:
|
||||
archive.writestr(descriptors[loader], metadata)
|
||||
for name in extra:
|
||||
archive.writestr(name, '')
|
||||
return directory
|
||||
|
||||
|
||||
class PlanTests(ReleaseTestCase):
|
||||
def test_markers_are_case_sensitive(self) -> None:
|
||||
for message, channel in [('B+ Beta', 'beta'), ('V+ Release', 'stable'),
|
||||
('b+ beta', 'none'), ('v+ release', 'none'), ('Fix a bug', 'none')]:
|
||||
with self.subTest(message=message):
|
||||
self.event['head_commit']['message'] = message
|
||||
result: dict = release.plan(self.root, self.config, self.event, self.environment)
|
||||
self.assertEqual(channel, result['channel'])
|
||||
if channel == 'beta':
|
||||
self.assertEqual('1.2.3-beta.17.2', result['version'])
|
||||
self.assertEqual('bukkit-beta', result['tag'])
|
||||
elif channel == 'stable':
|
||||
self.assertEqual('1.2.3', result['version'])
|
||||
self.assertEqual('v1.2.3', result['tag'])
|
||||
|
||||
def test_both_markers_fail(self) -> None:
|
||||
self.event['head_commit']['message'] = 'B+ first\nV+ second'
|
||||
with self.assertRaises(ValueError):
|
||||
release.plan(self.root, self.config, self.event, self.environment)
|
||||
|
||||
def test_only_pushed_default_branch_head_can_release(self) -> None:
|
||||
cases: list[tuple[dict, dict]] = [
|
||||
({'ref': 'refs/heads/feature'}, {}), ({'ref': 'refs/tags/V+1.2.3'}, {}),
|
||||
({'deleted': True}, {}), ({'repository': {}}, {}),
|
||||
({'head_commit': None}, {}), ({}, {'GITHUB_EVENT_NAME': 'pull_request'}),
|
||||
]
|
||||
for event_changes, environment_changes in cases:
|
||||
with self.subTest(event=event_changes, environment=environment_changes):
|
||||
self.assertEqual({'channel': 'none'}, release.plan(
|
||||
self.root, self.config, {**self.event, **event_changes},
|
||||
{**self.environment, **environment_changes}))
|
||||
|
||||
def test_head_identity_must_match_event_and_environment(self) -> None:
|
||||
for field in ('after', 'commit', 'environment'):
|
||||
with self.subTest(field=field):
|
||||
event: dict = copy.deepcopy(self.event)
|
||||
environment: dict = self.environment.copy()
|
||||
if field == 'commit':
|
||||
event['head_commit']['id'] = OTHER_SHA
|
||||
elif field == 'environment':
|
||||
environment['GITHUB_SHA'] = 'not-a-sha'
|
||||
else:
|
||||
event['after'] = OTHER_SHA
|
||||
with self.assertRaises(ValueError):
|
||||
release.plan(self.root, self.config, event, environment)
|
||||
|
||||
def test_old_commits_do_not_trigger_release(self) -> None:
|
||||
self.event['commits'] = [{'id': OTHER_SHA, 'message': 'V+ Release'}]
|
||||
self.event['head_commit']['message'] = 'Fix a bug'
|
||||
self.assertEqual({'channel': 'none'}, release.plan(self.root, self.config, self.event, self.environment))
|
||||
|
||||
def test_invalid_or_duplicate_versions_fail(self) -> None:
|
||||
for value in ('', 'exampleVersion=01.2.3\n', 'exampleVersion=1.2\n',
|
||||
'exampleVersion=1.2.3-beta.1\n', 'exampleVersion=1.2.3\nexampleVersion=2.3.4\n'):
|
||||
with self.subTest(value=value):
|
||||
(self.root / 'gradle.properties').write_text(value)
|
||||
with self.assertRaises(ValueError):
|
||||
release.plan(self.root, self.config, self.event, self.environment)
|
||||
|
||||
def test_invalid_run_identity_fails(self) -> None:
|
||||
for key in ('GITHUB_RUN_NUMBER', 'GITHUB_RUN_ATTEMPT'):
|
||||
with self.subTest(key=key), self.assertRaises(ValueError):
|
||||
release.plan(self.root, self.config, self.event, {**self.environment, key: '0'})
|
||||
|
||||
def test_minecraft_file_matrix_contains_all_loaders(self) -> None:
|
||||
(self.root / 'targets.json').write_text(json.dumps({'1.20.1': {}, '1.21.1': {}}))
|
||||
config: dict = {**self.config, 'platform': 'modded', 'targets': 'targets.json'}
|
||||
self.assertEqual(self.matrix(), release.targets(self.root, config))
|
||||
|
||||
def test_minecraft_directory_matrix_matches_files(self) -> None:
|
||||
folder: Path = self.root / 'targets'
|
||||
folder.mkdir()
|
||||
(folder / '1.20.1.json').write_text(json.dumps({'minecraft': '1.20.1',
|
||||
'loaders': ['fabric', 'forge', 'neoforge']}))
|
||||
config: dict = {**self.config, 'platform': 'modded', 'targets': 'targets'}
|
||||
self.assertEqual(self.matrix(('1.20.1',)), release.targets(self.root, config))
|
||||
for minecraft, loaders in [('1.20.2', ['fabric', 'forge', 'neoforge']), ('1.20.1', ['fabric'])]:
|
||||
with self.subTest(minecraft=minecraft, loaders=loaders):
|
||||
(folder / '1.20.1.json').write_text(json.dumps({'minecraft': minecraft, 'loaders': loaders}))
|
||||
with self.assertRaises(ValueError):
|
||||
release.targets(self.root, config)
|
||||
|
||||
def test_empty_or_invalid_minecraft_matrix_fails(self) -> None:
|
||||
for content in ({}, {'invalid': {}}):
|
||||
with self.subTest(content=content):
|
||||
(self.root / 'targets.json').write_text(json.dumps(content))
|
||||
with self.assertRaises(ValueError):
|
||||
release.targets(self.root, {**self.config, 'platform': 'modded', 'targets': 'targets.json'})
|
||||
|
||||
|
||||
class ArtifactTests(ReleaseTestCase):
|
||||
def test_complete_modded_matrix_and_checksums(self) -> None:
|
||||
publication: dict = self.publication(matrix={'include': self.matrix()})
|
||||
assets: dict[str, bytes] = release.collect_artifacts(self.artifact_tree(publication), publication)
|
||||
self.assertEqual(7, len(assets))
|
||||
checksums: bytes = assets.pop('SHA256SUMS-1.2.3.txt')
|
||||
self.assertEqual(''.join(f'{hashlib.sha256(data).hexdigest()} {name}\n'
|
||||
for name, data in sorted(assets.items())).encode(), checksums)
|
||||
|
||||
def test_missing_or_extra_matrix_target_fails(self) -> None:
|
||||
publication: dict = self.publication()
|
||||
directory: Path = self.artifact_tree(publication)
|
||||
(directory / 'release-extra').mkdir()
|
||||
with self.assertRaises(ValueError):
|
||||
release.collect_artifacts(directory, publication)
|
||||
(directory / 'release-extra').rmdir()
|
||||
(directory / 'release-bukkit').rename(directory / 'release-wrong')
|
||||
with self.assertRaises(ValueError):
|
||||
release.collect_artifacts(directory, publication)
|
||||
|
||||
def test_intermediate_artifact_beside_runtime_fails(self) -> None:
|
||||
publication: dict = self.publication()
|
||||
directory: Path = self.artifact_tree(publication)
|
||||
(directory / 'release-bukkit' / 'sources.jar').write_bytes(b'sources')
|
||||
with self.assertRaises(ValueError):
|
||||
release.collect_artifacts(directory, publication)
|
||||
|
||||
def test_symbolic_link_artifacts_fail(self) -> None:
|
||||
publication: dict = self.publication()
|
||||
directory: Path = self.artifact_tree(publication)
|
||||
path: Path = next((directory / 'release-bukkit').iterdir())
|
||||
target: Path = self.root / path.name
|
||||
path.rename(target)
|
||||
path.symlink_to(target)
|
||||
with self.assertRaises(ValueError):
|
||||
release.collect_artifacts(directory, publication)
|
||||
|
||||
def test_bukkit_is_forbidden_in_modded_release(self) -> None:
|
||||
publication: dict = self.publication(matrix={'include': self.matrix(('1.20.1',))})
|
||||
with self.assertRaises(ValueError):
|
||||
release.collect_artifacts(self.artifact_tree(publication, ('plugin.yml',)), publication)
|
||||
|
||||
def test_velocity_is_forbidden_in_bukkit_release(self) -> None:
|
||||
publication: dict = self.publication()
|
||||
with self.assertRaises(ValueError):
|
||||
release.collect_artifacts(self.artifact_tree(publication, ('velocity-plugin.json',)), publication)
|
||||
|
||||
def test_modded_descriptor_is_forbidden_in_bukkit_release(self) -> None:
|
||||
publication: dict = self.publication()
|
||||
with self.assertRaises(ValueError):
|
||||
release.collect_artifacts(self.artifact_tree(publication, ('fabric.mod.json',)), publication)
|
||||
|
||||
def test_required_platform_descriptor_is_checked(self) -> None:
|
||||
publication: dict = self.publication()
|
||||
directory: Path = self.artifact_tree(publication)
|
||||
path: Path = next((directory / 'release-bukkit').iterdir())
|
||||
with zipfile.ZipFile(path, 'w') as archive:
|
||||
archive.writestr('fabric.mod.json', '{}')
|
||||
with self.assertRaises(ValueError):
|
||||
release.collect_artifacts(directory, publication)
|
||||
|
||||
def test_wrong_version_and_embedded_version_suffix_fail(self) -> None:
|
||||
publication: dict = self.publication()
|
||||
directory: Path = self.artifact_tree(publication)
|
||||
path: Path = next((directory / 'release-bukkit').iterdir())
|
||||
for version in ('9.8.7', '11.2.3'):
|
||||
with self.subTest(version=version):
|
||||
changed: Path = path.with_name(f'Example-{version}-bukkit.jar')
|
||||
path.rename(changed)
|
||||
with self.assertRaises(ValueError):
|
||||
release.collect_artifacts(directory, publication)
|
||||
changed.rename(path)
|
||||
|
||||
def test_descriptor_version_must_match_release(self) -> None:
|
||||
publication: dict = self.publication()
|
||||
directory: Path = self.artifact_tree(publication)
|
||||
path: Path = next((directory / 'release-bukkit').iterdir())
|
||||
with zipfile.ZipFile(path, 'w') as archive:
|
||||
archive.writestr('plugin.yml', 'name: Example\nversion: 1.2.4\nmain: example.Plugin\n')
|
||||
with self.assertRaises(ValueError):
|
||||
release.collect_artifacts(directory, publication)
|
||||
|
||||
def test_modded_descriptor_versions_must_match_release(self) -> None:
|
||||
publication: dict = self.publication(matrix={'include': self.matrix(('1.20.1',))})
|
||||
directory: Path = self.artifact_tree(publication)
|
||||
for folder in directory.iterdir():
|
||||
with self.subTest(target=folder.name):
|
||||
path: Path = next(folder.iterdir())
|
||||
original: bytes = path.read_bytes()
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
descriptor: str = archive.namelist()[0]
|
||||
contents: str = archive.read(descriptor).decode().replace('1.2.3', '1.2.4')
|
||||
with zipfile.ZipFile(path, 'w') as archive:
|
||||
archive.writestr(descriptor, contents)
|
||||
with self.assertRaises(ValueError):
|
||||
release.collect_artifacts(directory, publication)
|
||||
path.write_bytes(original)
|
||||
|
||||
def test_modded_filename_must_match_minecraft_target(self) -> None:
|
||||
publication: dict = self.publication(matrix={'include': self.matrix(('1.20.1',))})
|
||||
directory: Path = self.artifact_tree(publication)
|
||||
path: Path = next((directory / 'release-fabric-1.20.1').iterdir())
|
||||
path.rename(path.with_name(path.name.replace('1.20.1', '1.21.1')))
|
||||
with self.assertRaises(ValueError):
|
||||
release.collect_artifacts(directory, publication)
|
||||
|
||||
def test_modded_metadata_must_match_minecraft_target(self) -> None:
|
||||
publication: dict = self.publication(matrix={'include': self.matrix(('1.20.1',))})
|
||||
directory: Path = self.artifact_tree(publication)
|
||||
for folder in directory.iterdir():
|
||||
with self.subTest(target=folder.name):
|
||||
path: Path = next(folder.iterdir())
|
||||
original: bytes = path.read_bytes()
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
descriptor: str = archive.namelist()[0]
|
||||
contents: str = archive.read(descriptor).decode().replace('1.20.1', '1.21.1')
|
||||
with zipfile.ZipFile(path, 'w') as archive:
|
||||
archive.writestr(descriptor, contents)
|
||||
with self.assertRaises(ValueError):
|
||||
release.collect_artifacts(directory, publication)
|
||||
path.write_bytes(original)
|
||||
|
||||
|
||||
class PublicationTests(ReleaseTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.github: FakeGitHub = FakeGitHub()
|
||||
self.assets: dict[str, bytes] = {'Example-1.2.3-bukkit.jar': b'jar', 'SHA256SUMS-1.2.3.txt': b'checksums'}
|
||||
|
||||
def test_new_stable_release_is_published_at_head(self) -> None:
|
||||
release.publish(self.github, self.config, self.publication(), self.assets)
|
||||
record: dict = self.github.releases[1]
|
||||
self.assertEqual(SHA, self.github.tags['v1.2.3'])
|
||||
self.assertFalse(record['draft'])
|
||||
self.assertFalse(record['prerelease'])
|
||||
self.assertEqual('true', record['make_latest'])
|
||||
self.assertEqual(set(self.assets), set(self.github.uploads[1]))
|
||||
|
||||
def test_stable_existing_tag_collision_has_no_writes(self) -> None:
|
||||
self.github.tags['v1.2.3'] = OTHER_SHA
|
||||
with self.assertRaises(ValueError):
|
||||
release.publish(self.github, self.config, self.publication(), self.assets)
|
||||
self.assertEqual([], self.github.writes)
|
||||
self.assertEqual(OTHER_SHA, self.github.tags['v1.2.3'])
|
||||
|
||||
def test_stable_same_content_rerun_has_no_writes(self) -> None:
|
||||
self.github.seed(self.publication(), self.assets, immutable=True)
|
||||
result: str = release.publish(self.github, self.config, self.publication(), self.assets)
|
||||
self.assertIn('already published', result)
|
||||
self.assertEqual([], self.github.writes)
|
||||
|
||||
def test_published_stable_artifacts_cannot_be_replaced(self) -> None:
|
||||
self.github.seed(self.publication(), self.assets, immutable=True)
|
||||
with self.assertRaises(ValueError):
|
||||
release.publish(self.github, self.config, self.publication(),
|
||||
{**self.assets, 'Example-1.2.3-bukkit.jar': b'different'})
|
||||
self.assertEqual([], self.github.writes)
|
||||
|
||||
def test_stable_release_identity_collision_has_no_writes(self) -> None:
|
||||
self.github.seed(self.publication(sha=OTHER_SHA), self.assets)
|
||||
self.github.tags['v1.2.3'] = SHA
|
||||
with self.assertRaises(ValueError):
|
||||
release.publish(self.github, self.config, self.publication(), self.assets)
|
||||
self.assertEqual([], self.github.writes)
|
||||
|
||||
def test_stable_release_cannot_reuse_published_prerelease(self) -> None:
|
||||
record: dict = self.github.seed(self.publication(), self.assets)
|
||||
record['prerelease'] = True
|
||||
with self.assertRaises(ValueError):
|
||||
release.publish(self.github, self.config, self.publication(), self.assets)
|
||||
self.assertEqual([], self.github.writes)
|
||||
|
||||
def test_partially_uploaded_stable_draft_resumes(self) -> None:
|
||||
record: dict = self.github.seed(self.publication(), {'Example-1.2.3-bukkit.jar': b'jar'}, draft=True)
|
||||
original_id: int = self.github.uploads[record['id']]['Example-1.2.3-bukkit.jar']['id']
|
||||
release.publish(self.github, self.config, self.publication(), self.assets)
|
||||
self.assertFalse(self.github.releases[record['id']]['draft'])
|
||||
self.assertEqual(original_id, self.github.uploads[record['id']]['Example-1.2.3-bukkit.jar']['id'])
|
||||
self.assertEqual(set(self.assets), set(self.github.uploads[record['id']]))
|
||||
self.assertEqual(1, len(self.github.releases))
|
||||
|
||||
def test_conflicting_draft_asset_fails_without_replacement(self) -> None:
|
||||
self.github.seed(self.publication(), {'Example-1.2.3-bukkit.jar': b'other'}, draft=True)
|
||||
with self.assertRaises(ValueError):
|
||||
release.publish(self.github, self.config, self.publication(), self.assets)
|
||||
self.assertEqual([], self.github.writes)
|
||||
self.assertTrue(self.github.releases[1]['draft'])
|
||||
|
||||
def test_newer_beta_run_or_attempt_is_preserved(self) -> None:
|
||||
for run, attempt in ((18, 1), (17, 3)):
|
||||
with self.subTest(run=run, attempt=attempt):
|
||||
github: FakeGitHub = FakeGitHub()
|
||||
github.seed(self.publication('beta', run=run, attempt=attempt, sha=OTHER_SHA), self.assets)
|
||||
result: str = release.publish(github, self.config, self.publication('beta'), self.assets)
|
||||
self.assertIn('Skipped', result)
|
||||
self.assertEqual([], github.writes)
|
||||
self.assertEqual(OTHER_SHA, github.tags['bukkit-beta'])
|
||||
|
||||
def test_successful_beta_replaces_assets_and_remains_prerelease(self) -> None:
|
||||
previous: dict = self.publication('beta', run=16, attempt=1, sha=OTHER_SHA, version='1.2.3-beta.16.1')
|
||||
record: dict = self.github.seed(previous, {'old.jar': b'old'})
|
||||
release.publish(self.github, self.config, self.publication('beta'), self.assets)
|
||||
current: dict = self.github.releases[record['id']]
|
||||
self.assertEqual(SHA, self.github.tags['bukkit-beta'])
|
||||
self.assertTrue(current['prerelease'])
|
||||
self.assertFalse(current['draft'])
|
||||
self.assertEqual('false', current['make_latest'])
|
||||
self.assertEqual(set(self.assets), set(self.github.uploads[record['id']]))
|
||||
|
||||
def test_failed_beta_upload_preserves_tag_and_assets_in_recoverable_draft(self) -> None:
|
||||
previous: dict = self.publication('beta', run=16, attempt=1, sha=OTHER_SHA, version='1.2.3-beta.16.1')
|
||||
record: dict = self.github.seed(previous, {'old.jar': b'old'})
|
||||
self.github.fail_upload = 'SHA256SUMS-1.2.3.txt'
|
||||
with self.assertRaises(RuntimeError):
|
||||
release.publish(self.github, self.config, self.publication('beta'), self.assets)
|
||||
self.assertEqual(OTHER_SHA, self.github.tags['bukkit-beta'])
|
||||
self.assertTrue(self.github.releases[record['id']]['draft'])
|
||||
self.assertEqual(17, release.release_metadata(self.github.releases[record['id']])['run'])
|
||||
self.assertIn('old.jar', self.github.uploads[record['id']])
|
||||
self.assertFalse(any(method == 'DELETE' or path.startswith('git/refs')
|
||||
for method, path in self.github.writes))
|
||||
|
||||
def test_failed_beta_cleanup_resumes_without_reuploading_verified_assets(self) -> None:
|
||||
previous: dict = self.publication('beta', run=16, attempt=1, sha=OTHER_SHA, version='1.2.3-beta.16.1')
|
||||
record: dict = self.github.seed(previous, {'old.jar': b'old'})
|
||||
self.github.fail_delete = self.github.uploads[record['id']]['old.jar']['id']
|
||||
with self.assertRaises(RuntimeError):
|
||||
release.publish(self.github, self.config, self.publication('beta'), self.assets)
|
||||
self.assertTrue(self.github.releases[record['id']]['draft'])
|
||||
self.assertEqual(OTHER_SHA, self.github.tags['bukkit-beta'])
|
||||
uploaded_ids: dict[str, int] = {name: self.github.uploads[record['id']][name]['id'] for name in self.assets}
|
||||
self.github.fail_delete = None
|
||||
release.publish(self.github, self.config, self.publication('beta'), self.assets)
|
||||
self.assertFalse(self.github.releases[record['id']]['draft'])
|
||||
self.assertEqual(SHA, self.github.tags['bukkit-beta'])
|
||||
self.assertEqual(uploaded_ids, {name: entry['id'] for name, entry in self.github.uploads[record['id']].items()})
|
||||
|
||||
def test_incomplete_newer_beta_draft_blocks_older_run(self) -> None:
|
||||
self.github.seed(self.publication('beta', run=18, sha=OTHER_SHA), {}, draft=True)
|
||||
result: str = release.publish(self.github, self.config, self.publication('beta'), self.assets)
|
||||
self.assertIn('Skipped', result)
|
||||
self.assertEqual([], self.github.writes)
|
||||
self.assertTrue(self.github.releases[1]['draft'])
|
||||
|
||||
def test_starter_upload_in_draft_is_replaced_before_publication(self) -> None:
|
||||
self.github.seed(self.publication(), {'Example-1.2.3-bukkit.jar': b''}, draft=True)
|
||||
starter: dict = self.github.uploads[1]['Example-1.2.3-bukkit.jar']
|
||||
identifier: int = starter['id']
|
||||
starter.update({'state': 'starter', 'digest': None})
|
||||
release.publish(self.github, self.config, self.publication(), self.assets)
|
||||
self.assertFalse(self.github.releases[1]['draft'])
|
||||
self.assertNotEqual(identifier, self.github.uploads[1]['Example-1.2.3-bukkit.jar']['id'])
|
||||
self.assertEqual(digest(b'jar'), self.github.uploads[1]['Example-1.2.3-bukkit.jar']['digest'])
|
||||
|
||||
def test_invalid_upload_digest_does_not_create_tag_or_publish(self) -> None:
|
||||
self.github.invalid_receipt = True
|
||||
with self.assertRaises(ValueError):
|
||||
release.publish(self.github, self.config, self.publication(), self.assets)
|
||||
self.assertEqual({}, self.github.tags)
|
||||
self.assertTrue(self.github.releases[1]['draft'])
|
||||
|
||||
def test_beta_will_not_replace_stable_or_immutable_release(self) -> None:
|
||||
for immutable, prerelease in ((True, True), (False, False)):
|
||||
with self.subTest(immutable=immutable, prerelease=prerelease):
|
||||
github: FakeGitHub = FakeGitHub()
|
||||
record: dict = github.seed(self.publication('beta', run=16, sha=OTHER_SHA), self.assets,
|
||||
immutable=immutable)
|
||||
record['prerelease'] = prerelease
|
||||
with self.assertRaises(ValueError):
|
||||
release.publish(github, self.config, self.publication('beta'), self.assets)
|
||||
self.assertEqual([], github.writes)
|
||||
|
||||
def test_unowned_existing_release_is_preserved(self) -> None:
|
||||
record: dict = self.github.seed(self.publication(), self.assets)
|
||||
record['body'] = 'Published manually'
|
||||
with self.assertRaises(ValueError):
|
||||
release.publish(self.github, self.config, self.publication(), self.assets)
|
||||
self.assertEqual([], self.github.writes)
|
||||
|
||||
def test_beta_tag_without_release_is_preserved(self) -> None:
|
||||
self.github.tags['bukkit-beta'] = OTHER_SHA
|
||||
with self.assertRaises(ValueError):
|
||||
release.publish(self.github, self.config, self.publication('beta'), self.assets)
|
||||
self.assertEqual([], self.github.writes)
|
||||
|
||||
def test_modded_stable_release_never_changes_latest(self) -> None:
|
||||
self.github.latest = {'tag_name': 'v1.0.0'}
|
||||
release.publish(self.github, {**self.config, 'platform': 'modded'}, self.publication(), self.assets)
|
||||
self.assertFalse(self.github.releases[1]['prerelease'])
|
||||
self.assertEqual('false', self.github.releases[1]['make_latest'])
|
||||
self.assertEqual('v1.0.0', self.github.latest['tag_name'])
|
||||
|
||||
def test_bukkit_backport_does_not_replace_newer_latest(self) -> None:
|
||||
for latest in ('v1.3.0', 'v2.0.0', 'v1.2.3'):
|
||||
with self.subTest(latest=latest):
|
||||
github: FakeGitHub = FakeGitHub()
|
||||
github.latest = {'tag_name': latest}
|
||||
release.publish(github, self.config, self.publication(), self.assets)
|
||||
self.assertEqual('false', github.releases[1]['make_latest'])
|
||||
self.assertEqual(latest, github.latest['tag_name'])
|
||||
|
||||
def test_newer_bukkit_release_becomes_latest(self) -> None:
|
||||
self.github.latest = {'tag_name': 'v1.2.2'}
|
||||
release.publish(self.github, self.config, self.publication(), self.assets)
|
||||
self.assertEqual('true', self.github.releases[1]['make_latest'])
|
||||
self.assertEqual('v1.2.3', self.github.latest['tag_name'])
|
||||
|
||||
|
||||
class GitHubReadTests(ReleaseTestCase):
|
||||
def test_annotated_tag_resolves_to_commit(self) -> None:
|
||||
github: release.GitHub = release.GitHub('example/product', 'test-token')
|
||||
responses: dict[str, dict] = {
|
||||
'git/ref/tags/v1.2.3': {'object': {'type': 'tag', 'sha': OTHER_SHA}},
|
||||
f'git/tags/{OTHER_SHA}': {'object': {'type': 'commit', 'sha': SHA}},
|
||||
}
|
||||
with patch.object(github, 'request', side_effect=lambda method, path, **options: responses[path]):
|
||||
self.assertEqual(SHA, github.tag_commit('v1.2.3'))
|
||||
|
||||
def test_noncommit_tag_cannot_be_published(self) -> None:
|
||||
github: release.GitHub = release.GitHub('example/product', 'test-token')
|
||||
with patch.object(github, 'request', return_value={'object': {'type': 'tree', 'sha': SHA}}):
|
||||
with self.assertRaises(ValueError):
|
||||
github.tag_commit('v1.2.3')
|
||||
|
||||
def test_assets_include_all_pages(self) -> None:
|
||||
github: release.GitHub = release.GitHub('example/product', 'test-token')
|
||||
first: list[dict] = [{'id': number, 'name': f'{number}.jar'} for number in range(100)]
|
||||
last: list[dict] = [{'id': 100, 'name': 'last.jar'}]
|
||||
with patch.object(github, 'request', side_effect=[first, last]):
|
||||
self.assertEqual(first + last, github.assets(1))
|
||||
|
||||
def test_invalid_repository_is_rejected(self) -> None:
|
||||
for repository in ('example', '../example/product', 'example/product/extra'):
|
||||
with self.subTest(repository=repository), self.assertRaises(ValueError):
|
||||
release.GitHub(repository, 'test-token')
|
||||
|
||||
|
||||
class DependencyResolutionTests(ReleaseTestCase):
|
||||
def test_branch_resolves_to_full_commit_sha(self) -> None:
|
||||
with patch.object(release.GitHub, 'request', return_value={'sha': SHA}) as request:
|
||||
self.assertEqual(SHA, release.resolve_dependency('example/product', 'master', 'test-token'))
|
||||
request.assert_called_once_with('GET', 'commits/master')
|
||||
|
||||
def test_full_commit_reference_is_verified(self) -> None:
|
||||
with patch.object(release.GitHub, 'request', return_value={'sha': SHA}) as request:
|
||||
self.assertEqual(SHA, release.resolve_dependency('example/product', SHA, 'test-token'))
|
||||
request.assert_called_once_with('GET', f'commits/{SHA}')
|
||||
|
||||
def test_incomplete_or_invalid_resolved_sha_fails(self) -> None:
|
||||
for sha in ('', 'a' * 39, 'a' * 41, 'A' * 40, 'g' * 40, 'master'):
|
||||
with self.subTest(sha=sha), patch.object(release.GitHub, 'request', return_value={'sha': sha}):
|
||||
with self.assertRaises(ValueError):
|
||||
release.resolve_dependency('example/product', 'master', 'test-token')
|
||||
|
||||
def test_reference_is_encoded_as_single_url_path_component(self) -> None:
|
||||
with patch.object(release.GitHub, 'request', return_value={'sha': SHA}) as request:
|
||||
self.assertEqual(SHA, release.resolve_dependency('example/product', 'feature/next+release #?', 'test-token'))
|
||||
request.assert_called_once_with('GET', 'commits/feature%2Fnext%2Brelease%20%23%3F')
|
||||
|
||||
|
||||
class MainTests(ReleaseTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.config.update({'volmlibRef': 'master', 'wormholesRef': 'release/api'})
|
||||
(self.root / '.github').mkdir()
|
||||
(self.root / '.github/release.json').write_text(json.dumps(self.config))
|
||||
self.event['head_commit']['message'] = 'B+ Publish beta'
|
||||
(self.root / 'event.json').write_text(json.dumps(self.event))
|
||||
self.environment.update({
|
||||
'GITHUB_EVENT_PATH': str(self.root / 'event.json'),
|
||||
'GITHUB_OUTPUT': str(self.root / 'output.txt'),
|
||||
'GITHUB_REPOSITORY': 'example/product',
|
||||
'GH_TOKEN': 'test-token',
|
||||
})
|
||||
script = patch.object(release, '__file__', str(self.root / '.github/release.py'))
|
||||
script.start()
|
||||
self.addCleanup(script.stop)
|
||||
output = patch('sys.stdout', new=io.StringIO())
|
||||
output.start()
|
||||
self.addCleanup(output.stop)
|
||||
|
||||
def test_plan_freezes_each_dependency_branch_once(self) -> None:
|
||||
with patch.dict(release.os.environ, self.environment, clear=True), \
|
||||
patch.object(release.sys, 'argv', ['release.py', 'plan']), \
|
||||
patch.object(release.GitHub, 'request', side_effect=[{'sha': SHA}, {'sha': OTHER_SHA}]) as request:
|
||||
release.main()
|
||||
outputs: dict[str, str] = dict(line.split('=', 1) for line in (self.root / 'output.txt').read_text().splitlines())
|
||||
self.assertEqual(SHA, outputs['volmlib_ref'])
|
||||
self.assertEqual(OTHER_SHA, outputs['wormholes_ref'])
|
||||
self.assertEqual('1.2.3-beta.17.2', outputs['version'])
|
||||
self.assertEqual('2', outputs['attempt'])
|
||||
self.assertEqual(2, request.call_count)
|
||||
self.assertEqual(['commits/master', 'commits/release%2Fapi'], [call.args[1] for call in request.call_args_list])
|
||||
|
||||
def test_unmarked_push_never_resolves_dependencies(self) -> None:
|
||||
self.event['head_commit']['message'] = 'Fix a bug'
|
||||
(self.root / 'event.json').write_text(json.dumps(self.event))
|
||||
with patch.dict(release.os.environ, self.environment, clear=True), \
|
||||
patch.object(release.sys, 'argv', ['release.py', 'plan']), \
|
||||
patch.object(release, 'resolve_dependency', side_effect=AssertionError('Unexpected dependency lookup')):
|
||||
release.main()
|
||||
self.assertEqual('channel=none\n', (self.root / 'output.txt').read_text())
|
||||
|
||||
def test_publish_rerun_uses_original_planned_attempt_and_dependency_commits(self) -> None:
|
||||
publication: dict = self.publication('beta')
|
||||
artifacts: Path = self.artifact_tree(publication)
|
||||
github: FakeGitHub = FakeGitHub()
|
||||
self.environment.update({'GITHUB_RUN_ATTEMPT': '3', 'RELEASE_ATTEMPT': '2',
|
||||
'RELEASE_VOLMLIB_REF': SHA, 'RELEASE_WORMHOLES_REF': OTHER_SHA})
|
||||
with patch.dict(release.os.environ, self.environment, clear=True), \
|
||||
patch.object(release.sys, 'argv', ['release.py', 'publish', '--artifacts', str(artifacts)]), \
|
||||
patch.object(release, 'GitHub', return_value=github), \
|
||||
patch.object(release, 'resolve_dependency', side_effect=AssertionError('Dependencies must stay frozen')):
|
||||
release.main()
|
||||
metadata: dict = release.release_metadata(github.releases[1])
|
||||
self.assertEqual('1.2.3-beta.17.2', metadata['version'])
|
||||
self.assertEqual(2, metadata['attempt'])
|
||||
self.assertEqual(SHA, metadata['volmlib_ref'])
|
||||
self.assertEqual(OTHER_SHA, metadata['wormholes_ref'])
|
||||
self.assertFalse(github.releases[1]['draft'])
|
||||
self.assertEqual({'Example-1.2.3-beta.17.2-bukkit.jar', 'SHA256SUMS-1.2.3-beta.17.2.txt'},
|
||||
set(github.uploads[1]))
|
||||
writes: list[tuple[str, str]] = github.writes.copy()
|
||||
release.os.environ['GITHUB_RUN_ATTEMPT'] = '4'
|
||||
release.main()
|
||||
self.assertEqual(writes, github.writes)
|
||||
|
||||
def test_publish_rejects_unresolved_dependency_reference_before_writing(self) -> None:
|
||||
artifacts: Path = self.artifact_tree(self.publication('beta'))
|
||||
github: FakeGitHub = FakeGitHub()
|
||||
self.environment.update({'RELEASE_ATTEMPT': '2', 'RELEASE_VOLMLIB_REF': SHA,
|
||||
'RELEASE_WORMHOLES_REF': OTHER_SHA})
|
||||
for key in ('RELEASE_VOLMLIB_REF', 'RELEASE_WORMHOLES_REF'):
|
||||
with self.subTest(key=key), \
|
||||
patch.dict(release.os.environ, {**self.environment, key: 'master'}, clear=True), \
|
||||
patch.object(release.sys, 'argv', ['release.py', 'publish', '--artifacts', str(artifacts)]), \
|
||||
patch.object(release, 'GitHub', return_value=github):
|
||||
with self.assertRaises(ValueError):
|
||||
release.main()
|
||||
self.assertEqual([], github.writes)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,157 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/**'
|
||||
- '**/*.gradle'
|
||||
- '**/gradle.properties'
|
||||
- 'gradle/**/*.json'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Test release handling
|
||||
run: python3 -m unittest discover -s .github -p 'test_release.py' -v
|
||||
|
||||
plan:
|
||||
needs: validate
|
||||
if: github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
channel: ${{ steps.release.outputs.channel }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
attempt: ${{ steps.release.outputs.attempt }}
|
||||
version_property: ${{ steps.release.outputs.version_property }}
|
||||
matrix: ${{ steps.release.outputs.matrix }}
|
||||
volmlib_ref: ${{ steps.release.outputs.volmlib_ref }}
|
||||
wormholes_ref: ${{ steps.release.outputs.wormholes_ref }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Select release channel and targets
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 .github/release.py plan
|
||||
|
||||
build:
|
||||
needs: plan
|
||||
if: needs.plan.outputs.channel != 'none'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 3
|
||||
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
|
||||
env:
|
||||
ORG_GRADLE_PROJECT_localVolmLibDirectory: ${{ github.workspace }}/VolmLib
|
||||
RELEASE_VERSION: ${{ needs.plan.outputs.version }}
|
||||
VERSION_PROPERTY: ${{ needs.plan.outputs.version_property }}
|
||||
RELEASE_LOADER: ${{ matrix.loader }}
|
||||
MINECRAFT_VERSION: ${{ matrix.minecraft }}
|
||||
steps:
|
||||
- name: Check out release sources
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
path: project
|
||||
submodules: recursive
|
||||
persist-credentials: false
|
||||
- name: Check out VolmLib
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
repository: VolmitSoftware/VolmLib
|
||||
ref: ${{ needs.plan.outputs.volmlib_ref }}
|
||||
path: VolmLib
|
||||
persist-credentials: false
|
||||
- name: Check out Wormholes API sources
|
||||
if: needs.plan.outputs.wormholes_ref != ''
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
repository: VolmitSoftware/Wormholes
|
||||
ref: ${{ needs.plan.outputs.wormholes_ref }}
|
||||
path: WormholesPlugin
|
||||
submodules: recursive
|
||||
persist-credentials: false
|
||||
- name: Set up Java 21 toolchain
|
||||
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- name: Set up Java 25
|
||||
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
- uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6
|
||||
- name: Build Wormholes API
|
||||
if: needs.plan.outputs.wormholes_ref != ''
|
||||
working-directory: WormholesPlugin
|
||||
run: ./gradlew apiJar --no-daemon --max-workers=2 '-Dorg.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8'
|
||||
- name: Locate Wormholes API
|
||||
if: needs.plan.outputs.wormholes_ref != ''
|
||||
shell: bash
|
||||
run: |
|
||||
api_jars=(WormholesPlugin/build/libs/*-api.jar)
|
||||
test "${#api_jars[@]}" -eq 1
|
||||
test -f "${api_jars[0]}"
|
||||
echo "ORG_GRADLE_PROJECT_wormholesApiJar=$PWD/${api_jars[0]}" >> "$GITHUB_ENV"
|
||||
- name: Build and verify release jar
|
||||
working-directory: project
|
||||
shell: bash
|
||||
run: |
|
||||
arguments=(prepareRelease --no-daemon --max-workers=2
|
||||
'-Dorg.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8'
|
||||
"-P$VERSION_PROPERTY=$RELEASE_VERSION")
|
||||
if [[ "$RELEASE_LOADER" != bukkit ]]; then
|
||||
arguments+=("-PreleaseLoader=$RELEASE_LOADER" "-PminecraftVersion=$MINECRAFT_VERSION"
|
||||
-PmoddedBuildParallelism=1 -PmoddedTestForks=1)
|
||||
fi
|
||||
./gradlew "${arguments[@]}"
|
||||
- name: Upload verified jar
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
|
||||
with:
|
||||
name: release-${{ matrix.id }}
|
||||
path: project/build/release/*.jar
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
compression-level: 0
|
||||
|
||||
publish:
|
||||
needs: [plan, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
concurrency:
|
||||
group: release-publication
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Download complete release matrix
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333
|
||||
with:
|
||||
pattern: release-*
|
||||
path: build/release-artifacts
|
||||
- name: Publish verified release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_ATTEMPT: ${{ needs.plan.outputs.attempt }}
|
||||
RELEASE_VOLMLIB_REF: ${{ needs.plan.outputs.volmlib_ref }}
|
||||
RELEASE_WORMHOLES_REF: ${{ needs.plan.outputs.wormholes_ref }}
|
||||
run: python3 .github/release.py publish --artifacts build/release-artifacts
|
||||
@@ -81,3 +81,5 @@ BUILDS/
|
||||
/docs/
|
||||
/.perf/
|
||||
/.codegraph/
|
||||
|
||||
.superpowers/
|
||||
+430
-274
@@ -2,52 +2,71 @@
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed to take away your freedom to share and change the
|
||||
works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all
|
||||
versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use
|
||||
the GNU General Public License for most of our software; it applies also to any other work released this way by its
|
||||
authors. You can apply it to your programs, too.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make
|
||||
sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive
|
||||
source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and
|
||||
that you know you can do these things.
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights.
|
||||
Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it:
|
||||
responsibilities to respect the freedom of others.
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients
|
||||
the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must
|
||||
show them these terms so they know their rights.
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute
|
||||
and/or modify it.
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software.
|
||||
For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems
|
||||
will not be attributed erroneously to authors of previous versions.
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run modified versions of the software inside them, although
|
||||
the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the
|
||||
software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely
|
||||
where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we stand ready to extend this provision to those
|
||||
domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict
|
||||
development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger
|
||||
that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and modification follow.
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
@@ -55,106 +74,142 @@ The precise terms and conditions for copying, distribution and modification foll
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "
|
||||
Licensees" and
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission,
|
||||
other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a
|
||||
work "based on" the earlier work.
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based on the Program.
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily
|
||||
liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy.
|
||||
Propagation includes copying, distribution (with or without modification), making available to the public, and in some
|
||||
countries other activities as well.
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction
|
||||
with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright
|
||||
notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the extent that warranties are provided), that
|
||||
licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a
|
||||
list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means
|
||||
any non-source form of a work.
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body,
|
||||
or, in the case of interfaces specified for a particular programming language, one that is widely used among developers
|
||||
working in that language.
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in
|
||||
the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to
|
||||
enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is
|
||||
available to the public in source code form. A
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used
|
||||
to run it.
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (
|
||||
for an executable work) run the object code and to modify the work, including scripts to control those activities.
|
||||
However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs
|
||||
which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding
|
||||
Source includes interface definition files associated with source files for the work, and the source code for shared
|
||||
libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data
|
||||
communication or control flow between those subprograms and other parts of the work.
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the
|
||||
Corresponding Source.
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same work.
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided
|
||||
the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program.
|
||||
The output from running a covered work is covered by this License only if the output, given its content, constitutes a
|
||||
covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey, without conditions so long as your license
|
||||
otherwise remains in force. You may convey covered works to others for the sole purpose of having them make
|
||||
modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do not control copyright. Those thus making or running
|
||||
the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that
|
||||
prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not
|
||||
allowed; section 10 makes it unnecessary.
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling
|
||||
obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or
|
||||
restricting circumvention of such measures.
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the
|
||||
extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you
|
||||
disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users,
|
||||
your or third parties' legal rights to forbid circumvention of technological measures.
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating
|
||||
that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices
|
||||
of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for
|
||||
a fee.
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source
|
||||
code under the terms of section 4, provided that you also meet all of these conditions:
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
@@ -177,17 +232,22 @@ code under the terms of section 4, provided that you also meet all of these cond
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of
|
||||
the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or
|
||||
distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the
|
||||
compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause
|
||||
this License to apply to the other parts of the aggregate.
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
@@ -230,55 +290,77 @@ machine-readable Corresponding Source under the terms of this License, in one of
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library,
|
||||
need not be included in conveying the object code work.
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used
|
||||
for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In
|
||||
determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a
|
||||
particular product received by a particular user, "normally used" refers to a typical or common use of that class of
|
||||
product, regardless of the status of the particular user or of the way in which the particular user actually uses, or
|
||||
expects or is expected to use, the product. A product is a consumer product regardless of whether the product has
|
||||
substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of
|
||||
the product.
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information
|
||||
required to install and execute modified versions of a covered work in that User Product from a modified version of its
|
||||
Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code
|
||||
is in no case prevented or interfered with solely because modification has been made.
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the
|
||||
conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to
|
||||
the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding
|
||||
Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not
|
||||
apply if neither you nor any third party retains the ability to install modified object code on the User Product (for
|
||||
example, the work has been installed in ROM).
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a requirement to continue to provide support
|
||||
service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product
|
||||
in which it has been modified or installed. Access to a network may be denied when the modification itself materially
|
||||
and adversely affects the operation of the network or violates the rules and protocols for communication across the
|
||||
network.
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format
|
||||
that is publicly documented (and with an implementation available to the public in source code form), and must require
|
||||
no special password or key for unpacking, reading or copying.
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of
|
||||
its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were
|
||||
included in this License, to the extent that they are valid under applicable law. If additional permissions apply only
|
||||
to part of the Program, that part may be used separately under those permissions, but the entire Program remains
|
||||
governed by this License without regard to the additional permissions.
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or
|
||||
from any part of it. (Additional permissions may be written to require their own removal in certain cases when you
|
||||
modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have
|
||||
or can give appropriate copyright permission.
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by
|
||||
the copyright holders of that material) supplement the terms of this License with terms:
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
@@ -303,182 +385,251 @@ the copyright holders of that material) supplement the terms of this License wit
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the
|
||||
Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with
|
||||
a term that is a further restriction, you may remove that term. If a license document contains a further restriction but
|
||||
permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of
|
||||
that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a
|
||||
statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as
|
||||
exceptions; the above requirements apply either way.
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to
|
||||
propagate or modify it is void, and will automatically terminate your rights under this License (including any patent
|
||||
licenses granted under the third paragraph of section 11).
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license from a particular copyright holder is
|
||||
reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b)
|
||||
permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days
|
||||
after the cessation.
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you
|
||||
of the violation by some reasonable means, this is the first time you have received notice of violation of this
|
||||
License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the
|
||||
notice.
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the licenses of parties who have received copies or
|
||||
rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not
|
||||
qualify to receive new licenses for the same material under section 10.
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a
|
||||
covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not
|
||||
require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered
|
||||
work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run,
|
||||
modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third
|
||||
parties with this License.
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or
|
||||
subdividing an organization, or merging organizations. If propagation of a covered work results from an entity
|
||||
transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work
|
||||
the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with
|
||||
reasonable efforts.
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For
|
||||
example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License,
|
||||
and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using,
|
||||
selling, offering for sale, or importing the Program or any portion of it.
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the
|
||||
Program is based. The work thus licensed is called the contributor's "contributor version".
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already
|
||||
acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or
|
||||
selling its contributor version, but do not include claims that would be infringed only as a consequence of further
|
||||
modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent
|
||||
sublicenses in a manner consistent with the requirements of this License.
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential
|
||||
patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its
|
||||
contributor version.
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not
|
||||
to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a
|
||||
patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not
|
||||
available for anyone to copy, free of charge and under the terms of this License, through a publicly available network
|
||||
server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available,
|
||||
or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a
|
||||
manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "
|
||||
Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in
|
||||
a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in
|
||||
that country that you have reason to believe are valid.
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring
|
||||
conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing
|
||||
them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is
|
||||
automatically extended to all recipients of the covered work and works based on it.
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of,
|
||||
or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You
|
||||
may not convey a covered work if you are a party to an arrangement with a third party that is in the business of
|
||||
distributing software, under which you make payment to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a
|
||||
discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from
|
||||
those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered
|
||||
work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to
|
||||
infringement that may otherwise be available to you under applicable patent law.
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this
|
||||
License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to
|
||||
satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence
|
||||
you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further
|
||||
conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License
|
||||
would be to refrain entirely from conveying the Program.
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work
|
||||
licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the
|
||||
resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special
|
||||
requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply
|
||||
to the combination as such.
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to
|
||||
time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new
|
||||
problems or concerns.
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the
|
||||
GNU General Public License "or any later version" applies to it, you have the option of following the terms and
|
||||
conditions either of that numbered version or of any later version published by the Free Software Foundation. If the
|
||||
Program does not specify a version number of the GNU General Public License, you may choose any version ever published
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used,
|
||||
that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the
|
||||
Program.
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different permissions. However, no additional obligations are imposed
|
||||
on any author or copyright holder as a result of your choosing to follow a later version.
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING
|
||||
THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR
|
||||
IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
|
||||
THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU
|
||||
ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO
|
||||
MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL,
|
||||
INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO
|
||||
LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
|
||||
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
|
||||
DAMAGES.
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to
|
||||
their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil
|
||||
liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program
|
||||
in return for a fee.
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve
|
||||
this is to make it free software which everyone can redistribute and change under these terms.
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to
|
||||
most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer
|
||||
to where the full notice is found.
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
@@ -498,21 +649,26 @@ to where the full notice is found.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of
|
||||
course, your program's commands might be different; for a GUI interface, you would use an "about box".
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for
|
||||
the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is
|
||||
a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If
|
||||
this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -25,7 +25,7 @@ Canonical English is defined in the typed Java catalogs under `core/src/main/jav
|
||||
| Spigot / CraftBukkit | plugin jar | 26.1.2 - 26.3 | Managed `iris:*` creation and generation; exact vanilla-slot `/iris replace` is unavailable |
|
||||
| Fabric | mod jar | 26.3 | Server worldgen + client HUD; requires Fabric Loader 0.19.5+ |
|
||||
| Forge | mod jar | 26.3 | Server worldgen + client HUD; current target is Forge 26.3-66.0.8 |
|
||||
| NeoForge | mod jar | 26.3 | Server worldgen + client HUD; current target is NeoForge 26.3.0.33-beta |
|
||||
| NeoForge | mod jar | 26.3 | Server worldgen + client HUD; requires NeoForge 26.3.0.65-beta+; current target is NeoForge 26.3.0.83 |
|
||||
|
||||
Java 25 is required on every platform.
|
||||
|
||||
@@ -248,28 +248,26 @@ which is what a board reader means.
|
||||
|
||||
Requirements: JDK 25 (set `JAVA_HOME` to it). The Gradle wrapper handles everything else.
|
||||
|
||||
```
|
||||
./gradlew buildAllToOut
|
||||
```sh
|
||||
./gradlew buildAll
|
||||
```
|
||||
|
||||
builds every platform artifact into the workspace-level `../PluginOuts/` directory:
|
||||
This builds and verifies the Bukkit plugin, API, SPI and the Fabric, Forge and NeoForge jars
|
||||
for every Minecraft version declared in `gradle/mod-targets.json`. Artifacts stay in the
|
||||
producing module's `build/` directory. The packed Bukkit plugin is in `build/libs/`, the
|
||||
API is in `build/api/`, the SPI is in `spi/build/libs/`, and mod jars are in
|
||||
`adapters/<loader>/build/<minecraft>/libs/`.
|
||||
|
||||
```
|
||||
Iris v<version> [CraftBukkit] <mc>.jar
|
||||
Iris v<version> [Fabric] <mc>+<loader>.jar
|
||||
Iris v<version> [Forge] <mc>+<loader>.jar
|
||||
Iris v<version> [NeoForge] <mc>+<loader>.jar
|
||||
```
|
||||
Use `./gradlew buildBukkit` for the plugin, or `verify<Loader><mc>` for one mod jar,
|
||||
for example `verifyFabric26_2` or `verifyNeoForge1_21_1`. `buildFabric`, `buildForge`
|
||||
and `buildNeoforge` select the `minecraftVersion` Gradle property. `./gradlew checkAll`
|
||||
runs every check plus the complete mod matrix. Forge 1.21.1 compiles its tests because
|
||||
Forge 52 cannot bootstrap Minecraft outside FML.
|
||||
|
||||
Per-platform tasks: `./gradlew buildBukkit`, `buildFabric`, `buildForge`, `buildNeoforge`. The
|
||||
SPI jar (the pure-JVM adapter/platform contract, not the stable plugin API) is built to `spi/build/libs/` by
|
||||
`./gradlew :spi:jar`.
|
||||
|
||||
`./gradlew buildAll` also runs `buildAllToOut` and copies the jars into a consumer dropin tree
|
||||
for a local test server. The consumer directory defaults to `../../[Minecraft Server]/consumers/`
|
||||
when it exists, otherwise `build/consumers/` inside the repo. `-Plocation=/path/to/consumers`
|
||||
changes only the consumer directory. Both tasks copy all four verified platform jars into
|
||||
`../PluginOuts/`.
|
||||
Multiplexor reads `gradle/multiplexor.gradle` to discover targets and build each in a
|
||||
separate source sandbox. Use its project build and deployment commands to export jars
|
||||
or install them into managed test instances. Gradle builds do not copy artifacts to
|
||||
external destinations.
|
||||
|
||||
VolmLib is built from source when a `VolmLib` checkout sits beside the repo or in a parent directory
|
||||
(`VOLMLIB_DIR` or `-PlocalVolmLibDirectory=<path>` pick another one). Without one, or with
|
||||
|
||||
+67
-2
@@ -245,9 +245,15 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
for (int y = groundY + 1; y <= groundY + 32; y++) {
|
||||
assertTrue("Roof at " + x + "," + y, state(blocks, x, y, 0).isAir());
|
||||
}
|
||||
for (int y = 12; y < groundY; y++) {
|
||||
int supportY = x < 2 ? groundY - 33 : 12;
|
||||
for (int y = supportY; y < groundY; y++) {
|
||||
assertTrue("Missing foundation at " + x + "," + y, state(blocks, x, y, 0).isSolid());
|
||||
}
|
||||
if (x < 2) {
|
||||
for (int y = 13; y < supportY; y++) {
|
||||
assertTrue("Cave beneath support at " + x + "," + y, state(blocks, x, y, 0).isAir());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +274,57 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
assertTrue(state(blocks, 0, groundY + 1, 0).isAir());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenPreservesCavesBelowIntactGroundAtTheSkirtEdge() {
|
||||
StructureStart start = desertStart();
|
||||
int groundY = start.getBoundingBox().minY() - 1;
|
||||
int x = start.getBoundingBox().maxX() + 23;
|
||||
int z = start.getBoundingBox().minZ();
|
||||
BoundingBox area = new BoundingBox(x, 0, z, x, 127, z);
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
for (int y = groundY - 35; y <= groundY; y++) {
|
||||
BlockState block = y >= groundY - 34 && y <= groundY - 13
|
||||
? Blocks.CAVE_AIR.defaultBlockState() : Blocks.STONE.defaultBlockState();
|
||||
put(blocks, x, y, z, block);
|
||||
}
|
||||
put(blocks, x, groundY - 1, z, Blocks.DIRT.defaultBlockState());
|
||||
put(blocks, x, groundY, z, Blocks.GRASS_BLOCK.defaultBlockState());
|
||||
NativeStructureTerrainIntegrator.TerrainTarget target = surfaceTarget(start, StructureTerrainMode.FLATTEN);
|
||||
((IrisStructureTerrain) target.terrain()).setHorizontalPadding(24).setFlattenRange(64);
|
||||
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area, List.of(target), (px, pz) -> groundY);
|
||||
|
||||
for (int y = groundY - 34; y <= groundY - 13; y++) {
|
||||
assertEquals("Cave at " + y, Blocks.CAVE_AIR.defaultBlockState(), state(blocks, x, y, z));
|
||||
}
|
||||
assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, x, groundY - 1, z));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenBridgesTheFoundationGapWithoutFillingACaveBelowItsSupport() {
|
||||
StructureStart start = desertStart();
|
||||
int groundY = start.getBoundingBox().minY() - 1;
|
||||
BoundingBox area = new BoundingBox(0, 0, 0, 0, 127, 0);
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
put(blocks, 0, groundY, 0, Blocks.GRASS_BLOCK.defaultBlockState());
|
||||
for (int y = groundY - 11; y <= groundY - 9; y++) {
|
||||
put(blocks, 0, y, 0, Blocks.STONE.defaultBlockState());
|
||||
}
|
||||
for (int y = groundY - 20; y <= groundY - 12; y++) {
|
||||
put(blocks, 0, y, 0, Blocks.CAVE_AIR.defaultBlockState());
|
||||
}
|
||||
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area,
|
||||
List.of(surfaceTarget(start, StructureTerrainMode.FLATTEN)), (x, z) -> groundY);
|
||||
|
||||
for (int y = groundY - 8; y < groundY; y++) {
|
||||
assertEquals("Foundation at " + y, Blocks.STONE.defaultBlockState(), state(blocks, 0, y, 0));
|
||||
}
|
||||
for (int y = groundY - 20; y <= groundY - 12; y++) {
|
||||
assertEquals("Cave at " + y, Blocks.CAVE_AIR.defaultBlockState(), state(blocks, 0, y, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenLeavesBuriedAndSubmergedInstancesUntouched() {
|
||||
StructureStart start = desertStart();
|
||||
@@ -619,10 +676,18 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
for (int z = 0; z <= 8; z++) {
|
||||
int groundY = plan.flattenedHeights().get(NativeStructureTemplateOccupancy.columnKey(x, z)) - 1;
|
||||
assertTrue("Village grade at " + x + "," + z + ": " + groundY, groundY >= 64 && groundY <= 70);
|
||||
for (int y = 20; y <= groundY; y++) {
|
||||
boolean originalFoundation = (x == 0 || x == 8) && (z == 0 || z == 8);
|
||||
int supportY = originalFoundation ? 63 : 20;
|
||||
for (int y = supportY; y <= groundY; y++) {
|
||||
assertTrue("Disconnected village ground at " + x + "," + y + "," + z,
|
||||
state(blocks, x, y, z).isSolid());
|
||||
}
|
||||
if (originalFoundation) {
|
||||
for (int y = 21; y < supportY; y++) {
|
||||
assertTrue("Cave beneath foundation at " + x + "," + y + "," + z,
|
||||
state(blocks, x, y, z).isAir());
|
||||
}
|
||||
}
|
||||
for (int y = groundY + 1; y <= 96; y++) {
|
||||
assertTrue("Roof remains above village at " + x + "," + y + "," + z,
|
||||
state(blocks, x, y, z).isAir());
|
||||
|
||||
+10
-6
@@ -3,6 +3,7 @@ package art.arcane.iris.platform.bukkit.nms.v26_2_R1;
|
||||
import art.arcane.volmlib.nativelib.v26_2_R1.terrain.NativeStructureWorldgenAccess;
|
||||
|
||||
import art.arcane.iris.structure.placement.IrisStructureTerrain;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
|
||||
import art.arcane.volmlib.util.structure.StructureTerrainMode;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructurePostProcessor;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructureTerrainIntegrator;
|
||||
@@ -230,8 +231,9 @@ public class NativeStructureWorldgenAccessTest {
|
||||
recording.world(), generationCenter,
|
||||
(x, z) -> SURFACE_FIRST_FREE_Y,
|
||||
(x, z) -> FLOOR_FIRST_FREE_Y,
|
||||
true,
|
||||
position -> false);
|
||||
true, true,
|
||||
position -> false,
|
||||
NativeBlockChangeSet.disabled());
|
||||
int x = generationCenter.getMiddleBlockX();
|
||||
int z = generationCenter.getMiddleBlockZ();
|
||||
|
||||
@@ -248,8 +250,9 @@ public class NativeStructureWorldgenAccessTest {
|
||||
recording.world(), generationCenter(),
|
||||
(x, z) -> SURFACE_FIRST_FREE_Y,
|
||||
(x, z) -> FLOOR_FIRST_FREE_Y,
|
||||
false,
|
||||
position::equals);
|
||||
false, true,
|
||||
position::equals,
|
||||
NativeBlockChangeSet.disabled());
|
||||
|
||||
assertSame(Blocks.DIRT, access.getBlockState(position).getBlock());
|
||||
assertFalse(access.ensureCanWrite(position));
|
||||
@@ -319,8 +322,9 @@ public class NativeStructureWorldgenAccessTest {
|
||||
recording.world(), generationCenter(),
|
||||
(x, z) -> SURFACE_FIRST_FREE_Y,
|
||||
(x, z) -> FLOOR_FIRST_FREE_Y,
|
||||
false,
|
||||
position -> false);
|
||||
false, true,
|
||||
position -> false,
|
||||
NativeBlockChangeSet.disabled());
|
||||
}
|
||||
|
||||
private static ChunkPos generationCenter() {
|
||||
|
||||
+19
-8
@@ -9,6 +9,7 @@ import art.arcane.iris.structure.nativegen.NativeStructureVolumeIndex;
|
||||
import art.arcane.iris.generation.runtime.IrisEngine;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume;
|
||||
import art.arcane.volmlib.nativelib.terrain.structure.StructureTerrainSettings;
|
||||
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import net.minecraft.SharedConstants;
|
||||
@@ -69,7 +70,8 @@ public class NativeStructureVolumeIndexTest {
|
||||
StructureStart start = swampHut(0, 0, 4, 6);
|
||||
BoundingBox bounds = start.getPieces().getFirst().getBoundingBox();
|
||||
|
||||
KList<NativeStructureVolume> volumes = NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, start);
|
||||
KList<NativeStructureVolume> volumes = NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, start,
|
||||
StructureTerrainSettings.SOURCE, (x, z) -> 63);
|
||||
|
||||
assertEquals(1, volumes.size());
|
||||
NativeStructureVolume volume = volumes.getFirst();
|
||||
@@ -84,8 +86,10 @@ public class NativeStructureVolumeIndexTest {
|
||||
|
||||
@Test
|
||||
public void invalidStartsContributeNoVolumes() {
|
||||
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, StructureStart.INVALID_START));
|
||||
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, null));
|
||||
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, StructureStart.INVALID_START,
|
||||
StructureTerrainSettings.SOURCE, (x, z) -> 63));
|
||||
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, null,
|
||||
StructureTerrainSettings.SOURCE, (x, z) -> 63));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -323,7 +327,7 @@ public class NativeStructureVolumeIndexTest {
|
||||
BlockingResolver coordination = new BlockingResolver();
|
||||
NativeStructureVolumeIndex.OriginResolver volumes = (engine, chunkX, chunkZ) ->
|
||||
new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ,
|
||||
-256, 0, -256, 256, 255, 256));
|
||||
-256, 0, -256, 256, 255, 256, null));
|
||||
NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting((engine, chunkX, chunkZ) -> {
|
||||
coordination.volumesAt(engine, chunkX, chunkZ);
|
||||
return volumes.volumesAt(engine, chunkX, chunkZ);
|
||||
@@ -369,7 +373,7 @@ public class NativeStructureVolumeIndexTest {
|
||||
terrain.join();
|
||||
}
|
||||
return new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ,
|
||||
-256, 0, -256, 256, 100, 256));
|
||||
-256, 0, -256, 256, 100, 256, null));
|
||||
});
|
||||
ExecutorService owner = Executors.newSingleThreadExecutor();
|
||||
ForkJoinPool workers = new ForkJoinPool(2);
|
||||
@@ -493,7 +497,13 @@ public class NativeStructureVolumeIndexTest {
|
||||
private static Map<?, ?> cache(NativeStructureVolumeIndex index, String name) throws Exception {
|
||||
Field field = NativeStructureVolumeIndex.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
return (Map<?, ?>) field.get(index);
|
||||
Object value = field.get(index);
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
return map;
|
||||
}
|
||||
Field entries = value.getClass().getDeclaredField("entries");
|
||||
entries.setAccessible(true);
|
||||
return (Map<?, ?>) entries.get(value);
|
||||
}
|
||||
|
||||
private static StructureStart swampHut(int chunkX, int chunkZ, int x, int z) {
|
||||
@@ -544,7 +554,8 @@ public class NativeStructureVolumeIndexTest {
|
||||
if (chunkX != originChunkX || chunkZ != originChunkZ) {
|
||||
return NativeStructureVolume.NONE;
|
||||
}
|
||||
return NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, swampHut(chunkX, chunkZ, 4, 6));
|
||||
return NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, swampHut(chunkX, chunkZ, 4, 6),
|
||||
StructureTerrainSettings.SOURCE, (x, z) -> 63);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,7 +607,7 @@ public class NativeStructureVolumeIndexTest {
|
||||
return new KList<>(new NativeStructureVolume(
|
||||
chunkX + ":" + chunkZ,
|
||||
0, 0, 0,
|
||||
15, 255, 15));
|
||||
15, 255, 15, null));
|
||||
}
|
||||
}
|
||||
private record ScopedEngine(IrisEngine engine, AtomicInteger scopes) {
|
||||
|
||||
+563
@@ -0,0 +1,563 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportProgress;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportResult;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.dimension.LevelStem;
|
||||
import net.minecraft.world.level.levelgen.Aquifer;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
|
||||
import net.minecraft.world.level.levelgen.NoiseRouter;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintStream;
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Enumeration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.SortedMap;
|
||||
import java.util.TreeMap;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
public final class DatapackCostProbe {
|
||||
private static final String LOG_PREFIX = "[datapack-cost]";
|
||||
static final String VANILLA_PRESET = "minecraft:normal";
|
||||
private static final int WARMUP_ORIGIN = -4096;
|
||||
private static final int MINIMUM_CALLS = 8;
|
||||
private static final int TOP_OFFENDERS = 10;
|
||||
private static final long ANALYSIS_STACK_BYTES = 1L << 30;
|
||||
private static final double MEGABYTE = 1024.0 * 1024.0;
|
||||
private static final double NANOS_PER_SECOND = 1_000_000_000.0;
|
||||
|
||||
private DatapackCostProbe() {
|
||||
}
|
||||
|
||||
public static void main(String[] arguments) {
|
||||
PrintStream diagnostics = System.err;
|
||||
try {
|
||||
execute(Configuration.parse(arguments));
|
||||
} catch (Throwable failure) {
|
||||
failure.printStackTrace(diagnostics);
|
||||
diagnostics.flush();
|
||||
System.exit(1);
|
||||
}
|
||||
System.out.flush();
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
private static void execute(Configuration configuration) throws Exception {
|
||||
HeadlessNativeBootstrap.initialize();
|
||||
Files.createDirectories(configuration.work());
|
||||
Path work = Files.createTempDirectory(configuration.work(), "run-");
|
||||
List<HeadlessNativeRegistries> loaded = new ArrayList<>(2);
|
||||
try {
|
||||
log("exporting " + configuration.dimension() + " from " + configuration.pack());
|
||||
ExportRun export = export(configuration, work);
|
||||
log(String.format(Locale.ROOT, "exported in %.1f s: %d files, %d density functions", export.stats().seconds(),
|
||||
export.stats().files(), export.stats().densityFunctions()));
|
||||
DensityJsonBreakdown.Report densityJson = onLargeStack("density-json",
|
||||
() -> DensityJsonBreakdown.analyze(export.files(), configuration.dimension()).report());
|
||||
Path datapack = work.resolve("datapack");
|
||||
writeFiles(export.files(), datapack);
|
||||
Path vanillaPack = Files.createDirectories(work.resolve("vanilla"));
|
||||
ExportStats exportStats = export.stats();
|
||||
export.files().clear();
|
||||
|
||||
long baseline = settledHeap();
|
||||
long loadStarted = System.nanoTime();
|
||||
HeadlessNativeRegistries vanilla = isolatedLoad(vanillaPack);
|
||||
double vanillaLoad = (System.nanoTime() - loadStarted) / NANOS_PER_SECOND;
|
||||
loaded.add(vanilla);
|
||||
long vanillaHeap = settledHeap();
|
||||
loadStarted = System.nanoTime();
|
||||
HeadlessNativeRegistries exported = isolatedLoad(datapack);
|
||||
double exportLoad = (System.nanoTime() - loadStarted) / NANOS_PER_SECOND;
|
||||
loaded.add(exported);
|
||||
long exportHeap = settledHeap();
|
||||
log(String.format(Locale.ROOT, "registries loaded: vanilla %.2f s, export %.2f s", vanillaLoad, exportLoad));
|
||||
|
||||
WorldgenStageBench vanillaBench = new WorldgenStageBench(new WorldgenStageBench.Options("vanilla",
|
||||
presetOverworld(vanilla.registries(), VANILLA_PRESET), vanilla.registries(), configuration.seed()));
|
||||
warm(vanillaBench, configuration);
|
||||
long vanillaSamplers = settledHeap();
|
||||
WorldgenStageBench exportBench = new WorldgenStageBench(new WorldgenStageBench.Options("export",
|
||||
exportedOverworld(exported.registries()), exported.registries(), configuration.seed()));
|
||||
warm(exportBench, configuration);
|
||||
long exportSamplers = settledHeap();
|
||||
|
||||
double loadStart = ManagementFactory.getOperatingSystemMXBean().getSystemLoadAverage();
|
||||
Measurement vanillaMeasurement = new Measurement(vanillaBench);
|
||||
Measurement exportMeasurement = new Measurement(exportBench);
|
||||
measure(List.of(vanillaMeasurement, exportMeasurement), configuration);
|
||||
double loadEnd = ManagementFactory.getOperatingSystemMXBean().getSystemLoadAverage();
|
||||
Map<String, GeneratorStats> generators = new LinkedHashMap<>();
|
||||
generators.put("vanilla", vanillaMeasurement.stats(vanillaLoad, vanillaHeap - baseline, vanillaSamplers - exportHeap));
|
||||
generators.put("export", exportMeasurement.stats(exportLoad, exportHeap - vanillaHeap, exportSamplers - vanillaSamplers));
|
||||
|
||||
log("analyzing density function graphs");
|
||||
Map<String, List<DensityCostAnalysis.RootCost>> roots = new LinkedHashMap<>();
|
||||
roots.put("vanilla", onLargeStack("density-cost-vanilla", () -> analyzeRoots(vanillaBench.settings(), configuration.dimension())));
|
||||
roots.put("export", onLargeStack("density-cost-export", () -> analyzeRoots(exportBench.settings(), configuration.dimension())));
|
||||
|
||||
Report report = new Report(configuration.pack().toString(), configuration.dimension(), configuration.seed(),
|
||||
configuration.rounds() * configuration.side() * configuration.side(), configuration.warmup() * configuration.warmup(),
|
||||
configuration.rounds(), new MachineLoad(Runtime.getRuntime().availableProcessors(), loadStart, loadEnd),
|
||||
"single thread for chunk stages and calls, vanilla and export interleaved per round; registry load uses HeadlessNativeRegistries' 4-thread loader; export uses Iris's own pools",
|
||||
"feature decoration not measured: Paper's applyBiomeDecoration needs a live ServerLevel and the headless feature world lives in test fixtures",
|
||||
exportStats, generators, densityJson, roots, definitions());
|
||||
writeReport(report, configuration.output());
|
||||
System.out.println(table(report));
|
||||
log("report written to " + configuration.output());
|
||||
} finally {
|
||||
for (HeadlessNativeRegistries registries : loaded) {
|
||||
registries.close();
|
||||
}
|
||||
deleteTree(work);
|
||||
}
|
||||
}
|
||||
|
||||
private static ExportRun export(Configuration configuration, Path work) throws Exception {
|
||||
Path exportRoot = Files.createDirectories(work.resolve("exports"));
|
||||
StageClock clock = new StageClock();
|
||||
WorldgenExportResult result;
|
||||
long started;
|
||||
long finished;
|
||||
try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace(new RealPackProbeSupport.WorkspaceOptions(
|
||||
configuration.pack().toFile(), configuration.dimension(), LOG_PREFIX, new HeadlessNativeRuntime(), work))) {
|
||||
WorldgenExportRequest request = WorldgenExportRequest.builder()
|
||||
.pack(workspace.pack().toPath())
|
||||
.dimension(configuration.dimension())
|
||||
.exportRoot(exportRoot)
|
||||
.objects(true)
|
||||
.build();
|
||||
started = System.nanoTime();
|
||||
clock.start(started);
|
||||
result = WorldgenDatapackExporter.create().export(request, clock);
|
||||
finished = System.nanoTime();
|
||||
}
|
||||
if (!result.isSuccess()) {
|
||||
throw new IllegalStateException("Export failed: " + result.message() + " " + result.diagnostics());
|
||||
}
|
||||
SortedMap<String, byte[]> files = zipEntries(result.output());
|
||||
ExportStats stats = new ExportStats((finished - started) / NANOS_PER_SECOND, clock.seconds(finished), Files.size(result.output()),
|
||||
files.size(), count(files, "/worldgen/density_function/"), bytes(files, "/worldgen/density_function/"),
|
||||
count(files, "/worldgen/noise/"), count(files, "/worldgen/biome/"), result.diagnostics().size());
|
||||
Files.delete(result.output());
|
||||
return new ExportRun(stats, files);
|
||||
}
|
||||
|
||||
private static void warm(WorldgenStageBench bench, Configuration configuration) {
|
||||
long started = System.nanoTime();
|
||||
bench.chunks(WARMUP_ORIGIN, WARMUP_ORIGIN, configuration.warmup(), new WorldgenStageBench.ChunkSamples());
|
||||
WorldgenStageBench.CallBudget budget = new WorldgenStageBench.CallBudget(MINIMUM_CALLS * 4, MINIMUM_CALLS,
|
||||
TimeUnit.SECONDS.toNanos(configuration.callSeconds()) / 4);
|
||||
bench.uncachedBiome(budget, new WorldgenStageBench.CallSamples());
|
||||
bench.baseHeight(budget, Heightmap.Types.WORLD_SURFACE_WG, new WorldgenStageBench.CallSamples());
|
||||
bench.baseColumn(budget, new WorldgenStageBench.CallSamples());
|
||||
log(String.format(Locale.ROOT, "%s warmed in %.1f s", bench.label(), (System.nanoTime() - started) / NANOS_PER_SECOND));
|
||||
}
|
||||
|
||||
private static void measure(List<Measurement> measurements, Configuration configuration) {
|
||||
int stride = configuration.side() + 4;
|
||||
WorldgenStageBench.CallBudget budget = new WorldgenStageBench.CallBudget(
|
||||
Math.max(1, configuration.calls() / configuration.rounds()), Math.max(1, MINIMUM_CALLS / configuration.rounds()),
|
||||
TimeUnit.SECONDS.toNanos(configuration.callSeconds()) / configuration.rounds());
|
||||
for (int round = 0; round < configuration.rounds(); round++) {
|
||||
for (int index = 0; index < measurements.size(); index++) {
|
||||
Measurement measurement = measurements.get((index + round) % measurements.size());
|
||||
long started = System.nanoTime();
|
||||
measurement.bench.chunks(round * stride, round * stride, configuration.side(), measurement.chunks);
|
||||
measurement.bench.uncachedBiome(budget, measurement.uncachedBiome);
|
||||
measurement.bench.baseHeight(budget, Heightmap.Types.WORLD_SURFACE_WG, measurement.surfaceHeight);
|
||||
measurement.bench.baseHeight(budget, Heightmap.Types.OCEAN_FLOOR_WG, measurement.floorHeight);
|
||||
measurement.bench.baseColumn(budget, measurement.column);
|
||||
log(String.format(Locale.ROOT, "%s round %d measured in %.1f s", measurement.bench.label(), round,
|
||||
(System.nanoTime() - started) / NANOS_PER_SECOND));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<DensityCostAnalysis.RootCost> analyzeRoots(NoiseGeneratorSettings settings, String dimension) {
|
||||
DensityCostAnalysis analysis = new DensityCostAnalysis(id -> DensityJsonBreakdown.category(dimension, id), TOP_OFFENDERS);
|
||||
Map<String, DensityFunction> roots = new LinkedHashMap<>();
|
||||
NoiseRouter router = settings.noiseRouter();
|
||||
roots.put("temperature", router.temperature());
|
||||
roots.put("vegetation", router.vegetation());
|
||||
roots.put("continents", router.continents());
|
||||
roots.put("erosion", router.erosion());
|
||||
roots.put("depth", router.depth());
|
||||
roots.put("ridges", router.ridges());
|
||||
roots.put("chunk_surface_level", router.chunkSurfaceLevel());
|
||||
roots.put("final_density", router.finalDensity());
|
||||
if (settings.aquifers().isPresent()) {
|
||||
Aquifer.Config aquifer = settings.aquifers().get();
|
||||
roots.put("aquifer.barrier", aquifer.barrierNoise());
|
||||
roots.put("aquifer.fluid_level_floodedness", aquifer.fluidLevelFloodednessNoise());
|
||||
roots.put("aquifer.fluid_level_spread", aquifer.fluidLevelSpreadNoise());
|
||||
roots.put("aquifer.lava", aquifer.lavaNoise());
|
||||
roots.put("aquifer.exclusion", aquifer.exclusion());
|
||||
roots.put("aquifer.surface_level", aquifer.surfaceLevel());
|
||||
}
|
||||
List<DensityCostAnalysis.RootCost> costs = new ArrayList<>(roots.size());
|
||||
for (Map.Entry<String, DensityFunction> root : roots.entrySet()) {
|
||||
costs.add(analysis.analyze(root.getKey(), root.getValue()));
|
||||
}
|
||||
return costs;
|
||||
}
|
||||
|
||||
static LevelStem presetOverworld(RegistryAccess registries, String preset) {
|
||||
return registries.lookupOrThrow(Registries.WORLD_PRESET)
|
||||
.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, Identifier.parse(preset))).value().overworld()
|
||||
.orElseThrow(() -> new IllegalStateException("World preset " + preset + " has no overworld"));
|
||||
}
|
||||
|
||||
static LevelStem exportedOverworld(RegistryAccess registries) {
|
||||
return registries.lookupOrThrow(Registries.LEVEL_STEM).getOrThrow(LevelStem.OVERWORLD).value();
|
||||
}
|
||||
|
||||
private static HeadlessNativeRegistries isolatedLoad(Path directory) {
|
||||
try (PaperRegistryScope scope = PaperRegistryScope.open()) {
|
||||
return HeadlessNativeRegistries.load(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static long settledHeap() throws InterruptedException {
|
||||
for (int pass = 0; pass < 4; pass++) {
|
||||
System.gc();
|
||||
Thread.sleep(200L);
|
||||
}
|
||||
return ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getUsed();
|
||||
}
|
||||
|
||||
private static <T> T onLargeStack(String name, Callable<T> work) throws Exception {
|
||||
AtomicReference<T> result = new AtomicReference<>();
|
||||
AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||
Thread thread = new Thread(null, () -> {
|
||||
try {
|
||||
result.set(work.call());
|
||||
} catch (Throwable problem) {
|
||||
failure.set(problem);
|
||||
}
|
||||
}, name, ANALYSIS_STACK_BYTES);
|
||||
thread.start();
|
||||
thread.join();
|
||||
if (failure.get() instanceof Exception exception) {
|
||||
throw exception;
|
||||
}
|
||||
if (failure.get() instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
return result.get();
|
||||
}
|
||||
|
||||
private static SortedMap<String, byte[]> zipEntries(Path zip) throws IOException {
|
||||
SortedMap<String, byte[]> files = new TreeMap<>();
|
||||
try (ZipFile archive = new ZipFile(zip.toFile())) {
|
||||
Enumeration<? extends ZipEntry> entries = archive.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
ZipEntry entry = entries.nextElement();
|
||||
if (!entry.isDirectory()) {
|
||||
files.put(entry.getName(), archive.getInputStream(entry).readAllBytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
private static void writeFiles(SortedMap<String, byte[]> files, Path directory) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
for (Map.Entry<String, byte[]> file : files.entrySet()) {
|
||||
Path target = directory.resolve(file.getKey()).normalize();
|
||||
if (!target.startsWith(directory)) {
|
||||
throw new IllegalArgumentException("Export file escapes the datapack root: " + file.getKey());
|
||||
}
|
||||
Files.createDirectories(target.getParent());
|
||||
Files.write(target, file.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private static int count(SortedMap<String, byte[]> files, String folder) {
|
||||
int count = 0;
|
||||
for (String path : files.keySet()) {
|
||||
if (path.contains(folder) && path.endsWith(".json")) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static long bytes(SortedMap<String, byte[]> files, String folder) {
|
||||
long bytes = 0;
|
||||
for (Map.Entry<String, byte[]> file : files.entrySet()) {
|
||||
if (file.getKey().contains(folder) && file.getKey().endsWith(".json")) {
|
||||
bytes += file.getValue().length;
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static void writeReport(Report report, Path output) throws IOException {
|
||||
Files.createDirectories(output.getParent());
|
||||
Gson gson = new GsonBuilder().setPrettyPrinting().serializeSpecialFloatingPointValues().create();
|
||||
Files.writeString(output, gson.toJson(report), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static Map<String, String> definitions() {
|
||||
Map<String, String> definitions = new LinkedHashMap<>();
|
||||
definitions.put("chunks", "per stage: wall ms and current-thread CPU ms per chunk; rounds of side x side chunks alternate vanilla and export");
|
||||
definitions.put("chunks.biomes", "ChunkGenerator.createBiomes on a fresh ProtoChunk, ms per chunk over the grid plus a one-chunk ring");
|
||||
definitions.put("chunks.noise", "NoiseChunk construction (aquifer init) plus NoiseBasedChunkGenerator.doFill, ms per chunk");
|
||||
definitions.put("chunks.surface", "MaterialSystem.buildSurface with the fill's NoiseChunk and a region biome manager, ms per chunk");
|
||||
definitions.put("chunks.carverBiome", "one uncached BiomeSource lookup at the chunk origin, as ChunkAccess.carverBiome memoizes per chunk, ms per chunk");
|
||||
definitions.put("uncachedBiome", "BiomeSource.createUncachedResolver(...).getNoiseBiome at random quart positions within 32k blocks, wall us per call and mean thread CPU us per call");
|
||||
definitions.put("baseHeight", "NoiseBasedChunkGenerator.getBaseHeight as structure placement calls it, us per call");
|
||||
definitions.put("baseColumn", "NoiseBasedChunkGenerator.getBaseColumn, us per call");
|
||||
definitions.put("retainedHeapBytes", "used heap after four System.gc passes, delta across the registry load");
|
||||
definitions.put("samplerHeapBytes", "used heap delta across RandomState creation and warmup (compiled samplers and buffer pools)");
|
||||
definitions.put("roots.dagNodes", "distinct non-constant density function and spline nodes reachable, references shared");
|
||||
definitions.put("roots.cachedNodes", "non-constant evaluations per point with caches enabled: every distinct cache body once, all branches");
|
||||
definitions.put("roots.expandedNodes", "non-constant evaluations per point with every reference and cache re-expanded, all branches");
|
||||
definitions.put("roots.uncachedPointWorst", "worst single uncached point evaluation with lazy splines, interval_select and range_choice; interpolated off-lattice costs 8 corners at expanded cost; find_top_surface counts its probe steps");
|
||||
definitions.put("roots.dagShare", "share of the distinct nodes by the category of the named file that holds them; offset copies are Grad2 and Taps duplicates");
|
||||
definitions.put("roots.exclusive", "named density functions ranked by multiplicity x own nodes in the expanded tree");
|
||||
definitions.put("roots.inclusive", "named density functions ranked by multiplicity x subtree nodes in the expanded tree");
|
||||
definitions.put("densityJson.structurallyDuplicateBytes", "bytes beyond one file per JSON shape, with numbers, density function references and noise ids abstracted");
|
||||
return definitions;
|
||||
}
|
||||
|
||||
private static String table(Report report) {
|
||||
StringBuilder out = new StringBuilder(4096);
|
||||
GeneratorStats vanilla = report.generators().get("vanilla");
|
||||
GeneratorStats export = report.generators().get("export");
|
||||
out.append(String.format(Locale.ROOT, "%s %s seed %d: %d chunks in %d interleaved rounds after %d warmup chunks, one thread, load %.0f-%.0f on %d cores%n",
|
||||
LOG_PREFIX, report.dimension(), report.seed(), report.chunks(), report.rounds(), report.warmupChunks(),
|
||||
report.load().loadAverageStart(), report.load().loadAverageEnd(), report.load().processors()));
|
||||
out.append(String.format(Locale.ROOT, "%-34s %10s %10s %8s %10s %10s %8s%n", "stage (mean)", "vanilla", "export", "ratio",
|
||||
"cpu van", "cpu exp", "ratio"));
|
||||
out.append(String.format(Locale.ROOT, "%-34s %10s %10.1f%n", "export (s)", "-", report.export().seconds()));
|
||||
row(out, "registry load (s)", vanilla.registryLoadSeconds(), export.registryLoadSeconds());
|
||||
row(out, "retained heap after load (MB)", vanilla.retainedHeapBytes() / MEGABYTE, export.retainedHeapBytes() / MEGABYTE);
|
||||
row(out, "sampler heap after warmup (MB)", vanilla.samplerHeapBytes() / MEGABYTE, export.samplerHeapBytes() / MEGABYTE);
|
||||
stage(out, "biome fill (ms/chunk)", vanilla.chunks().biomes(), export.chunks().biomes());
|
||||
stage(out, "noise fill (ms/chunk)", vanilla.chunks().noise(), export.chunks().noise());
|
||||
stage(out, "surface (ms/chunk)", vanilla.chunks().surface(), export.chunks().surface());
|
||||
stage(out, "carver biome lookup (ms/chunk)", vanilla.chunks().carverBiome(), export.chunks().carverBiome());
|
||||
row(out, "chunk total (ms/chunk)", vanilla.chunks().totalMean(), export.chunks().totalMean(),
|
||||
vanilla.chunks().totalCpuMean(), export.chunks().totalCpuMean());
|
||||
call(out, "uncached getNoiseBiome (us/call)", vanilla.uncachedBiome(), export.uncachedBiome());
|
||||
call(out, "getBaseHeight WORLD_SURFACE (us)", vanilla.baseHeightSurface(), export.baseHeightSurface());
|
||||
call(out, "getBaseHeight OCEAN_FLOOR (us)", vanilla.baseHeightFloor(), export.baseHeightFloor());
|
||||
call(out, "getBaseColumn (us/call)", vanilla.baseColumn(), export.baseColumn());
|
||||
out.append(String.format(Locale.ROOT, "%-24s %9s %9s %9s %10s %10s %10s %10s%n", "router root", "dag(v)", "dag",
|
||||
"cached", "expand(v)", "expanded", "uncPt(v)", "uncachedPt"));
|
||||
List<DensityCostAnalysis.RootCost> vanillaRoots = report.roots().get("vanilla");
|
||||
List<DensityCostAnalysis.RootCost> exportRoots = report.roots().get("export");
|
||||
for (DensityCostAnalysis.RootCost root : exportRoots) {
|
||||
DensityCostAnalysis.RootCost reference = find(vanillaRoots, root.root());
|
||||
out.append(String.format(Locale.ROOT, "%-24s %9s %9s %9s %10s %10s %10s %10s%n", root.root(),
|
||||
reference == null ? "-" : compact(reference.dagNodes()), compact(root.dagNodes()), compact(root.cachedNodes()),
|
||||
reference == null ? "-" : compact(reference.expandedNodes()), compact(root.expandedNodes()),
|
||||
reference == null ? "-" : compact(reference.uncachedPointWorst()), compact(root.uncachedPointWorst())));
|
||||
}
|
||||
out.append("top expanded offenders (exclusive share of root, multiplicity)\n");
|
||||
for (DensityCostAnalysis.RootCost root : exportRoots) {
|
||||
if (root.exclusive().isEmpty() || root.expandedNodes() < 1.0e4) {
|
||||
continue;
|
||||
}
|
||||
StringBuilder line = new StringBuilder(String.format(Locale.ROOT, " %-22s", root.root()));
|
||||
for (int index = 0; index < Math.min(2, root.exclusive().size()); index++) {
|
||||
DensityCostAnalysis.Offender offender = root.exclusive().get(index);
|
||||
line.append(String.format(Locale.ROOT, " %4.1f%% %s x%s;", 100.0 * offender.contribution() / root.expandedNodes(),
|
||||
shortName(offender.name()), compact(offender.multiplicity())));
|
||||
}
|
||||
out.append(line).append('\n');
|
||||
}
|
||||
for (DensityCostAnalysis.RootCost root : exportRoots) {
|
||||
if (root.dagNodes() < 1.0e4) {
|
||||
continue;
|
||||
}
|
||||
List<Map.Entry<String, Double>> shares = new ArrayList<>(root.dagShare().entrySet());
|
||||
shares.sort(Map.Entry.<String, Double>comparingByValue().reversed());
|
||||
StringBuilder line = new StringBuilder(String.format(Locale.ROOT, " dag %-18s", root.root()));
|
||||
for (int index = 0; index < Math.min(3, shares.size()); index++) {
|
||||
line.append(String.format(Locale.ROOT, " %4.1f%% %s;", 100.0 * shares.get(index).getValue(), shares.get(index).getKey()));
|
||||
}
|
||||
out.append(line).append('\n');
|
||||
}
|
||||
DensityJsonBreakdown.Report json = report.densityJson();
|
||||
out.append(String.format(Locale.ROOT, "density function JSON %.0f MB in %d files, %d cellular instances in %d shapes%n",
|
||||
json.bytes() / MEGABYTE, json.files(), json.cellularInstances(), json.cellularShapes().size()));
|
||||
for (int index = 0; index < Math.min(9, json.categories().size()); index++) {
|
||||
DensityJsonBreakdown.CategoryBytes category = json.categories().get(index);
|
||||
out.append(String.format(Locale.ROOT, " %-22s %8.1f MB %7d files %7d shapes %8.1f MB dup%n", category.category(),
|
||||
category.bytes() / MEGABYTE, category.files(), category.distinctShapes(), category.structurallyDuplicateBytes() / MEGABYTE));
|
||||
}
|
||||
for (int index = 0; index < Math.min(4, json.libraryOwners().size()); index++) {
|
||||
DensityJsonBreakdown.Bytes owner = json.libraryOwners().get(index);
|
||||
out.append(String.format(Locale.ROOT, " %-48s %8.1f MB%n", owner.name(), owner.bytes() / MEGABYTE));
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
private static void row(StringBuilder out, String label, double vanilla, double export) {
|
||||
out.append(String.format(Locale.ROOT, "%-34s %10.3f %10.3f %8s%n", label, vanilla, export, ratio(vanilla, export)));
|
||||
}
|
||||
|
||||
private static void row(StringBuilder out, String label, double vanilla, double export, double vanillaCpu, double exportCpu) {
|
||||
out.append(String.format(Locale.ROOT, "%-34s %10.3f %10.3f %8s %10.3f %10.3f %8s%n", label, vanilla, export, ratio(vanilla, export),
|
||||
vanillaCpu, exportCpu, ratio(vanillaCpu, exportCpu)));
|
||||
}
|
||||
|
||||
private static void stage(StringBuilder out, String label, WorldgenStageBench.StageTiming vanilla, WorldgenStageBench.StageTiming export) {
|
||||
row(out, label, vanilla.wall().mean(), export.wall().mean(), vanilla.cpu().mean(), export.cpu().mean());
|
||||
}
|
||||
|
||||
private static void call(StringBuilder out, String label, WorldgenStageBench.CallTiming vanilla, WorldgenStageBench.CallTiming export) {
|
||||
row(out, label, vanilla.wall().mean(), export.wall().mean(), vanilla.cpuMean(), export.cpuMean());
|
||||
}
|
||||
|
||||
private static String ratio(double vanilla, double export) {
|
||||
return vanilla > 0.0 ? String.format(Locale.ROOT, "%.1fx", export / vanilla) : "-";
|
||||
}
|
||||
|
||||
private static DensityCostAnalysis.RootCost find(List<DensityCostAnalysis.RootCost> roots, String name) {
|
||||
for (DensityCostAnalysis.RootCost root : roots) {
|
||||
if (root.root().equals(name)) {
|
||||
return root;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String shortName(String name) {
|
||||
int separator = name.indexOf(':');
|
||||
String path = separator < 0 ? name : name.substring(separator + 1);
|
||||
return path.length() <= 34 ? path : "..." + path.substring(path.length() - 31);
|
||||
}
|
||||
|
||||
private static String compact(double value) {
|
||||
if (value < 1.0e4) {
|
||||
return String.format(Locale.ROOT, "%.0f", value);
|
||||
}
|
||||
return String.format(Locale.ROOT, "%.2e", value);
|
||||
}
|
||||
|
||||
private static void log(String message) {
|
||||
System.out.println(LOG_PREFIX + " " + message);
|
||||
}
|
||||
|
||||
private static void deleteTree(Path root) throws IOException {
|
||||
if (!Files.exists(root)) {
|
||||
return;
|
||||
}
|
||||
HeadlessNativeRegistries.delete(root);
|
||||
}
|
||||
|
||||
record Configuration(Path pack, String dimension, long seed, int side, int rounds, int warmup, int calls, long callSeconds,
|
||||
Path output, Path work) {
|
||||
Configuration {
|
||||
if (side < 1 || rounds < 1 || warmup < 1 || calls < MINIMUM_CALLS || callSeconds < 1) {
|
||||
throw new IllegalArgumentException("side, rounds, warmup and callSeconds must be positive and calls at least " + MINIMUM_CALLS);
|
||||
}
|
||||
}
|
||||
|
||||
static Configuration parse(String[] arguments) {
|
||||
if (arguments.length != 10) {
|
||||
throw new IllegalArgumentException("Usage: pack dimension seed side rounds warmupSide calls callSeconds output work");
|
||||
}
|
||||
return new Configuration(new File(arguments[0]).toPath().toAbsolutePath(), arguments[1], Long.parseLong(arguments[2]),
|
||||
Integer.parseInt(arguments[3]), Integer.parseInt(arguments[4]), Integer.parseInt(arguments[5]),
|
||||
Integer.parseInt(arguments[6]), Long.parseLong(arguments[7]), new File(arguments[8]).toPath().toAbsolutePath(),
|
||||
new File(arguments[9]).toPath().toAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
record ExportStats(double seconds, Map<String, Double> stageSeconds, long zipBytes, int files, int densityFunctions,
|
||||
long densityFunctionBytes, int noises, int biomes, int diagnostics) {
|
||||
}
|
||||
|
||||
record GeneratorStats(double registryLoadSeconds, long retainedHeapBytes, long samplerHeapBytes, WorldgenStageBench.ChunkTimings chunks,
|
||||
WorldgenStageBench.CallTiming uncachedBiome, WorldgenStageBench.CallTiming baseHeightSurface,
|
||||
WorldgenStageBench.CallTiming baseHeightFloor, WorldgenStageBench.CallTiming baseColumn) {
|
||||
}
|
||||
|
||||
record MachineLoad(int processors, double loadAverageStart, double loadAverageEnd) {
|
||||
}
|
||||
|
||||
record Report(String pack, String dimension, long seed, int chunks, int warmupChunks, int rounds, MachineLoad load,
|
||||
String threading, String decoration,
|
||||
ExportStats export, Map<String, GeneratorStats> generators, DensityJsonBreakdown.Report densityJson,
|
||||
Map<String, List<DensityCostAnalysis.RootCost>> roots, Map<String, String> definitions) {
|
||||
}
|
||||
|
||||
private record ExportRun(ExportStats stats, SortedMap<String, byte[]> files) {
|
||||
}
|
||||
|
||||
private static final class Measurement {
|
||||
private final WorldgenStageBench bench;
|
||||
private final WorldgenStageBench.ChunkSamples chunks = new WorldgenStageBench.ChunkSamples();
|
||||
private final WorldgenStageBench.CallSamples uncachedBiome = new WorldgenStageBench.CallSamples();
|
||||
private final WorldgenStageBench.CallSamples surfaceHeight = new WorldgenStageBench.CallSamples();
|
||||
private final WorldgenStageBench.CallSamples floorHeight = new WorldgenStageBench.CallSamples();
|
||||
private final WorldgenStageBench.CallSamples column = new WorldgenStageBench.CallSamples();
|
||||
|
||||
private Measurement(WorldgenStageBench bench) {
|
||||
this.bench = bench;
|
||||
}
|
||||
|
||||
private GeneratorStats stats(double loadSeconds, long retainedHeap, long samplerHeap) {
|
||||
return new GeneratorStats(loadSeconds, retainedHeap, samplerHeap, chunks.timings(), uncachedBiome.timing(),
|
||||
surfaceHeight.timing(), floorHeight.timing(), column.timing());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class StageClock implements WorldgenExportProgress {
|
||||
private final Map<String, Long> starts = new LinkedHashMap<>();
|
||||
private long origin;
|
||||
|
||||
private void start(long nanos) {
|
||||
origin = nanos;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stage(String stage) {
|
||||
starts.put(stage, System.nanoTime());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resources(int done, int total) {
|
||||
}
|
||||
|
||||
private Map<String, Double> seconds(long finished) {
|
||||
Map<String, Double> seconds = new LinkedHashMap<>();
|
||||
String previous = "setup";
|
||||
long previousStart = origin;
|
||||
for (Map.Entry<String, Long> entry : starts.entrySet()) {
|
||||
seconds.put(previous, (entry.getValue() - previousStart) / NANOS_PER_SECOND);
|
||||
previous = entry.getKey();
|
||||
previousStart = entry.getValue();
|
||||
}
|
||||
seconds.put(previous, (finished - previousStart) / NANOS_PER_SECOND);
|
||||
return seconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.generation.concurrent.MultiBurst;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportProgress;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportResult;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportService;
|
||||
import art.arcane.iris.pack.datapack.export.compile.surface.MaterialProgramCompiler;
|
||||
import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic;
|
||||
import art.arcane.iris.probe.fidelity.BiomeShareReport;
|
||||
import art.arcane.iris.probe.fidelity.BiomeTypes;
|
||||
import art.arcane.iris.probe.fidelity.ColumnSource;
|
||||
import art.arcane.iris.probe.fidelity.ColumnSurvey;
|
||||
import art.arcane.iris.probe.fidelity.FidelityComparison;
|
||||
import art.arcane.iris.probe.fidelity.FidelityReport;
|
||||
import art.arcane.iris.probe.fidelity.FidelitySampling;
|
||||
import art.arcane.iris.probe.fidelity.FidelitySamples;
|
||||
import art.arcane.iris.probe.fidelity.FidelityScanner;
|
||||
import art.arcane.iris.probe.fidelity.FidelitySource;
|
||||
import art.arcane.iris.probe.fidelity.FidelityStats;
|
||||
import art.arcane.iris.probe.fidelity.FidelityThresholds;
|
||||
import art.arcane.iris.probe.fidelity.IrisEngineColumnSource;
|
||||
import art.arcane.iris.probe.fidelity.IrisEngineFidelitySource;
|
||||
import art.arcane.iris.probe.fidelity.SurveyWindow;
|
||||
import art.arcane.iris.probe.fidelity.VanillaNoiseColumnSource;
|
||||
import art.arcane.iris.probe.fidelity.VanillaNoiseFidelitySource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.SortedMap;
|
||||
import java.util.TreeMap;
|
||||
|
||||
public final class DatapackFidelityProbe {
|
||||
private static final String LOG_PREFIX = "[datapack-fidelity]";
|
||||
private static final String USAGE = "Usage: datapackFidelityProbe pack dimension seed radiusChunks stride output surveyBlocks surveyStride surveySeeds mode";
|
||||
private static final String TARGET_VERSION = "26.3";
|
||||
private static final String EXPORT_FORMAT = "directory";
|
||||
private static final String EXPORT_OUTPUT = "export";
|
||||
private static final int MAX_STRIDE = 16;
|
||||
private static final int CHUNK_WIDTH = 16;
|
||||
private static final int ARGUMENTS = 10;
|
||||
private static final String COLUMN_SCOPE = "columns";
|
||||
private static final String CHUNK_SCOPE = "chunks";
|
||||
private static final int EXIT_ERROR = 1;
|
||||
private static final int EXIT_FIDELITY_FAILED = 3;
|
||||
|
||||
private DatapackFidelityProbe() {
|
||||
}
|
||||
|
||||
public static void main(String[] arguments) {
|
||||
int status;
|
||||
try {
|
||||
status = execute(arguments, WorldgenDatapackExporter.create()).passed() ? 0 : EXIT_FIDELITY_FAILED;
|
||||
} catch (Throwable failure) {
|
||||
failure.printStackTrace(System.err);
|
||||
status = EXIT_ERROR;
|
||||
}
|
||||
System.out.flush();
|
||||
System.err.flush();
|
||||
System.exit(status);
|
||||
}
|
||||
|
||||
static FidelityReport execute(String[] arguments, WorldgenExportService exporter) throws Exception {
|
||||
Objects.requireNonNull(exporter, "exporter");
|
||||
Configuration configuration = Configuration.parse(arguments);
|
||||
System.out.println(LOG_PREFIX + " pack=" + configuration.pack() + " dimension=" + configuration.dimension()
|
||||
+ " seed=" + configuration.seed() + " radiusChunks=" + configuration.radiusChunks() + " stride=" + configuration.stride()
|
||||
+ " surveyBlocks=" + configuration.survey().size() + " surveyStride=" + configuration.survey().stride()
|
||||
+ " surveySeeds=" + configuration.surveySeeds() + " mode=" + configuration.mode().label());
|
||||
Outcome outcome = compare(configuration, exporter);
|
||||
writeReport(configuration.output(), outcome.json());
|
||||
System.out.print(outcome.table());
|
||||
System.out.println(LOG_PREFIX + " report=" + configuration.output());
|
||||
return outcome.report();
|
||||
}
|
||||
|
||||
private static Outcome compare(Configuration configuration, WorldgenExportService exporter) throws Exception {
|
||||
Path scratch = Files.createTempDirectory("iris-datapack-fidelity-");
|
||||
try {
|
||||
return compareIn(scratch, configuration, exporter);
|
||||
} finally {
|
||||
ProbeWorkspaceCleanup.deleteRecursively(scratch);
|
||||
}
|
||||
}
|
||||
|
||||
private static Outcome compareIn(Path scratch, Configuration configuration, WorldgenExportService exporter) throws Exception {
|
||||
HeadlessNativeRuntime runtime = new HeadlessNativeRuntime();
|
||||
try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace(new RealPackProbeSupport.WorkspaceOptions(
|
||||
configuration.pack().toFile(), configuration.dimension(), LOG_PREFIX, runtime, scratch))) {
|
||||
WorldgenExportResult export = export(exporter, workspace.pack().toPath(), configuration.dimension(), scratch.resolve("exports"));
|
||||
Target target = new Target(export.output(), WorldgenExportRequest.DEFAULT_NAMESPACE,
|
||||
VanillaResourceIdentifier.sanitizePath(configuration.dimension()), export.loadKeyByBiomeId());
|
||||
return switch (configuration.mode()) {
|
||||
case FULL -> Outcome.of(compareAll(workspace, target, configuration));
|
||||
case BIOMES -> Outcome.of(compareBiomes(workspace, target, configuration));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static FidelityReport compareAll(RealPackProbeSupport.Workspace workspace, Target target, Configuration configuration) throws Exception {
|
||||
List<Long> seeds = configuration.surveySeeds();
|
||||
FidelityStats irisColumns = null;
|
||||
FidelityStats exportColumns = null;
|
||||
FidelitySamples chunks = null;
|
||||
for (long seed : seeds) {
|
||||
try (RealPackProbeSupport.EngineSession session = workspace.openEngine(seed, false, "fidelity-" + seed)) {
|
||||
Surveyed surveyed = survey(session.engine(), target, seed, configuration.survey());
|
||||
irisColumns = irisColumns == null ? surveyed.iris() : irisColumns.merge(surveyed.iris());
|
||||
exportColumns = exportColumns == null ? surveyed.export() : exportColumns.merge(surveyed.export());
|
||||
if (seed == configuration.seed()) {
|
||||
chunks = scanChunks(session.engine(), target, configuration);
|
||||
}
|
||||
}
|
||||
}
|
||||
FidelitySampling columnSampling = new FidelitySampling(COLUMN_SCOPE, configuration.survey().size(), configuration.survey().stride(),
|
||||
seeds, irisColumns.columns());
|
||||
return FidelityComparison.compare(new FidelitySamples(irisColumns, exportColumns, columnSampling), chunks, FidelityThresholds.defaults());
|
||||
}
|
||||
|
||||
private static BiomeShareReport compareBiomes(RealPackProbeSupport.Workspace workspace, Target target, Configuration configuration) throws Exception {
|
||||
List<FidelitySamples> seeds = new ArrayList<>(configuration.seedCount());
|
||||
Map<String, String> biomeTypes = Map.of();
|
||||
for (long seed : configuration.surveySeeds()) {
|
||||
try (RealPackProbeSupport.EngineSession session = workspace.openEngine(seed, false, "biomes-" + seed)) {
|
||||
Engine engine = session.engine();
|
||||
Surveyed surveyed = survey(engine, target, seed, configuration.survey());
|
||||
if (biomeTypes.isEmpty()) {
|
||||
biomeTypes = BiomeTypes.of(engine.getDimension().getAllRegions(engine), key -> engine.getData().getBiomeLoader().load(key));
|
||||
}
|
||||
seeds.add(new FidelitySamples(surveyed.iris(), surveyed.export(), new FidelitySampling(COLUMN_SCOPE, configuration.survey().size(),
|
||||
configuration.survey().stride(), List.of(seed), surveyed.iris().columns())));
|
||||
}
|
||||
}
|
||||
return FidelityComparison.compareBiomeShares(seeds, biomeTypes, FidelityThresholds.defaults());
|
||||
}
|
||||
|
||||
private static Surveyed survey(Engine engine, Target target, long seed, SurveyWindow window) throws Exception {
|
||||
try (ColumnSource iris = new IrisEngineColumnSource(engine);
|
||||
ColumnSource vanilla = new VanillaNoiseColumnSource(target.sampler(seed), target.loadKeyByBiomeId())) {
|
||||
requireSameHeightRange(iris.minY(), iris.maxY(), vanilla.minY(), vanilla.maxY());
|
||||
return new Surveyed(surveyed(iris, window, seed), surveyed(vanilla, window, seed));
|
||||
}
|
||||
}
|
||||
|
||||
private static FidelityStats surveyed(ColumnSource source, SurveyWindow window, long seed) {
|
||||
long started = System.nanoTime();
|
||||
FidelityStats stats = ColumnSurvey.scan(source, window, MultiBurst.burst.parallelism());
|
||||
System.out.println(LOG_PREFIX + " surveyed " + source.label() + " seed=" + seed + " columns=" + stats.columns()
|
||||
+ " in " + (System.nanoTime() - started) / 1_000_000L + "ms");
|
||||
return stats;
|
||||
}
|
||||
|
||||
private static FidelitySamples scanChunks(Engine engine, Target target, Configuration configuration) throws Exception {
|
||||
try (IrisEngineFidelitySource iris = new IrisEngineFidelitySource(engine);
|
||||
VanillaNoiseFidelitySource vanilla = new VanillaNoiseFidelitySource(target.sampler(configuration.seed()), target.loadKeyByBiomeId(),
|
||||
target.namespace() + ":" + MaterialProgramCompiler.WALL_SHELL)) {
|
||||
requireSameHeightRange(iris.minY(), iris.maxY(), vanilla.minY(), vanilla.maxY());
|
||||
FidelityStats irisStats = scan(iris, configuration);
|
||||
FidelityStats exportStats = scan(vanilla, configuration);
|
||||
FidelitySampling sampling = new FidelitySampling(CHUNK_SCOPE, (2 * configuration.radiusChunks() + 1) * CHUNK_WIDTH,
|
||||
configuration.stride(), List.of(configuration.seed()), irisStats.columns());
|
||||
return new FidelitySamples(irisStats, exportStats, sampling);
|
||||
}
|
||||
}
|
||||
|
||||
private static WorldgenExportResult export(WorldgenExportService exporter, Path pack, String dimension, Path exportRoot) throws IOException {
|
||||
Files.createDirectories(exportRoot);
|
||||
WorldgenExportRequest request = WorldgenExportRequest.builder()
|
||||
.pack(pack)
|
||||
.dimension(dimension)
|
||||
.version(TARGET_VERSION)
|
||||
.format(EXPORT_FORMAT)
|
||||
.output(EXPORT_OUTPUT)
|
||||
.exportRoot(exportRoot)
|
||||
.build();
|
||||
WorldgenExportResult result = exporter.export(request, WorldgenExportProgress.NONE);
|
||||
if (!result.isSuccess()) {
|
||||
throw new IllegalStateException("Datapack export of dimension '" + dimension + "' was " + result.status() + ": " + result.message());
|
||||
}
|
||||
System.out.println(LOG_PREFIX + " exported target=" + TARGET_VERSION + " biomes=" + result.loadKeyByBiomeId().size()
|
||||
+ " diagnostics=" + result.diagnostics().size() + " " + diagnosticCounts(result.diagnostics()));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static FidelityStats scan(FidelitySource source, Configuration configuration) {
|
||||
long started = System.nanoTime();
|
||||
FidelityStats stats = FidelityScanner.scan(source, 0, 0, configuration.radiusChunks(), configuration.stride(),
|
||||
MultiBurst.burst.parallelism());
|
||||
System.out.println(LOG_PREFIX + " sampled " + source.label() + " columns=" + stats.columns() + " terrainColumns=" + stats.heightColumns()
|
||||
+ " in " + (System.nanoTime() - started) / 1_000_000L + "ms");
|
||||
return stats;
|
||||
}
|
||||
|
||||
private static void requireSameHeightRange(int irisMinY, int irisMaxY, int exportMinY, int exportMaxY) {
|
||||
if (irisMinY != exportMinY || irisMaxY != exportMaxY) {
|
||||
throw new IllegalStateException("Iris height range [" + irisMinY + ", " + irisMaxY
|
||||
+ ") differs from the exported dimension range [" + exportMinY + ", " + exportMaxY + ")");
|
||||
}
|
||||
}
|
||||
|
||||
private static SortedMap<String, Integer> diagnosticCounts(List<ExportDiagnostic> diagnostics) {
|
||||
SortedMap<String, Integer> counts = new TreeMap<>();
|
||||
for (ExportDiagnostic diagnostic : diagnostics) {
|
||||
counts.merge(diagnostic.limit().name(), 1, Integer::sum);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
private static void writeReport(Path output, String json) throws IOException {
|
||||
Path parent = output.getParent();
|
||||
Files.createDirectories(parent);
|
||||
Path staged = Files.createTempFile(parent, ".datapack-fidelity-", ".json");
|
||||
try {
|
||||
Files.writeString(staged, json + "\n", StandardCharsets.UTF_8);
|
||||
Files.move(staged, output, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} finally {
|
||||
Files.deleteIfExists(staged);
|
||||
}
|
||||
}
|
||||
|
||||
private enum Mode {
|
||||
FULL,
|
||||
BIOMES;
|
||||
|
||||
private static Mode parse(String value) {
|
||||
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||||
for (Mode mode : values()) {
|
||||
if (mode.label().equals(normalized)) {
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("mode must be full or biomes, got '" + value + "'");
|
||||
}
|
||||
|
||||
private String label() {
|
||||
return name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
|
||||
private record Outcome(FidelityReport report, String json, String table) {
|
||||
private static Outcome of(FidelityReport report) {
|
||||
return new Outcome(report, report.toJson(), report.toTable());
|
||||
}
|
||||
|
||||
private static Outcome of(BiomeShareReport report) {
|
||||
return new Outcome(report.mean(), report.toJson(), report.toTable());
|
||||
}
|
||||
}
|
||||
|
||||
private record Surveyed(FidelityStats iris, FidelityStats export) {
|
||||
}
|
||||
|
||||
private record Target(Path output, String namespace, String dimension, Map<String, String> loadKeyByBiomeId) {
|
||||
private WorldgenExportSampler sampler(long seed) throws IOException {
|
||||
return WorldgenExportSampler.open(output, WorldgenExportMode.OVERWORLD, namespace + ":" + dimension, seed);
|
||||
}
|
||||
}
|
||||
|
||||
private record Configuration(Path pack, String dimension, long seed, int radiusChunks, int stride, Path output, SurveyWindow survey,
|
||||
int seedCount, Mode mode) {
|
||||
private static Configuration parse(String[] arguments) {
|
||||
if (arguments.length != ARGUMENTS) {
|
||||
throw new IllegalArgumentException(USAGE);
|
||||
}
|
||||
Path pack = Path.of(arguments[0]).toAbsolutePath().normalize();
|
||||
if (!Files.isDirectory(pack)) {
|
||||
throw new IllegalArgumentException("Pack folder " + pack + " does not exist");
|
||||
}
|
||||
String dimension = arguments[1].trim();
|
||||
if (dimension.isEmpty()) {
|
||||
throw new IllegalArgumentException("Dimension key must not be blank");
|
||||
}
|
||||
long seed = parseLong(arguments[2], "seed");
|
||||
int radiusChunks = parseInt(arguments[3], "radiusChunks");
|
||||
if (radiusChunks < 0) {
|
||||
throw new IllegalArgumentException("radiusChunks must be zero or positive, got " + radiusChunks);
|
||||
}
|
||||
int stride = parseInt(arguments[4], "stride");
|
||||
if (stride < 1 || stride > MAX_STRIDE) {
|
||||
throw new IllegalArgumentException("stride must be within [1, " + MAX_STRIDE + "], got " + stride);
|
||||
}
|
||||
Path output = Path.of(arguments[5]).toAbsolutePath().normalize();
|
||||
if (Files.isDirectory(output)) {
|
||||
throw new IllegalArgumentException("Report output " + output + " is a directory");
|
||||
}
|
||||
SurveyWindow survey = new SurveyWindow(0, 0, parseInt(arguments[6], "surveyBlocks"), parseInt(arguments[7], "surveyStride"));
|
||||
int seedCount = parseInt(arguments[8], "surveySeeds");
|
||||
if (seedCount < 1) {
|
||||
throw new IllegalArgumentException("surveySeeds must be at least 1, got " + seedCount);
|
||||
}
|
||||
Mode mode = Mode.parse(arguments[9]);
|
||||
return new Configuration(pack, dimension, seed, radiusChunks, stride, output, survey, seedCount, mode);
|
||||
}
|
||||
|
||||
private List<Long> surveySeeds() {
|
||||
List<Long> seeds = new ArrayList<>(seedCount);
|
||||
for (int index = 0; index < seedCount; index++) {
|
||||
seeds.add(seed + index);
|
||||
}
|
||||
return seeds;
|
||||
}
|
||||
|
||||
private static long parseLong(String value, String name) {
|
||||
try {
|
||||
return Long.parseLong(value.trim());
|
||||
} catch (NumberFormatException failure) {
|
||||
throw new IllegalArgumentException(name + " must be an integer, got '" + value + "'", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static int parseInt(String value, String name) {
|
||||
try {
|
||||
return Integer.parseInt(value.trim());
|
||||
} catch (NumberFormatException failure) {
|
||||
throw new IllegalArgumentException(name + " must be an integer, got '" + value + "'", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.util.CubicSpline;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityFunctions;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.generator.ConstantFunction;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.op.CacheFunction;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.op.FindTopSurfaceFunction;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.op.InterpolatedFunction;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.op.IntervalSelectFunction;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.op.RangeChoiceFunction;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.op.SplineFunction;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
final class DensityCostAnalysis {
|
||||
private static final double LATTICE_CORNERS = 8.0;
|
||||
private static final int MAX_SURFACE_STEPS = 4096;
|
||||
private static final String INLINE = "<inline>";
|
||||
|
||||
private final Function<String, String> categories;
|
||||
private final int topCount;
|
||||
private final Map<Object, Costs> costs = new IdentityHashMap<>();
|
||||
private final Map<DensityFunction, Body> bodies = new IdentityHashMap<>();
|
||||
private final Map<DensityFunction, String> names = new IdentityHashMap<>();
|
||||
|
||||
DensityCostAnalysis(Function<String, String> categories, int topCount) {
|
||||
this.categories = categories;
|
||||
this.topCount = topCount;
|
||||
}
|
||||
|
||||
RootCost analyze(String root, DensityFunction function) {
|
||||
Costs total = cost(function);
|
||||
Reach reach = new Reach();
|
||||
reach(function, reach, categories.apply(rootName(function, root)));
|
||||
double cached = total.cachedLocal();
|
||||
for (CacheFunction cache : reach.caches.keySet()) {
|
||||
cached += cost(cache.input()).cachedLocal();
|
||||
}
|
||||
Body rootBody = new Body();
|
||||
local(function, rootBody, true);
|
||||
Map<DensityFunction, Double> multiplicity = multiplicity(rootBody);
|
||||
List<Offender> offenders = new ArrayList<>(multiplicity.size());
|
||||
Map<String, Double> shares = new TreeMap<>();
|
||||
double expanded = total.expanded();
|
||||
shares.merge(categories.apply(rootName(function, root)), rootBody.self / expanded, Double::sum);
|
||||
for (Map.Entry<DensityFunction, Double> entry : multiplicity.entrySet()) {
|
||||
DensityFunction value = entry.getKey();
|
||||
Body body = body(value);
|
||||
String name = names.get(value);
|
||||
String category = categories.apply(name);
|
||||
double contribution = entry.getValue() * body.self;
|
||||
shares.merge(category, contribution / expanded, Double::sum);
|
||||
offenders.add(new Offender(name, category, entry.getValue(), body.self, cost(value).expanded(),
|
||||
contribution, entry.getValue() * cost(value).expanded()));
|
||||
}
|
||||
List<Offender> exclusive = top(offenders, Comparator.comparingDouble(Offender::contribution).reversed());
|
||||
List<Offender> inclusive = top(offenders, Comparator.comparingDouble(Offender::inclusive).reversed());
|
||||
Map<String, Double> dagShares = new TreeMap<>();
|
||||
for (Map.Entry<String, Double> entry : reach.categories.entrySet()) {
|
||||
dagShares.put(entry.getKey(), entry.getValue() / Math.max(1L, reach.nodes));
|
||||
}
|
||||
return new RootCost(root, reach.nodes, cached, expanded, total.lazy(), multiplicity.size(), exclusive, inclusive, shares, dagShares);
|
||||
}
|
||||
|
||||
private List<Offender> top(List<Offender> offenders, Comparator<Offender> order) {
|
||||
List<Offender> sorted = new ArrayList<>(offenders);
|
||||
sorted.sort(order);
|
||||
return List.copyOf(sorted.subList(0, Math.min(topCount, sorted.size())));
|
||||
}
|
||||
|
||||
private String rootName(DensityFunction function, String root) {
|
||||
if (function instanceof DensityFunctions.HolderHolder(Holder<DensityFunction> holder)) {
|
||||
return name(holder);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
private Costs cost(DensityFunction function) {
|
||||
if (function instanceof DensityFunctions.HolderHolder(Holder<DensityFunction> holder)) {
|
||||
DensityFunction value = holder.value();
|
||||
names.putIfAbsent(value, name(holder));
|
||||
return cost(value);
|
||||
}
|
||||
Costs known = costs.get(function);
|
||||
if (known != null) {
|
||||
return known;
|
||||
}
|
||||
Costs computed = compute(function);
|
||||
costs.put(function, computed);
|
||||
return computed;
|
||||
}
|
||||
|
||||
private Costs compute(DensityFunction function) {
|
||||
return switch (function) {
|
||||
case ConstantFunction ignored -> new Costs(0.0, 0.0, 0.0);
|
||||
case CacheFunction cache -> {
|
||||
Costs input = cost(cache.input());
|
||||
yield new Costs(1.0 + input.expanded(), 1.0 + input.lazy(), 1.0);
|
||||
}
|
||||
case SplineFunction spline -> {
|
||||
Costs inner = spline(spline.spline());
|
||||
yield new Costs(1.0 + inner.expanded(), 1.0 + inner.lazy(), 1.0 + inner.cachedLocal());
|
||||
}
|
||||
case IntervalSelectFunction select -> {
|
||||
Costs input = cost(select.input());
|
||||
double expanded = 1.0 + input.expanded();
|
||||
double cached = 1.0 + input.cachedLocal();
|
||||
double lazy = 0.0;
|
||||
for (DensityFunction branch : select.functions()) {
|
||||
Costs branchCost = cost(branch);
|
||||
expanded += branchCost.expanded();
|
||||
cached += branchCost.cachedLocal();
|
||||
lazy = Math.max(lazy, branchCost.lazy());
|
||||
}
|
||||
yield new Costs(expanded, 1.0 + input.lazy() + lazy, cached);
|
||||
}
|
||||
case RangeChoiceFunction choice -> {
|
||||
Costs input = cost(choice.input());
|
||||
Costs inside = cost(choice.whenInRange());
|
||||
Costs outside = cost(choice.whenOutOfRange());
|
||||
yield new Costs(1.0 + input.expanded() + inside.expanded() + outside.expanded(),
|
||||
1.0 + input.lazy() + Math.max(inside.lazy(), outside.lazy()),
|
||||
1.0 + input.cachedLocal() + inside.cachedLocal() + outside.cachedLocal());
|
||||
}
|
||||
case InterpolatedFunction grid -> {
|
||||
Costs input = cost(grid.input());
|
||||
yield new Costs(1.0 + input.expanded(), 1.0 + LATTICE_CORNERS * input.expanded(), 1.0 + input.cachedLocal());
|
||||
}
|
||||
case FindTopSurfaceFunction surface -> {
|
||||
Costs density = cost(surface.density());
|
||||
Costs upper = cost(surface.upperBound());
|
||||
double steps = surfaceSteps(surface);
|
||||
yield new Costs(1.0 + density.expanded() + upper.expanded(), 1.0 + upper.lazy() + steps * density.lazy(),
|
||||
1.0 + density.cachedLocal() + upper.cachedLocal());
|
||||
}
|
||||
default -> {
|
||||
double expanded = 1.0;
|
||||
double lazy = 1.0;
|
||||
double cached = 1.0;
|
||||
for (DensityFunction child : children(function)) {
|
||||
Costs childCost = cost(child);
|
||||
expanded += childCost.expanded();
|
||||
lazy += childCost.lazy();
|
||||
cached += childCost.cachedLocal();
|
||||
}
|
||||
yield new Costs(expanded, lazy, cached);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Costs spline(CubicSpline<SplineFunction.Coordinate> spline) {
|
||||
if (!(spline instanceof CubicSpline.Multipoint<SplineFunction.Coordinate> multipoint)) {
|
||||
return new Costs(0.0, 0.0, 0.0);
|
||||
}
|
||||
Costs known = costs.get(multipoint);
|
||||
if (known != null) {
|
||||
return known;
|
||||
}
|
||||
Costs coordinate = cost(multipoint.coordinate().function());
|
||||
List<CubicSpline<SplineFunction.Coordinate>> values = multipoint.values();
|
||||
double expanded = 1.0 + coordinate.expanded();
|
||||
double cached = 1.0 + coordinate.cachedLocal();
|
||||
double[] lazy = new double[values.size()];
|
||||
for (int index = 0; index < values.size(); index++) {
|
||||
Costs value = spline(values.get(index));
|
||||
expanded += value.expanded();
|
||||
cached += value.cachedLocal();
|
||||
lazy[index] = value.lazy();
|
||||
}
|
||||
double worstPair = lazy.length == 1 ? lazy[0] : 0.0;
|
||||
for (int index = 0; index + 1 < lazy.length; index++) {
|
||||
worstPair = Math.max(worstPair, lazy[index] + lazy[index + 1]);
|
||||
}
|
||||
Costs computed = new Costs(expanded, 1.0 + coordinate.lazy() + worstPair, cached);
|
||||
costs.put(multipoint, computed);
|
||||
return computed;
|
||||
}
|
||||
|
||||
private void reach(DensityFunction function, Reach reach, String category) {
|
||||
if (function instanceof DensityFunctions.HolderHolder(Holder<DensityFunction> holder)) {
|
||||
reach(holder.value(), reach, categories.apply(name(holder)));
|
||||
return;
|
||||
}
|
||||
if (function instanceof ConstantFunction || reach.visited.put(function, Boolean.TRUE) != null) {
|
||||
return;
|
||||
}
|
||||
reach.count(category);
|
||||
if (function instanceof CacheFunction cache) {
|
||||
reach.caches.put(cache, Boolean.TRUE);
|
||||
}
|
||||
if (function instanceof SplineFunction spline) {
|
||||
reachSpline(spline.spline(), reach, category);
|
||||
return;
|
||||
}
|
||||
for (DensityFunction child : children(function)) {
|
||||
reach(child, reach, category);
|
||||
}
|
||||
}
|
||||
|
||||
private void reachSpline(CubicSpline<SplineFunction.Coordinate> spline, Reach reach, String category) {
|
||||
if (!(spline instanceof CubicSpline.Multipoint<SplineFunction.Coordinate> multipoint)) {
|
||||
return;
|
||||
}
|
||||
if (reach.visited.put(multipoint, Boolean.TRUE) != null) {
|
||||
return;
|
||||
}
|
||||
reach.count(category);
|
||||
reach(multipoint.coordinate().function(), reach, category);
|
||||
for (CubicSpline<SplineFunction.Coordinate> value : multipoint.values()) {
|
||||
reachSpline(value, reach, category);
|
||||
}
|
||||
}
|
||||
|
||||
private Body body(DensityFunction value) {
|
||||
Body known = bodies.get(value);
|
||||
if (known != null) {
|
||||
return known;
|
||||
}
|
||||
Body body = new Body();
|
||||
local(value, body, true);
|
||||
bodies.put(value, body);
|
||||
return body;
|
||||
}
|
||||
|
||||
private void local(DensityFunction function, Body body, boolean top) {
|
||||
if (function instanceof DensityFunctions.HolderHolder(Holder<DensityFunction> holder)) {
|
||||
if (!top) {
|
||||
DensityFunction value = holder.value();
|
||||
names.putIfAbsent(value, name(holder));
|
||||
body.references.merge(value, 1.0, Double::sum);
|
||||
return;
|
||||
}
|
||||
local(holder.value(), body, true);
|
||||
return;
|
||||
}
|
||||
if (function instanceof ConstantFunction) {
|
||||
return;
|
||||
}
|
||||
body.self++;
|
||||
if (function instanceof SplineFunction spline) {
|
||||
localSpline(spline.spline(), body);
|
||||
return;
|
||||
}
|
||||
for (DensityFunction child : children(function)) {
|
||||
local(child, body, false);
|
||||
}
|
||||
}
|
||||
|
||||
private void localSpline(CubicSpline<SplineFunction.Coordinate> spline, Body body) {
|
||||
if (!(spline instanceof CubicSpline.Multipoint<SplineFunction.Coordinate> multipoint)) {
|
||||
return;
|
||||
}
|
||||
body.self++;
|
||||
local(multipoint.coordinate().function(), body, false);
|
||||
for (CubicSpline<SplineFunction.Coordinate> value : multipoint.values()) {
|
||||
localSpline(value, body);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<DensityFunction, Double> multiplicity(Body root) {
|
||||
List<DensityFunction> order = new ArrayList<>();
|
||||
Map<DensityFunction, Boolean> visited = new IdentityHashMap<>();
|
||||
for (DensityFunction child : root.references.keySet()) {
|
||||
postOrder(child, visited, order);
|
||||
}
|
||||
Collections.reverse(order);
|
||||
Map<DensityFunction, Double> multiplicity = new IdentityHashMap<>(order.size() * 2);
|
||||
for (Map.Entry<DensityFunction, Double> entry : root.references.entrySet()) {
|
||||
multiplicity.merge(entry.getKey(), entry.getValue(), Double::sum);
|
||||
}
|
||||
for (DensityFunction value : order) {
|
||||
double times = multiplicity.getOrDefault(value, 0.0);
|
||||
for (Map.Entry<DensityFunction, Double> entry : body(value).references.entrySet()) {
|
||||
multiplicity.merge(entry.getKey(), times * entry.getValue(), Double::sum);
|
||||
}
|
||||
}
|
||||
return multiplicity;
|
||||
}
|
||||
|
||||
private void postOrder(DensityFunction value, Map<DensityFunction, Boolean> visited, List<DensityFunction> order) {
|
||||
if (visited.put(value, Boolean.TRUE) != null) {
|
||||
return;
|
||||
}
|
||||
for (DensityFunction child : body(value).references.keySet()) {
|
||||
postOrder(child, visited, order);
|
||||
}
|
||||
order.add(value);
|
||||
}
|
||||
|
||||
private static double surfaceSteps(FindTopSurfaceFunction surface) {
|
||||
double upper = surface.upperBound().range().max();
|
||||
if (!Double.isFinite(upper)) {
|
||||
return MAX_SURFACE_STEPS;
|
||||
}
|
||||
double steps = Math.floor((upper - surface.lowerBound()) / surface.cellHeight()) + 1.0;
|
||||
return Math.clamp(steps, 1.0, MAX_SURFACE_STEPS);
|
||||
}
|
||||
|
||||
private static List<DensityFunction> children(DensityFunction function) {
|
||||
List<DensityFunction> children = new ArrayList<>(4);
|
||||
function.rewriteChildren(child -> {
|
||||
children.add(child);
|
||||
return child;
|
||||
});
|
||||
return children;
|
||||
}
|
||||
|
||||
private static String name(Holder<DensityFunction> holder) {
|
||||
return holder.unwrapKey().map(key -> key.identifier().toString()).orElse(INLINE);
|
||||
}
|
||||
|
||||
record Costs(double expanded, double lazy, double cachedLocal) {
|
||||
}
|
||||
|
||||
record Offender(String name, String category, double multiplicity, double selfNodes, double subtreeNodes,
|
||||
double contribution, double inclusive) {
|
||||
}
|
||||
|
||||
record RootCost(String root, long dagNodes, double cachedNodes, double expandedNodes, double uncachedPointWorst,
|
||||
int namedSubtrees, List<Offender> exclusive, List<Offender> inclusive, Map<String, Double> categoryShare,
|
||||
Map<String, Double> dagShare) {
|
||||
}
|
||||
|
||||
private static final class Reach {
|
||||
private final Map<Object, Boolean> visited = new IdentityHashMap<>();
|
||||
private final Map<CacheFunction, Boolean> caches = new IdentityHashMap<>();
|
||||
private final Map<String, Double> categories = new TreeMap<>();
|
||||
private long nodes;
|
||||
|
||||
private void count(String category) {
|
||||
nodes++;
|
||||
categories.merge(category, 1.0, Double::sum);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Body {
|
||||
private final Map<DensityFunction, Double> references = new IdentityHashMap<>();
|
||||
private double self;
|
||||
}
|
||||
}
|
||||
+409
@@ -0,0 +1,409 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.SortedMap;
|
||||
import java.util.TreeMap;
|
||||
|
||||
final class DensityJsonBreakdown {
|
||||
private static final String DENSITY_FUNCTIONS = "/worldgen/density_function/";
|
||||
private static final String NOISES = "/worldgen/noise/";
|
||||
private static final String JSON = ".json";
|
||||
private static final String CELLULAR = "cellular";
|
||||
private static final String NOISE_STYLE = "noise style";
|
||||
private static final String UNREFERENCED = "unreferenced";
|
||||
private static final String OFFSET_MARKER = "/offset_";
|
||||
private static final String OFFSET_SUFFIX = " offset copies";
|
||||
private static final int INSTANCE_SEGMENTS = 3;
|
||||
private static final Set<String> ROUTER_ROOTS = Set.of("final_density", "temperature", "vegetation", "continents", "erosion",
|
||||
"depth", "ridges", "chunk_surface_level", "barrier", "fluid_level_floodedness", "fluid_level_spread", "lava", "exclusion",
|
||||
"surface_level", "spawn_elevation");
|
||||
private static final long FNV_OFFSET = 0xcbf29ce484222325L;
|
||||
private static final long FNV_PRIME = 0x100000001b3L;
|
||||
private static final int TOP = 10;
|
||||
|
||||
private final String dimension;
|
||||
private final String dimensionPrefix;
|
||||
private final Map<String, Integer> categoryIndex = new HashMap<>();
|
||||
private final List<String> categoryNames = new ArrayList<>();
|
||||
private final Map<String, Entry> entries = new TreeMap<>();
|
||||
private final Map<String, List<String>> parents = new HashMap<>();
|
||||
private final Map<String, Long> owners = new HashMap<>();
|
||||
|
||||
private DensityJsonBreakdown(String dimension) {
|
||||
this.dimension = dimension;
|
||||
dimensionPrefix = dimension + "/";
|
||||
}
|
||||
|
||||
static DensityJsonBreakdown analyze(SortedMap<String, byte[]> files, String dimension) {
|
||||
DensityJsonBreakdown breakdown = new DensityJsonBreakdown(dimension);
|
||||
breakdown.load(files);
|
||||
return breakdown;
|
||||
}
|
||||
|
||||
static String category(String dimension, String id) {
|
||||
String base = baseCategory(dimension, id);
|
||||
return id.contains(OFFSET_MARKER) ? base + OFFSET_SUFFIX : base;
|
||||
}
|
||||
|
||||
private static String baseCategory(String dimension, String id) {
|
||||
int separator = id.indexOf(':');
|
||||
String path = separator < 0 ? id : id.substring(separator + 1);
|
||||
String dimensionPrefix = dimension + "/";
|
||||
if (path.startsWith(dimensionPrefix)) {
|
||||
path = path.substring(dimensionPrefix.length());
|
||||
}
|
||||
if (ROUTER_ROOTS.contains(path)) {
|
||||
return "router";
|
||||
}
|
||||
if (path.startsWith("cell/")) {
|
||||
return CELLULAR;
|
||||
}
|
||||
if (path.startsWith("style/")) {
|
||||
return NOISE_STYLE;
|
||||
}
|
||||
if (path.startsWith("biome/selector/")) {
|
||||
return "biome selector";
|
||||
}
|
||||
if (path.startsWith("biome/carving/")) {
|
||||
return "biome carving";
|
||||
}
|
||||
if (path.startsWith("biome/")) {
|
||||
return "biome decision";
|
||||
}
|
||||
if (path.equals("height") || path.startsWith("height/") || path.startsWith("terrain/height/")) {
|
||||
return "terrain height";
|
||||
}
|
||||
if (path.startsWith("terrain/generator/")) {
|
||||
return "terrain generator";
|
||||
}
|
||||
if (path.startsWith("terrain/volume/")) {
|
||||
return "terrain volumetric";
|
||||
}
|
||||
if (path.startsWith("terrain/")) {
|
||||
return "terrain bands";
|
||||
}
|
||||
if (path.startsWith("cave")) {
|
||||
return "caves";
|
||||
}
|
||||
if (path.startsWith("surface/")) {
|
||||
return "surface";
|
||||
}
|
||||
int slash = path.indexOf('/');
|
||||
return slash < 0 ? path : path.substring(0, slash);
|
||||
}
|
||||
|
||||
Report report() {
|
||||
Map<String, CategoryAccumulator> categories = new TreeMap<>();
|
||||
Map<String, CategoryAccumulator> groups = new TreeMap<>();
|
||||
Map<String, Long> libraryOwners = new TreeMap<>();
|
||||
long totalBytes = 0;
|
||||
long totalNodes = 0;
|
||||
for (Map.Entry<String, Entry> item : entries.entrySet()) {
|
||||
String id = item.getKey();
|
||||
Entry entry = item.getValue();
|
||||
totalBytes += entry.bytes;
|
||||
totalNodes += entry.nodes;
|
||||
String category = category(dimension, id);
|
||||
categories.computeIfAbsent(category, ignored -> new CategoryAccumulator()).add(entry);
|
||||
groups.computeIfAbsent(group(id), ignored -> new CategoryAccumulator()).add(entry);
|
||||
if (isLibrary(category)) {
|
||||
libraryOwners.merge(category + " via " + ownerLabel(owners(id)), entry.bytes, Long::sum);
|
||||
}
|
||||
}
|
||||
List<CategoryBytes> categoryRows = rows(categories, Integer.MAX_VALUE);
|
||||
List<CategoryBytes> groupRows = rows(groups, 40);
|
||||
List<Bytes> ownerRows = new ArrayList<>();
|
||||
for (Map.Entry<String, Long> owner : libraryOwners.entrySet()) {
|
||||
ownerRows.add(new Bytes(owner.getKey(), owner.getValue()));
|
||||
}
|
||||
ownerRows.sort(Comparator.comparingLong(Bytes::bytes).reversed());
|
||||
List<Bytes> largest = new ArrayList<>();
|
||||
for (Map.Entry<String, Entry> item : entries.entrySet()) {
|
||||
largest.add(new Bytes(item.getKey(), item.getValue().bytes));
|
||||
}
|
||||
largest.sort(Comparator.comparingLong(Bytes::bytes).reversed());
|
||||
return new Report(entries.size(), totalBytes, totalNodes, categoryRows, groupRows, ownerRows,
|
||||
List.copyOf(largest.subList(0, Math.min(TOP, largest.size()))), cellularInstances());
|
||||
}
|
||||
|
||||
private void load(SortedMap<String, byte[]> files) {
|
||||
Map<String, byte[]> bodies = new HashMap<>();
|
||||
Set<String> noises = new HashSet<>();
|
||||
for (Map.Entry<String, byte[]> file : files.entrySet()) {
|
||||
String id = resourceId(file.getKey(), DENSITY_FUNCTIONS);
|
||||
if (id != null) {
|
||||
bodies.put(id, file.getValue());
|
||||
continue;
|
||||
}
|
||||
String noise = resourceId(file.getKey(), NOISES);
|
||||
if (noise != null) {
|
||||
noises.add(noise);
|
||||
}
|
||||
}
|
||||
for (Map.Entry<String, byte[]> body : bodies.entrySet()) {
|
||||
JsonElement json = JsonParser.parseString(new String(body.getValue(), StandardCharsets.UTF_8));
|
||||
Entry entry = new Entry(body.getValue().length);
|
||||
entry.shape = shape(json, bodies.keySet(), noises, entry);
|
||||
entries.put(body.getKey(), entry);
|
||||
for (String reference : entry.references) {
|
||||
parents.computeIfAbsent(reference, ignored -> new ArrayList<>()).add(body.getKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private long shape(JsonElement element, Set<String> functions, Set<String> noises, Entry entry) {
|
||||
if (element.isJsonObject()) {
|
||||
entry.nodes++;
|
||||
JsonObject object = element.getAsJsonObject();
|
||||
long hash = mix(FNV_OFFSET, '{');
|
||||
for (String key : new TreeMap<>(object.asMap()).keySet()) {
|
||||
hash = mix(mix(hash, key.hashCode()), shape(object.get(key), functions, noises, entry));
|
||||
}
|
||||
return mix(hash, '}');
|
||||
}
|
||||
if (element.isJsonArray()) {
|
||||
JsonArray array = element.getAsJsonArray();
|
||||
long hash = mix(FNV_OFFSET, '[');
|
||||
for (JsonElement item : array) {
|
||||
hash = mix(hash, shape(item, functions, noises, entry));
|
||||
}
|
||||
return mix(hash, ']');
|
||||
}
|
||||
if (element.isJsonPrimitive()) {
|
||||
JsonPrimitive primitive = element.getAsJsonPrimitive();
|
||||
if (primitive.isNumber()) {
|
||||
return mix(FNV_OFFSET, 'N');
|
||||
}
|
||||
if (primitive.isString()) {
|
||||
String value = primitive.getAsString();
|
||||
if (functions.contains(value)) {
|
||||
entry.references.add(value);
|
||||
return mix(FNV_OFFSET, 'R');
|
||||
}
|
||||
if (noises.contains(value)) {
|
||||
return mix(FNV_OFFSET, 'Z');
|
||||
}
|
||||
return mix(FNV_OFFSET, value.hashCode());
|
||||
}
|
||||
return mix(FNV_OFFSET, primitive.getAsBoolean() ? 'T' : 'F');
|
||||
}
|
||||
return mix(FNV_OFFSET, '0');
|
||||
}
|
||||
|
||||
private List<CellularShape> cellularInstances() {
|
||||
Map<String, List<String>> instances = new TreeMap<>();
|
||||
for (String id : entries.keySet()) {
|
||||
if (!isLibrary(category(dimension, id)) || !id.contains(":cell/")) {
|
||||
continue;
|
||||
}
|
||||
instances.computeIfAbsent(instanceKey(id), ignored -> new ArrayList<>()).add(id);
|
||||
}
|
||||
Map<Long, CellularAccumulator> shapes = new HashMap<>();
|
||||
for (Map.Entry<String, List<String>> instance : instances.entrySet()) {
|
||||
List<String> members = instance.getValue();
|
||||
members.sort(String::compareTo);
|
||||
long hash = FNV_OFFSET;
|
||||
long bytes = 0;
|
||||
long owner = 0L;
|
||||
StringBuilder layout = new StringBuilder();
|
||||
for (String member : members) {
|
||||
Entry entry = entries.get(member);
|
||||
String suffix = member.substring(instance.getKey().length());
|
||||
hash = mix(mix(hash, suffix.hashCode()), entry.shape);
|
||||
bytes += entry.bytes;
|
||||
owner |= owners(member);
|
||||
if (!layout.isEmpty()) {
|
||||
layout.append(',');
|
||||
}
|
||||
layout.append(suffix);
|
||||
}
|
||||
CellularAccumulator accumulator = shapes.computeIfAbsent(hash, ignored -> new CellularAccumulator(instance.getKey(), layout.toString()));
|
||||
accumulator.instances++;
|
||||
accumulator.bytes += bytes;
|
||||
accumulator.owners |= owner;
|
||||
}
|
||||
List<CellularShape> rows = new ArrayList<>(shapes.size());
|
||||
for (CellularAccumulator accumulator : shapes.values()) {
|
||||
rows.add(new CellularShape(accumulator.example, accumulator.layout, accumulator.instances, accumulator.bytes, ownerLabel(accumulator.owners)));
|
||||
}
|
||||
rows.sort(Comparator.comparingLong(CellularShape::bytes).reversed());
|
||||
return List.copyOf(rows);
|
||||
}
|
||||
|
||||
private long owners(String id) {
|
||||
Long known = owners.get(id);
|
||||
if (known != null) {
|
||||
return known;
|
||||
}
|
||||
owners.put(id, 0L);
|
||||
long mask = 0L;
|
||||
List<String> referrers = parents.get(id);
|
||||
if (referrers == null) {
|
||||
mask = bit(UNREFERENCED);
|
||||
} else {
|
||||
for (String parent : referrers) {
|
||||
String category = category(dimension, parent);
|
||||
mask |= isLibrary(category) ? owners(parent) : bit(category);
|
||||
}
|
||||
}
|
||||
owners.put(id, mask);
|
||||
return mask;
|
||||
}
|
||||
|
||||
private long bit(String category) {
|
||||
int index = categoryIndex.computeIfAbsent(category, name -> {
|
||||
categoryNames.add(name);
|
||||
return categoryNames.size() - 1;
|
||||
});
|
||||
if (index >= Long.SIZE) {
|
||||
throw new IllegalStateException("More than " + Long.SIZE + " density function owner categories");
|
||||
}
|
||||
return 1L << index;
|
||||
}
|
||||
|
||||
private String ownerLabel(long mask) {
|
||||
if (mask == 0L) {
|
||||
return UNREFERENCED;
|
||||
}
|
||||
List<String> labels = new ArrayList<>(Long.bitCount(mask));
|
||||
for (int index = 0; index < categoryNames.size(); index++) {
|
||||
if ((mask & (1L << index)) != 0L) {
|
||||
labels.add(categoryNames.get(index));
|
||||
}
|
||||
}
|
||||
labels.sort(String::compareTo);
|
||||
return String.join("+", labels);
|
||||
}
|
||||
|
||||
private String group(String id) {
|
||||
int separator = id.indexOf(':');
|
||||
String path = separator < 0 ? id : id.substring(separator + 1);
|
||||
if (path.startsWith(dimensionPrefix)) {
|
||||
path = path.substring(dimensionPrefix.length());
|
||||
}
|
||||
String[] segments = path.split("/");
|
||||
if (segments.length == 1) {
|
||||
return segments[0];
|
||||
}
|
||||
if (segments[0].equals("cell") || segments[0].equals("style")) {
|
||||
return segments[0] + "/*/" + segments[segments.length - 1];
|
||||
}
|
||||
return segments[0] + "/" + segments[1];
|
||||
}
|
||||
|
||||
private static boolean isLibrary(String category) {
|
||||
return category.startsWith(CELLULAR) || category.startsWith(NOISE_STYLE);
|
||||
}
|
||||
|
||||
private static String instanceKey(String id) {
|
||||
int end = id.indexOf(':') + 1;
|
||||
for (int segment = 0; segment < INSTANCE_SEGMENTS && end > 0; segment++) {
|
||||
int slash = id.indexOf('/', end);
|
||||
if (slash < 0) {
|
||||
return id;
|
||||
}
|
||||
end = slash + 1;
|
||||
}
|
||||
return id.substring(0, end - 1);
|
||||
}
|
||||
|
||||
private static List<CategoryBytes> rows(Map<String, CategoryAccumulator> accumulators, int limit) {
|
||||
List<CategoryBytes> rows = new ArrayList<>(accumulators.size());
|
||||
for (Map.Entry<String, CategoryAccumulator> item : accumulators.entrySet()) {
|
||||
CategoryAccumulator accumulator = item.getValue();
|
||||
long representative = 0;
|
||||
for (long bytes : accumulator.representativeBytes.values()) {
|
||||
representative += bytes;
|
||||
}
|
||||
rows.add(new CategoryBytes(item.getKey(), accumulator.files, accumulator.bytes, accumulator.nodes,
|
||||
accumulator.representativeBytes.size(), accumulator.bytes - representative));
|
||||
}
|
||||
rows.sort(Comparator.comparingLong(CategoryBytes::bytes).reversed());
|
||||
return List.copyOf(rows.subList(0, Math.min(limit, rows.size())));
|
||||
}
|
||||
|
||||
private static String resourceId(String path, String folder) {
|
||||
int marker = path.indexOf(folder);
|
||||
if (!path.startsWith("data/") || marker < 0 || !path.endsWith(JSON)) {
|
||||
return null;
|
||||
}
|
||||
return path.substring("data/".length(), marker) + ":" + path.substring(marker + folder.length(), path.length() - JSON.length());
|
||||
}
|
||||
|
||||
private static long mix(long hash, long value) {
|
||||
long mixed = hash ^ value;
|
||||
mixed *= FNV_PRIME;
|
||||
return mixed ^ (mixed >>> 29);
|
||||
}
|
||||
|
||||
record Report(int files, long bytes, long nodes, List<CategoryBytes> categories, List<CategoryBytes> groups,
|
||||
List<Bytes> libraryOwners, List<Bytes> largest, List<CellularShape> cellularShapes) {
|
||||
int cellularInstances() {
|
||||
int count = 0;
|
||||
for (CellularShape shape : cellularShapes) {
|
||||
count += shape.instances();
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
record CategoryBytes(String category, int files, long bytes, long nodes, int distinctShapes, long structurallyDuplicateBytes) {
|
||||
}
|
||||
|
||||
record Bytes(String name, long bytes) {
|
||||
}
|
||||
|
||||
record CellularShape(String example, String members, int instances, long bytes, String owners) {
|
||||
}
|
||||
|
||||
private static final class Entry {
|
||||
private final long bytes;
|
||||
private final List<String> references = new ArrayList<>();
|
||||
private long nodes;
|
||||
private long shape;
|
||||
|
||||
private Entry(long bytes) {
|
||||
this.bytes = bytes;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CategoryAccumulator {
|
||||
private final Map<Long, Long> representativeBytes = new HashMap<>();
|
||||
private int files;
|
||||
private long bytes;
|
||||
private long nodes;
|
||||
|
||||
private void add(Entry entry) {
|
||||
files++;
|
||||
bytes += entry.bytes;
|
||||
nodes += entry.nodes;
|
||||
representativeBytes.putIfAbsent(entry.shape, entry.bytes);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CellularAccumulator {
|
||||
private final String example;
|
||||
private final String layout;
|
||||
private int instances;
|
||||
private long bytes;
|
||||
private long owners;
|
||||
|
||||
private CellularAccumulator(String example, String layout) {
|
||||
this.example = example;
|
||||
this.layout = layout;
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.platform.ClasspathVanillaDataSource;
|
||||
import art.arcane.iris.platform.bukkit.nms.BukkitGenerationRegistry;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.PlatformGenerationRegistry;
|
||||
@@ -9,6 +10,7 @@ import art.arcane.iris.spi.PlatformBiomeWriter;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.iris.spi.PlatformScheduler;
|
||||
import art.arcane.iris.spi.PlatformStructureHooks;
|
||||
import art.arcane.iris.spi.VanillaDataSource;
|
||||
import art.arcane.volmlib.nativelib.entity.NativeEntityType;
|
||||
import art.arcane.volmlib.nativelib.item.NativeItem;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState;
|
||||
@@ -35,6 +37,7 @@ final class HeadlessNativePlatform implements IrisPlatform, PlatformRegistries,
|
||||
private final NativeRegistryAccess nativeAccess;
|
||||
private final PlatformBiomeWriter biomeWriter;
|
||||
private final PlatformGenerationRegistry generationRegistry;
|
||||
private final ClasspathVanillaDataSource vanillaData = new ClasspathVanillaDataSource(() -> SharedConstants.class);
|
||||
private final NativeBlockResolver blocks = new NativeBlockResolver(this);
|
||||
private final NativeBlockState air = blocks.getAir();
|
||||
private final ConcurrentHashMap<String, NativeBlockState> states = new ConcurrentHashMap<>();
|
||||
@@ -215,6 +218,11 @@ final class HeadlessNativePlatform implements IrisPlatform, PlatformRegistries,
|
||||
return biomeWriter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public VanillaDataSource vanillaData() {
|
||||
return vanillaData;
|
||||
}
|
||||
|
||||
@Override
|
||||
public File dataFolder() {
|
||||
return services.dataFolder();
|
||||
|
||||
+11
-4
@@ -4,6 +4,7 @@ import art.arcane.iris.pack.datapack.IrisDatapackCompiler;
|
||||
import art.arcane.iris.pack.datapack.v263.DataFixerV263;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.network.chat.Component;
|
||||
@@ -25,6 +26,7 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
final class HeadlessNativeRegistries implements AutoCloseable {
|
||||
@@ -70,7 +72,7 @@ final class HeadlessNativeRegistries implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private static HeadlessNativeRegistries load(Path directory) {
|
||||
static HeadlessNativeRegistries load(Path directory) {
|
||||
PackLocationInfo location = new PackLocationInfo("iris-headless", Component.literal("Iris"),
|
||||
PackSource.WORLD, Optional.empty());
|
||||
MultiPackResourceManager resources = new MultiPackResourceManager(PackType.SERVER_DATA, List.of(
|
||||
@@ -78,12 +80,17 @@ final class HeadlessNativeRegistries implements AutoCloseable {
|
||||
try {
|
||||
RegistryAccess.Frozen builtins = RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY);
|
||||
RegistryAccess.Frozen loaded;
|
||||
RegistryAccess.Frozen dimensions;
|
||||
try (ExecutorService executor = Executors.newFixedThreadPool(4)) {
|
||||
loaded = RegistryDataLoader.load(resources, builtins.listRegistries().toList(),
|
||||
RegistryDataLoader.WORLD_REGISTRIES, executor).join();
|
||||
List<HolderLookup.RegistryLookup<?>> dimensionContext =
|
||||
Stream.concat(builtins.listRegistries(), loaded.listRegistries()).toList();
|
||||
dimensions = RegistryDataLoader.load(resources, dimensionContext,
|
||||
RegistryDataLoader.DIMENSION_REGISTRIES, executor).join();
|
||||
}
|
||||
RegistryAccess.Frozen registries = new RegistryAccess.ImmutableRegistryAccess(
|
||||
Stream.concat(builtins.registries(), loaded.registries())).freeze();
|
||||
RegistryAccess.Frozen registries = new RegistryAccess.ImmutableRegistryAccess(Stream.of(
|
||||
builtins.registries(), loaded.registries(), dimensions.registries()).flatMap(Function.identity())).freeze();
|
||||
HeadlessNativeBootstrap.bindComponents(registries);
|
||||
return new HeadlessNativeRegistries(directory, resources, registries);
|
||||
} catch (RuntimeException failure) {
|
||||
@@ -106,7 +113,7 @@ final class HeadlessNativeRegistries implements AutoCloseable {
|
||||
delete(directory);
|
||||
}
|
||||
|
||||
private static void delete(Path directory) throws IOException {
|
||||
static void delete(Path directory) throws IOException {
|
||||
try (Stream<Path> paths = Files.walk(directory)) {
|
||||
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) {
|
||||
Files.delete(path);
|
||||
|
||||
+8
-2
@@ -32,6 +32,7 @@ final class HeadlessNativeRuntime implements RealPackProbeSupport.RuntimeBinding
|
||||
private final Path stagingRoot;
|
||||
private NativeRegionTerrainWriter writer;
|
||||
private HeadlessNativeRegistries nativeRegistries;
|
||||
private File preparedPack;
|
||||
private HolderLookup.Provider registries;
|
||||
|
||||
HeadlessNativeRuntime() {
|
||||
@@ -60,12 +61,16 @@ final class HeadlessNativeRuntime implements RealPackProbeSupport.RuntimeBinding
|
||||
@Override
|
||||
public void prepare(IrisData data, IrisDimension dimension) {
|
||||
try {
|
||||
if (nativeRegistries != null) {
|
||||
File pack = data.getDataFolder().getCanonicalFile();
|
||||
if (nativeRegistries != null && !pack.equals(preparedPack)) {
|
||||
throw new IllegalStateException("Headless native runtime supports one immutable pack per process");
|
||||
}
|
||||
if (nativeRegistries == null) {
|
||||
nativeRegistries = HeadlessNativeRegistries.compile(data.getDataFolder());
|
||||
registries = nativeRegistries.registries();
|
||||
preparedPack = pack;
|
||||
writer = new NativeRegionTerrainWriter(nativeRegistries.registries());
|
||||
}
|
||||
registries = nativeRegistries.registries();
|
||||
} catch (IOException failure) {
|
||||
throw new UncheckedIOException("Cannot load native generation registries", failure);
|
||||
}
|
||||
@@ -92,6 +97,7 @@ final class HeadlessNativeRuntime implements RealPackProbeSupport.RuntimeBinding
|
||||
if (nativeRegistries != null) {
|
||||
nativeRegistries.close();
|
||||
nativeRegistries = null;
|
||||
preparedPack = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+30
-12
@@ -2,8 +2,9 @@ package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.generation.concurrent.MultiBurst;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyTileCache;
|
||||
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
|
||||
import art.arcane.iris.generation.hydrology.engine.HydrologyPregeneration;
|
||||
import art.arcane.iris.generation.hydrology.plan.WindowKey;
|
||||
import art.arcane.iris.world.pregen.PregenHydrology;
|
||||
import art.arcane.iris.world.pregen.PregenPerformanceProfile;
|
||||
|
||||
import net.minecraft.core.registries.Registries;
|
||||
@@ -31,11 +32,16 @@ import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
public final class OfflineRegionGenerator {
|
||||
private static final int CHUNKS_PER_HYDROLOGY_REGION = WindowKey.SIZE >> 4;
|
||||
|
||||
private OfflineRegionGenerator() {
|
||||
}
|
||||
|
||||
@@ -97,14 +103,13 @@ public final class OfflineRegionGenerator {
|
||||
PregenPerformanceProfile.apply(session.engine());
|
||||
System.out.println("generationProfile=pregen noiseCacheSize=" + IrisSettings.get().getPerformance().getNoiseCacheSize()
|
||||
+ " burstParallelism=" + MultiBurst.burst.parallelism() + " multicore=true");
|
||||
IrisHydrologyRuntime hydrology = session.engine().getComplex().getHydrologyRuntime();
|
||||
try (HydrologyTileCache.PregenerationScope hydrologyScope = hydrology == null ? null
|
||||
: hydrology.preparePregeneration(configuration.hydrologyArea());
|
||||
try (HydrologyPregeneration pregeneration = PregenHydrology.open(session.engine());
|
||||
HeadlessTerrainContext context = HeadlessTerrainContext.create(new HeadlessTerrainContext.Options(
|
||||
session.engine(), runtime, staged.resolve("templates"), configuration.levelKey(),
|
||||
configuration.noiseSettings(), spigot));
|
||||
RegionGenerationWindow workers = new RegionGenerationWindow(configuration.parallelism(),
|
||||
RegionGenerationWindow.Policy.STANDALONE)) {
|
||||
PregenHydrology.prepareAll(pregeneration, configuration.hydrologyRegions());
|
||||
HeadlessRegionTerrain.Session rolling = new HeadlessRegionTerrain.Session(workers);
|
||||
Path regionDirectory = session.engine().getTarget().getWorld().worldFolder().toPath().resolve("region");
|
||||
Files.createDirectory(regionDirectory);
|
||||
@@ -239,14 +244,27 @@ public final class OfflineRegionGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
HydrologyTileCache.PregenerationArea hydrologyArea() {
|
||||
List<long[]> hydrologyRegions() {
|
||||
int halo = NativeRegionPlan.terrainHalo();
|
||||
long minimumX = ((long) chunkX - halo) * 16L;
|
||||
long minimumZ = ((long) chunkZ - halo) * 16L;
|
||||
long maximumX = ((long) chunkX + (long) width * regionsX + halo) * 16L - 1L;
|
||||
long maximumZ = ((long) chunkZ + (long) width * regionsZ + halo) * 16L - 1L;
|
||||
return new HydrologyTileCache.PregenerationArea(Math.toIntExact((minimumX + maximumX) / 2L),
|
||||
Math.toIntExact((minimumZ + maximumZ) / 2L), minimumX, minimumZ, maximumX, maximumZ);
|
||||
Set<Long> seen = new HashSet<>();
|
||||
List<long[]> regions = new ArrayList<>();
|
||||
for (int row = 0; row < regionsZ; row++) {
|
||||
for (int column = 0; column < regionsX; column++) {
|
||||
int regionX = row % 2 == 0 ? column : regionsX - column - 1;
|
||||
int minimumX = Math.floorDiv(chunkX + regionX * width - halo, CHUNKS_PER_HYDROLOGY_REGION);
|
||||
int maximumX = Math.floorDiv(chunkX + (regionX + 1) * width - 1 + halo, CHUNKS_PER_HYDROLOGY_REGION);
|
||||
int minimumZ = Math.floorDiv(chunkZ + row * width - halo, CHUNKS_PER_HYDROLOGY_REGION);
|
||||
int maximumZ = Math.floorDiv(chunkZ + (row + 1) * width - 1 + halo, CHUNKS_PER_HYDROLOGY_REGION);
|
||||
for (int hydrologyZ = minimumZ; hydrologyZ <= maximumZ; hydrologyZ++) {
|
||||
for (int hydrologyX = minimumX; hydrologyX <= maximumX; hydrologyX++) {
|
||||
if (seen.add(((long) hydrologyX << 32) | (hydrologyZ & 0xFFFFFFFFL))) {
|
||||
regions.add(new long[]{hydrologyX, hydrologyZ});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return regions;
|
||||
}
|
||||
|
||||
static Configuration parse(String[] arguments) throws IOException {
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import io.papermc.paper.registry.PaperRegistryAccess;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
final class PaperRegistryScope implements AutoCloseable {
|
||||
private final Map<Object, Object> registries;
|
||||
private final Map<Object, Object> previous;
|
||||
|
||||
private PaperRegistryScope(Map<Object, Object> registries) {
|
||||
this.registries = registries;
|
||||
this.previous = new HashMap<>(registries);
|
||||
registries.clear();
|
||||
}
|
||||
|
||||
static PaperRegistryScope open() {
|
||||
return new PaperRegistryScope(registries());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
registries.clear();
|
||||
registries.putAll(previous);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<Object, Object> registries() {
|
||||
try {
|
||||
Field field = PaperRegistryAccess.class.getDeclaredField("registries");
|
||||
field.setAccessible(true);
|
||||
return (Map<Object, Object>) field.get(PaperRegistryAccess.instance());
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw new IllegalStateException("Paper registry access has no registries map to isolate", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.QuartPos;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.util.datafix.DataFixers;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.LevelHeightAccessor;
|
||||
import net.minecraft.world.level.StructureManager;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeManager;
|
||||
import net.minecraft.world.level.biome.BiomeResolver;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.PalettedContainerFactory;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import net.minecraft.world.level.chunk.UpgradeData;
|
||||
import net.minecraft.world.level.dimension.DimensionType;
|
||||
import net.minecraft.world.level.dimension.LevelStem;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.NoiseChunk;
|
||||
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
|
||||
import net.minecraft.world.level.levelgen.NoiseSettings;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.WorldGenerationContext;
|
||||
import net.minecraft.world.level.levelgen.blending.Blender;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityVolume;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.ScopedDensityBuffer;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
|
||||
import net.minecraft.world.level.storage.LevelStorageSource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.SortedMap;
|
||||
import java.util.SortedSet;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class WorldgenExportSampler implements AutoCloseable {
|
||||
private static final String DENSITY_FUNCTIONS = "/worldgen/density_function/";
|
||||
private static final int CHUNK_WIDTH = 16;
|
||||
private static final Method CREATE_NOISE_CHUNK = generatorMethod("createNoiseChunk", ChunkAccess.class, StructureManager.class, Blender.class,
|
||||
RandomState.class, NoiseSettings.class);
|
||||
private static final Method DO_FILL = generatorMethod("doFill", NoiseChunk.class, ChunkAccess.class);
|
||||
|
||||
private final HeadlessNativeRegistries loaded;
|
||||
private final NoiseBasedChunkGenerator generator;
|
||||
private final RandomState randomState;
|
||||
private final ThreadLocal<BiomeResolver> resolvers;
|
||||
private final LevelHeightAccessor heights;
|
||||
private final SortedSet<String> densityFunctionIds;
|
||||
private final ThreadLocal<SamplerContext> densityContexts = ThreadLocal.withInitial(() -> SamplerContext.builder().enableCaches().build());
|
||||
private final PalettedContainerFactory containers;
|
||||
private final long seed;
|
||||
private final long biomeZoomSeed;
|
||||
|
||||
private WorldgenExportSampler(HeadlessNativeRegistries loaded, LevelStem stem, long seed, SortedSet<String> densityFunctionIds) {
|
||||
this.loaded = loaded;
|
||||
if (!(stem.generator() instanceof NoiseBasedChunkGenerator noiseGenerator)) {
|
||||
throw new IllegalStateException("Exported dimension uses " + stem.generator().getClass().getSimpleName() + ", not a noise generator");
|
||||
}
|
||||
this.generator = noiseGenerator;
|
||||
this.randomState = RandomState.create(loaded.registries().lookupOrThrow(Registries.NOISE), seed, noiseGenerator.generatorSettings().value());
|
||||
this.resolvers = ThreadLocal.withInitial(() -> noiseGenerator.getBiomeSource().createCachingResolver(randomState));
|
||||
DimensionType type = stem.type().value();
|
||||
this.heights = LevelHeightAccessor.create(type.minY(), type.height());
|
||||
this.densityFunctionIds = densityFunctionIds;
|
||||
this.containers = PalettedContainerFactory.create(loaded.registries());
|
||||
this.seed = seed;
|
||||
this.biomeZoomSeed = BiomeManager.obfuscateSeed(seed);
|
||||
}
|
||||
|
||||
public static WorldgenExportSampler open(Map<String, byte[]> files, WorldgenExportMode mode, String dimensionId, long seed) throws IOException {
|
||||
Objects.requireNonNull(mode);
|
||||
Identifier dimension = Identifier.parse(dimensionId);
|
||||
HeadlessNativeBootstrap.initialize();
|
||||
Path directory = Files.createTempDirectory("iris-worldgen-export-");
|
||||
HeadlessNativeRegistries loaded;
|
||||
try {
|
||||
for (Map.Entry<String, byte[]> file : files.entrySet()) {
|
||||
Path target = directory.resolve(file.getKey()).normalize();
|
||||
if (!target.startsWith(directory)) {
|
||||
throw new IllegalArgumentException("Export file escapes the datapack root: " + file.getKey());
|
||||
}
|
||||
Files.createDirectories(target.getParent());
|
||||
Files.write(target, file.getValue());
|
||||
}
|
||||
try (PaperRegistryScope scope = PaperRegistryScope.open()) {
|
||||
loaded = HeadlessNativeRegistries.load(directory);
|
||||
}
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
HeadlessNativeRegistries.delete(directory);
|
||||
throw failure;
|
||||
}
|
||||
try {
|
||||
return new WorldgenExportSampler(loaded, stem(loaded.registries(), mode, dimension), seed, densityFunctionIds(files));
|
||||
} catch (RuntimeException failure) {
|
||||
loaded.close();
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
public static WorldgenExportSampler open(Path datapack, WorldgenExportMode mode, String dimensionId, long seed) throws IOException {
|
||||
return open(files(datapack), mode, dimensionId, seed);
|
||||
}
|
||||
|
||||
public float density(String functionId, int x, int y, int z) {
|
||||
DensityFunction function = loaded.registries().lookupOrThrow(Registries.DENSITY_FUNCTION)
|
||||
.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, Identifier.parse(functionId))).value();
|
||||
return randomState.getSampler(function).sampleValue(densityContexts.get(), x, y, z);
|
||||
}
|
||||
|
||||
public float[] densityChunk(DensityFunction function, int chunkX, int chunkZ) {
|
||||
DensityVolume volume = new DensityVolume(CHUNK_WIDTH, heights.getHeight(), CHUNK_WIDTH, chunkX * CHUNK_WIDTH, heights.getMinY(), chunkZ * CHUNK_WIDTH);
|
||||
return sampleVolume(function, volume);
|
||||
}
|
||||
|
||||
public DensityFunction finalDensity() {
|
||||
return generator.generatorSettings().value().noiseRouter().finalDensity();
|
||||
}
|
||||
|
||||
public int baseHeight(int x, int z) {
|
||||
return generator.getBaseHeight(x, z, Heightmap.Types.WORLD_SURFACE_WG, heights, randomState);
|
||||
}
|
||||
|
||||
public int floorHeight(int x, int z) {
|
||||
return generator.getBaseHeight(x, z, Heightmap.Types.OCEAN_FLOOR_WG, heights, randomState);
|
||||
}
|
||||
|
||||
public ProtoChunk terrainChunk(int chunkX, int chunkZ) {
|
||||
ProtoChunk chunk = new ProtoChunk(new ChunkPos(chunkX, chunkZ), UpgradeData.EMPTY, heights, containers, null);
|
||||
NoiseGeneratorSettings settings = generator.generatorSettings().value();
|
||||
NoiseSettings noise = settings.noiseSettings().clampToHeightAccessor(chunk.getHeightAccessorForGeneration());
|
||||
try (NoiseChunk noiseChunk = (NoiseChunk) invoke(CREATE_NOISE_CHUNK, chunk, NoStructures.INSTANCE, Blender.empty(), randomState, noise)) {
|
||||
invoke(DO_FILL, noiseChunk, chunk);
|
||||
randomState.surfaceSystem().buildSurface(randomState, new BiomeManager(resolvers.get(), biomeZoomSeed),
|
||||
new WorldGenerationContext(generator, chunk.getHeightAccessorForGeneration()), chunk, noiseChunk, settings.materialRule().value(), null);
|
||||
}
|
||||
return chunk;
|
||||
}
|
||||
|
||||
public StructureStart structureStart(String structureId, int chunkX, int chunkZ) throws IOException {
|
||||
Holder.Reference<Structure> structure = loaded.registries().lookupOrThrow(Registries.STRUCTURE)
|
||||
.getOrThrow(ResourceKey.create(Registries.STRUCTURE, Identifier.parse(structureId)));
|
||||
Path storageRoot = Files.createTempDirectory("iris-worldgen-export-structures-");
|
||||
try (LevelStorageSource.LevelStorageAccess storage = LevelStorageSource.createDefault(storageRoot).createAccess("export")) {
|
||||
StructureTemplateManager templates = new StructureTemplateManager(loaded.resources(), storage, DataFixers.getDataFixer(),
|
||||
loaded.registries().lookupOrThrow(Registries.BLOCK));
|
||||
return structure.value().generate(structure, Level.OVERWORLD, loaded.registries(), generator, generator.getBiomeSource(),
|
||||
randomState.createClimateSampler(SamplerContext.builder().enableCaches().build()), randomState, templates, seed,
|
||||
new ChunkPos(chunkX, chunkZ), 0, heights, structure.value().biomes()::contains);
|
||||
} finally {
|
||||
HeadlessNativeRegistries.delete(storageRoot);
|
||||
}
|
||||
}
|
||||
|
||||
public String biome(int x, int y, int z) {
|
||||
Holder<Biome> biome = resolvers.get().getNoiseBiome(QuartPos.fromBlock(x), QuartPos.fromBlock(y), QuartPos.fromBlock(z));
|
||||
return biome.unwrapKey().orElseThrow().identifier().toString();
|
||||
}
|
||||
|
||||
public Set<String> possibleBiomes() {
|
||||
Set<String> biomes = new TreeSet<>();
|
||||
for (Holder<Biome> biome : generator.getBiomeSource().possibleBiomes()) {
|
||||
biomes.add(biome.unwrapKey().orElseThrow().identifier().toString());
|
||||
}
|
||||
return biomes;
|
||||
}
|
||||
|
||||
public SortedSet<String> densityFunctionIds() {
|
||||
return densityFunctionIds;
|
||||
}
|
||||
|
||||
public int minY() {
|
||||
return heights.getMinY();
|
||||
}
|
||||
|
||||
public int height() {
|
||||
return heights.getHeight();
|
||||
}
|
||||
|
||||
public int seaLevel() {
|
||||
return generator.getSeaLevel();
|
||||
}
|
||||
|
||||
public RegistryAccess registries() {
|
||||
return loaded.registries();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
loaded.close();
|
||||
}
|
||||
|
||||
private float[] sampleVolume(DensityFunction function, DensityVolume volume) {
|
||||
try (ScopedDensityBuffer buffer = randomState.getSampler(function).bind(densityContexts.get()).sampleVolume(volume)) {
|
||||
float[] values = new float[volume.size()];
|
||||
for (int index = 0; index < values.length; index++) {
|
||||
values[index] = buffer.get(index);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
private Object invoke(Method method, Object... arguments) {
|
||||
try {
|
||||
return method.invoke(generator, arguments);
|
||||
} catch (InvocationTargetException failure) {
|
||||
if (failure.getCause() instanceof RuntimeException runtime) {
|
||||
throw runtime;
|
||||
}
|
||||
if (failure.getCause() instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
throw new IllegalStateException("Terrain stage " + method.getName() + " failed", failure.getCause());
|
||||
} catch (IllegalAccessException failure) {
|
||||
throw new IllegalStateException("Terrain stage " + method.getName() + " is not accessible", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static Method generatorMethod(String name, Class<?>... parameters) {
|
||||
try {
|
||||
Method method = NoiseBasedChunkGenerator.class.getDeclaredMethod(name, parameters);
|
||||
method.setAccessible(true);
|
||||
return method;
|
||||
} catch (NoSuchMethodException failure) {
|
||||
throw new IllegalStateException("NoiseBasedChunkGenerator has no terrain stage " + name, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static LevelStem stem(RegistryAccess registries, WorldgenExportMode mode, Identifier dimension) {
|
||||
return switch (mode) {
|
||||
case PRESET -> registries.lookupOrThrow(Registries.WORLD_PRESET)
|
||||
.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, dimension)).value().overworld()
|
||||
.orElseThrow(() -> new IllegalStateException("World preset " + dimension + " has no overworld"));
|
||||
case DIMENSION -> levelStem(registries, dimension);
|
||||
case OVERWORLD -> levelStem(registries, LevelStem.OVERWORLD.identifier());
|
||||
};
|
||||
}
|
||||
|
||||
private static LevelStem levelStem(RegistryAccess registries, Identifier id) {
|
||||
return registries.lookupOrThrow(Registries.LEVEL_STEM).getOrThrow(ResourceKey.create(Registries.LEVEL_STEM, id)).value();
|
||||
}
|
||||
|
||||
private static SortedMap<String, byte[]> files(Path datapack) throws IOException {
|
||||
if (!Files.isDirectory(datapack)) {
|
||||
throw new IOException("Exported datapack " + datapack + " is not a directory");
|
||||
}
|
||||
SortedMap<String, byte[]> files = new TreeMap<>();
|
||||
try (Stream<Path> paths = Files.walk(datapack)) {
|
||||
List<Path> regular = paths.filter(Files::isRegularFile).toList();
|
||||
for (Path file : regular) {
|
||||
files.put(datapack.relativize(file).toString().replace('\\', '/'), Files.readAllBytes(file));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
private static SortedSet<String> densityFunctionIds(Map<String, byte[]> files) {
|
||||
SortedSet<String> ids = new TreeSet<>();
|
||||
for (String path : files.keySet()) {
|
||||
int marker = path.indexOf(DENSITY_FUNCTIONS);
|
||||
if (path.startsWith("data/") && marker > 0 && path.endsWith(".json")) {
|
||||
String namespace = path.substring("data/".length(), marker);
|
||||
ids.add(namespace + ":" + path.substring(marker + DENSITY_FUNCTIONS.length(), path.length() - ".json".length()));
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static final class NoStructures extends StructureManager {
|
||||
private static final NoStructures INSTANCE = new NoStructures();
|
||||
|
||||
private NoStructures() {
|
||||
super(null, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<StructureStart> startsForStructure(int chunkX, int chunkZ, Predicate<Structure> predicate) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import io.papermc.paper.configuration.WorldConfiguration;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.QuartPos;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.LevelHeightAccessor;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeManager;
|
||||
import net.minecraft.world.level.biome.BiomeResolver;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.PalettedContainerFactory;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import net.minecraft.world.level.chunk.UpgradeData;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.dimension.DimensionType;
|
||||
import net.minecraft.world.level.dimension.LevelStem;
|
||||
import net.minecraft.world.level.levelgen.Aquifer;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.NoiseChunk;
|
||||
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
|
||||
import net.minecraft.world.level.levelgen.NoiseSettings;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.WorldGenerationContext;
|
||||
import net.minecraft.world.level.levelgen.blending.Blender;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityVolume;
|
||||
import net.minecraft.world.level.levelgen.material.rule.MaterialRule;
|
||||
import org.spigotmc.SpigotWorldConfig;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.lang.management.ThreadMXBean;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.SplittableRandom;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
final class WorldgenStageBench {
|
||||
private static final int SAMPLE_RADIUS_BLOCKS = 32_768;
|
||||
private static final double NANOS_PER_MILLI = 1_000_000.0;
|
||||
private static final double NANOS_PER_MICRO = 1_000.0;
|
||||
private static final ThreadMXBean THREADS = ManagementFactory.getThreadMXBean();
|
||||
|
||||
private final String label;
|
||||
private final NoiseBasedChunkGenerator generator;
|
||||
private final NoiseGeneratorSettings settings;
|
||||
private final RandomState randomState;
|
||||
private final LevelHeightAccessor heights;
|
||||
private final PalettedContainerFactory containers;
|
||||
private final long seed;
|
||||
private final Method fill;
|
||||
private final Aquifer.FluidPicker fluidPicker;
|
||||
private final BiomeResolver uncached;
|
||||
private final Level surfaceLevel;
|
||||
|
||||
WorldgenStageBench(Options options) {
|
||||
label = options.label();
|
||||
if (!(options.stem().generator() instanceof NoiseBasedChunkGenerator noiseGenerator)) {
|
||||
throw new IllegalStateException(label + " uses " + options.stem().generator().getClass().getSimpleName() + ", not a noise generator");
|
||||
}
|
||||
generator = noiseGenerator;
|
||||
settings = noiseGenerator.generatorSettings().value();
|
||||
seed = options.seed();
|
||||
randomState = RandomState.create(options.registries().lookupOrThrow(Registries.NOISE), seed, settings);
|
||||
DimensionType type = options.stem().type().value();
|
||||
heights = LevelHeightAccessor.create(type.minY(), type.height());
|
||||
containers = PalettedContainerFactory.create(options.registries());
|
||||
fill = fillMethod();
|
||||
fluidPicker = fluidPicker(noiseGenerator);
|
||||
uncached = noiseGenerator.getBiomeSource().createUncachedResolver(randomState);
|
||||
surfaceLevel = paperConfiguredLevel();
|
||||
}
|
||||
|
||||
String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
NoiseGeneratorSettings settings() {
|
||||
return settings;
|
||||
}
|
||||
|
||||
void chunks(int originChunkX, int originChunkZ, int side, ChunkSamples samples) {
|
||||
int ring = side + 2;
|
||||
Map<Long, ProtoChunk> chunks = new HashMap<>(ring * ring * 2);
|
||||
for (int dz = -1; dz <= side; dz++) {
|
||||
for (int dx = -1; dx <= side; dx++) {
|
||||
ChunkPos position = new ChunkPos(originChunkX + dx, originChunkZ + dz);
|
||||
ProtoChunk chunk = new ProtoChunk(position, UpgradeData.EMPTY, heights, containers, null);
|
||||
long started = System.nanoTime();
|
||||
long startedCpu = THREADS.getCurrentThreadCpuTime();
|
||||
generator.createBiomes(randomState, Blender.empty(), null, chunk).join();
|
||||
samples.biomes.add(System.nanoTime() - started, THREADS.getCurrentThreadCpuTime() - startedCpu);
|
||||
chunk.setPersistedStatus(ChunkStatus.BIOMES);
|
||||
chunks.put(ChunkPos.pack(position.x(), position.z()), chunk);
|
||||
}
|
||||
}
|
||||
RegionBiomes region = new RegionBiomes(chunks, uncached);
|
||||
BiomeManager biomeManager = new BiomeManager(region, BiomeManager.obfuscateSeed(seed));
|
||||
MaterialRule materialRule = settings.materialRule().value();
|
||||
for (int dz = 0; dz < side; dz++) {
|
||||
for (int dx = 0; dx < side; dx++) {
|
||||
int chunkX = originChunkX + dx;
|
||||
int chunkZ = originChunkZ + dz;
|
||||
ProtoChunk chunk = chunks.get(ChunkPos.pack(chunkX, chunkZ));
|
||||
Set<Holder<Biome>> possible = possibleBiomes(chunks, chunkX, chunkZ);
|
||||
long started = System.nanoTime();
|
||||
long startedCpu = THREADS.getCurrentThreadCpuTime();
|
||||
try (NoiseChunk noiseChunk = new NoiseChunk(randomState, null, settings, fluidPicker, Blender.empty(), volume(chunk))) {
|
||||
fill(noiseChunk, chunk);
|
||||
long filled = System.nanoTime();
|
||||
long filledCpu = THREADS.getCurrentThreadCpuTime();
|
||||
randomState.surfaceSystem().buildSurface(randomState, biomeManager,
|
||||
new WorldGenerationContext(generator, chunk.getHeightAccessorForGeneration(), surfaceLevel), chunk, noiseChunk, materialRule, possible);
|
||||
samples.surface.add(System.nanoTime() - filled, THREADS.getCurrentThreadCpuTime() - filledCpu);
|
||||
samples.noise.add(filled - started, filledCpu - startedCpu);
|
||||
}
|
||||
ChunkPos position = chunk.getPos();
|
||||
long carverStarted = System.nanoTime();
|
||||
long carverStartedCpu = THREADS.getCurrentThreadCpuTime();
|
||||
uncached.getNoiseBiome(QuartPos.fromBlock(position.getMinBlockX()), 0, QuartPos.fromBlock(position.getMinBlockZ()));
|
||||
samples.carverBiome.add(System.nanoTime() - carverStarted, THREADS.getCurrentThreadCpuTime() - carverStartedCpu);
|
||||
samples.chunks++;
|
||||
}
|
||||
}
|
||||
samples.biomeFallbacks += region.fallbacks();
|
||||
}
|
||||
|
||||
void uncachedBiome(CallBudget budget, CallSamples samples) {
|
||||
SplittableRandom random = new SplittableRandom(seed ^ 0x5DEECE66DL ^ samples.count);
|
||||
int minQuart = QuartPos.fromBlock(heights.getMinY());
|
||||
int quartHeight = QuartPos.fromBlock(heights.getHeight());
|
||||
long deadline = System.nanoTime() + budget.limitNanos();
|
||||
long startedCpu = THREADS.getCurrentThreadCpuTime();
|
||||
int taken = 0;
|
||||
while (taken < budget.calls() && (taken < budget.minimumCalls() || System.nanoTime() < deadline)) {
|
||||
int quartX = QuartPos.fromBlock(random.nextInt(-SAMPLE_RADIUS_BLOCKS, SAMPLE_RADIUS_BLOCKS));
|
||||
int quartY = minQuart + random.nextInt(quartHeight);
|
||||
int quartZ = QuartPos.fromBlock(random.nextInt(-SAMPLE_RADIUS_BLOCKS, SAMPLE_RADIUS_BLOCKS));
|
||||
long started = System.nanoTime();
|
||||
uncached.getNoiseBiome(quartX, quartY, quartZ);
|
||||
samples.wall.add(System.nanoTime() - started);
|
||||
taken++;
|
||||
}
|
||||
samples.finish(taken, THREADS.getCurrentThreadCpuTime() - startedCpu);
|
||||
}
|
||||
|
||||
void baseHeight(CallBudget budget, Heightmap.Types type, CallSamples samples) {
|
||||
SplittableRandom random = new SplittableRandom(seed ^ type.ordinal() ^ 0x2545F4914F6CDD1DL ^ samples.count);
|
||||
long deadline = System.nanoTime() + budget.limitNanos();
|
||||
long startedCpu = THREADS.getCurrentThreadCpuTime();
|
||||
int taken = 0;
|
||||
while (taken < budget.calls() && (taken < budget.minimumCalls() || System.nanoTime() < deadline)) {
|
||||
int x = random.nextInt(-SAMPLE_RADIUS_BLOCKS, SAMPLE_RADIUS_BLOCKS);
|
||||
int z = random.nextInt(-SAMPLE_RADIUS_BLOCKS, SAMPLE_RADIUS_BLOCKS);
|
||||
long started = System.nanoTime();
|
||||
generator.getBaseHeight(x, z, type, heights, randomState);
|
||||
samples.wall.add(System.nanoTime() - started);
|
||||
taken++;
|
||||
}
|
||||
samples.finish(taken, THREADS.getCurrentThreadCpuTime() - startedCpu);
|
||||
}
|
||||
|
||||
void baseColumn(CallBudget budget, CallSamples samples) {
|
||||
SplittableRandom random = new SplittableRandom(seed ^ 0x9E3779B97F4A7C15L ^ samples.count);
|
||||
long deadline = System.nanoTime() + budget.limitNanos();
|
||||
long startedCpu = THREADS.getCurrentThreadCpuTime();
|
||||
int taken = 0;
|
||||
while (taken < budget.calls() && (taken < budget.minimumCalls() || System.nanoTime() < deadline)) {
|
||||
int x = random.nextInt(-SAMPLE_RADIUS_BLOCKS, SAMPLE_RADIUS_BLOCKS);
|
||||
int z = random.nextInt(-SAMPLE_RADIUS_BLOCKS, SAMPLE_RADIUS_BLOCKS);
|
||||
long started = System.nanoTime();
|
||||
generator.getBaseColumn(x, z, heights, randomState);
|
||||
samples.wall.add(System.nanoTime() - started);
|
||||
taken++;
|
||||
}
|
||||
samples.finish(taken, THREADS.getCurrentThreadCpuTime() - startedCpu);
|
||||
}
|
||||
|
||||
private void fill(NoiseChunk noiseChunk, ChunkAccess chunk) {
|
||||
try {
|
||||
fill.invoke(generator, noiseChunk, chunk);
|
||||
} catch (IllegalAccessException failure) {
|
||||
throw new IllegalStateException("Cannot invoke NoiseBasedChunkGenerator.doFill for " + label, failure);
|
||||
} catch (InvocationTargetException failure) {
|
||||
if (failure.getCause() instanceof RuntimeException runtime) {
|
||||
throw runtime;
|
||||
}
|
||||
throw new IllegalStateException("Noise fill failed for " + label, failure.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
private DensityVolume volume(ChunkAccess chunk) {
|
||||
NoiseSettings noise = settings.noiseSettings().clampToHeightAccessor(chunk.getHeightAccessorForGeneration());
|
||||
ChunkPos position = chunk.getPos();
|
||||
return new DensityVolume(16, noise.height(), 16, position.getMinBlockX(), noise.minY(), position.getMinBlockZ());
|
||||
}
|
||||
|
||||
private static Set<Holder<Biome>> possibleBiomes(Map<Long, ProtoChunk> chunks, int chunkX, int chunkZ) {
|
||||
Set<Holder<Biome>> possible = new HashSet<>();
|
||||
for (int dz = -1; dz <= 1; dz++) {
|
||||
for (int dx = -1; dx <= 1; dx++) {
|
||||
chunks.get(ChunkPos.pack(chunkX + dx, chunkZ + dz)).collectBiomesInPalette(possible);
|
||||
}
|
||||
}
|
||||
return possible;
|
||||
}
|
||||
|
||||
private static Method fillMethod() {
|
||||
try {
|
||||
Method method = NoiseBasedChunkGenerator.class.getDeclaredMethod("doFill", NoiseChunk.class, ChunkAccess.class);
|
||||
method.setAccessible(true);
|
||||
return method;
|
||||
} catch (NoSuchMethodException failure) {
|
||||
throw new IllegalStateException("NoiseBasedChunkGenerator has no doFill(NoiseChunk, ChunkAccess)", failure);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Aquifer.FluidPicker fluidPicker(NoiseBasedChunkGenerator generator) {
|
||||
try {
|
||||
Field field = NoiseBasedChunkGenerator.class.getDeclaredField("globalFluidPicker");
|
||||
field.setAccessible(true);
|
||||
return ((Supplier<Aquifer.FluidPicker>) field.get(generator)).get();
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw new IllegalStateException("NoiseBasedChunkGenerator has no globalFluidPicker supplier", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static Level paperConfiguredLevel() {
|
||||
try {
|
||||
Constructor<WorldConfiguration> constructor = WorldConfiguration.class.getDeclaredConstructor(SpigotWorldConfig.class, Identifier.class);
|
||||
constructor.setAccessible(true);
|
||||
WorldConfiguration configuration = constructor.newInstance(null, Identifier.withDefaultNamespace("overworld"));
|
||||
configuration.environment = configuration.new Environment();
|
||||
Class<?> unsafeType = Class.forName("sun.misc.Unsafe");
|
||||
Field unsafeField = unsafeType.getDeclaredField("theUnsafe");
|
||||
unsafeField.setAccessible(true);
|
||||
Method allocate = unsafeType.getMethod("allocateInstance", Class.class);
|
||||
Level level = (Level) allocate.invoke(unsafeField.get(null), ServerLevel.class);
|
||||
Field paperConfig = Level.class.getDeclaredField("paperConfig");
|
||||
paperConfig.setAccessible(true);
|
||||
paperConfig.set(level, configuration);
|
||||
return level;
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw new IllegalStateException("Cannot build the Paper world configuration that surface rules read", failure);
|
||||
}
|
||||
}
|
||||
|
||||
record Options(String label, LevelStem stem, RegistryAccess registries, long seed) {
|
||||
Options {
|
||||
Objects.requireNonNull(label, "label");
|
||||
Objects.requireNonNull(stem, "stem");
|
||||
Objects.requireNonNull(registries, "registries");
|
||||
}
|
||||
}
|
||||
|
||||
record CallBudget(int calls, int minimumCalls, long limitNanos) {
|
||||
}
|
||||
|
||||
record ChunkTimings(int chunks, StageTiming biomes, StageTiming noise, StageTiming surface, StageTiming carverBiome,
|
||||
long biomeFallbacks) {
|
||||
double totalMean() {
|
||||
return biomes.wall().mean() + noise.wall().mean() + surface.wall().mean() + carverBiome.wall().mean();
|
||||
}
|
||||
|
||||
double totalCpuMean() {
|
||||
return biomes.cpu().mean() + noise.cpu().mean() + surface.cpu().mean() + carverBiome.cpu().mean();
|
||||
}
|
||||
}
|
||||
|
||||
record StageTiming(Summary wall, Summary cpu) {
|
||||
}
|
||||
|
||||
record CallTiming(Summary wall, int calls, double cpuMean) {
|
||||
}
|
||||
|
||||
record Summary(int count, double mean, double median, double p90, double min, double max) {
|
||||
private static Summary of(long[] nanos, double unit) {
|
||||
if (nanos.length == 0) {
|
||||
return new Summary(0, Double.NaN, Double.NaN, Double.NaN, Double.NaN, Double.NaN);
|
||||
}
|
||||
long[] sorted = nanos.clone();
|
||||
Arrays.sort(sorted);
|
||||
double total = 0.0;
|
||||
for (long value : sorted) {
|
||||
total += value;
|
||||
}
|
||||
return new Summary(sorted.length, total / sorted.length / unit, percentile(sorted, 0.5) / unit,
|
||||
percentile(sorted, 0.9) / unit, sorted[0] / unit, sorted[sorted.length - 1] / unit);
|
||||
}
|
||||
|
||||
private static double percentile(long[] sorted, double fraction) {
|
||||
int index = (int) Math.min(sorted.length - 1, Math.round(fraction * (sorted.length - 1)));
|
||||
return sorted[index];
|
||||
}
|
||||
}
|
||||
|
||||
static final class ChunkSamples {
|
||||
private final StageSamples biomes = new StageSamples();
|
||||
private final StageSamples noise = new StageSamples();
|
||||
private final StageSamples surface = new StageSamples();
|
||||
private final StageSamples carverBiome = new StageSamples();
|
||||
private int chunks;
|
||||
private long biomeFallbacks;
|
||||
|
||||
ChunkTimings timings() {
|
||||
return new ChunkTimings(chunks, biomes.timing(), noise.timing(), surface.timing(), carverBiome.timing(), biomeFallbacks);
|
||||
}
|
||||
}
|
||||
|
||||
static final class CallSamples {
|
||||
private final Samples wall = new Samples();
|
||||
private int count;
|
||||
private long cpuNanos;
|
||||
|
||||
CallTiming timing() {
|
||||
return new CallTiming(Summary.of(wall.values(), NANOS_PER_MICRO), count,
|
||||
count == 0 ? Double.NaN : cpuNanos / (double) count / NANOS_PER_MICRO);
|
||||
}
|
||||
|
||||
private void finish(int calls, long cpu) {
|
||||
count += calls;
|
||||
cpuNanos += cpu;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class StageSamples {
|
||||
private final Samples wall = new Samples();
|
||||
private final Samples cpu = new Samples();
|
||||
|
||||
private void add(long wallNanos, long cpuNanos) {
|
||||
wall.add(wallNanos);
|
||||
cpu.add(cpuNanos);
|
||||
}
|
||||
|
||||
private StageTiming timing() {
|
||||
return new StageTiming(Summary.of(wall.values(), NANOS_PER_MILLI), Summary.of(cpu.values(), NANOS_PER_MILLI));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Samples {
|
||||
private long[] values = new long[64];
|
||||
private int size;
|
||||
|
||||
private void add(long value) {
|
||||
if (size == values.length) {
|
||||
values = Arrays.copyOf(values, size * 2);
|
||||
}
|
||||
values[size++] = value;
|
||||
}
|
||||
|
||||
private long[] values() {
|
||||
return Arrays.copyOf(values, size);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RegionBiomes implements BiomeResolver {
|
||||
private final Map<Long, ProtoChunk> chunks;
|
||||
private final BiomeResolver fallback;
|
||||
private long fallbacks;
|
||||
|
||||
private RegionBiomes(Map<Long, ProtoChunk> chunks, BiomeResolver fallback) {
|
||||
this.chunks = chunks;
|
||||
this.fallback = fallback;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Holder<Biome> getNoiseBiome(int quartX, int quartY, int quartZ) {
|
||||
ProtoChunk chunk = chunks.get(ChunkPos.pack(QuartPos.toSection(quartX), QuartPos.toSection(quartZ)));
|
||||
if (chunk == null) {
|
||||
fallbacks++;
|
||||
return fallback.getNoiseBiome(quartX, quartY, quartZ);
|
||||
}
|
||||
return chunk.getNoiseBiome(quartX, quartY, quartZ);
|
||||
}
|
||||
|
||||
private long fallbacks() {
|
||||
return fallbacks;
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package art.arcane.iris.probe.fidelity;
|
||||
|
||||
import art.arcane.iris.probe.WorldgenExportSampler;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class VanillaNoiseColumnSource implements ColumnSource {
|
||||
private final WorldgenExportSampler sampler;
|
||||
private final Map<String, String> loadKeyByBiomeId;
|
||||
private final int minY;
|
||||
private final int maxY;
|
||||
private final int seaLevel;
|
||||
|
||||
public VanillaNoiseColumnSource(WorldgenExportSampler sampler, Map<String, String> loadKeyByBiomeId) {
|
||||
this.sampler = Objects.requireNonNull(sampler, "sampler");
|
||||
this.loadKeyByBiomeId = Map.copyOf(loadKeyByBiomeId);
|
||||
this.minY = sampler.minY();
|
||||
this.maxY = sampler.minY() + sampler.height();
|
||||
this.seaLevel = sampler.seaLevel();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return "vanilla:26.3";
|
||||
}
|
||||
|
||||
@Override
|
||||
public int minY() {
|
||||
return minY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int maxY() {
|
||||
return maxY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FidelityColumn column(int x, int z) {
|
||||
int floor = sampler.floorHeight(x, z);
|
||||
int terrainY = floor - 1;
|
||||
boolean wet = floor < seaLevel && sampler.baseHeight(x, z) > floor;
|
||||
return new FidelityColumn(loadKey(x, Math.max(minY, terrainY), z), terrainY, wet);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
sampler.close();
|
||||
}
|
||||
|
||||
private String loadKey(int x, int y, int z) {
|
||||
String biomeId = sampler.biome(x, y, z);
|
||||
String loadKey = loadKeyByBiomeId.get(biomeId);
|
||||
if (loadKey == null) {
|
||||
throw new IllegalStateException("Exported biome " + biomeId + " at " + x + "," + y + "," + z
|
||||
+ " has no Iris load key in the export result");
|
||||
}
|
||||
return loadKey;
|
||||
}
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
package art.arcane.iris.probe.fidelity;
|
||||
|
||||
import art.arcane.iris.probe.WorldgenExportSampler;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.chunk.LevelChunkSection;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityFunctions;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.op.BinaryFunction;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class VanillaNoiseFidelitySource implements FidelitySource {
|
||||
private static final int CHUNK_WIDTH = 16;
|
||||
private static final byte SOLID = 0;
|
||||
private static final byte OPEN = 1;
|
||||
private static final byte FLUID = 2;
|
||||
private static final byte LAVA = 3;
|
||||
|
||||
private final WorldgenExportSampler sampler;
|
||||
private final Map<String, String> loadKeyByBiomeId;
|
||||
private final String wallShell;
|
||||
private final DensityFunction massDensity;
|
||||
private final boolean walls;
|
||||
private final int minY;
|
||||
private final int maxY;
|
||||
|
||||
public VanillaNoiseFidelitySource(WorldgenExportSampler sampler, Map<String, String> loadKeyByBiomeId, String wallShell) {
|
||||
this.sampler = Objects.requireNonNull(sampler, "sampler");
|
||||
this.loadKeyByBiomeId = Map.copyOf(loadKeyByBiomeId);
|
||||
this.wallShell = Objects.requireNonNull(wallShell, "wallShell");
|
||||
this.massDensity = terrainMass(sampler.finalDensity());
|
||||
this.walls = sampler.densityFunctionIds().contains(wallShell);
|
||||
this.minY = sampler.minY();
|
||||
this.maxY = sampler.minY() + sampler.height();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String label() {
|
||||
return "vanilla:26.3";
|
||||
}
|
||||
|
||||
@Override
|
||||
public int minY() {
|
||||
return minY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int maxY() {
|
||||
return maxY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FidelityChunk sample(int chunkX, int chunkZ) {
|
||||
return new TerrainChunk(sampler.terrainChunk(chunkX, chunkZ), chunkX * CHUNK_WIDTH, chunkZ * CHUNK_WIDTH);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
sampler.close();
|
||||
}
|
||||
|
||||
private String loadKey(int x, int y, int z) {
|
||||
String biomeId = sampler.biome(x, y, z);
|
||||
String loadKey = loadKeyByBiomeId.get(biomeId);
|
||||
if (loadKey == null) {
|
||||
throw new IllegalStateException("Exported biome " + biomeId + " at " + x + "," + y + "," + z
|
||||
+ " has no Iris load key in the export result");
|
||||
}
|
||||
return loadKey;
|
||||
}
|
||||
|
||||
static DensityFunction terrainMass(DensityFunction finalDensity) {
|
||||
DensityFunction root = finalDensity;
|
||||
while (root instanceof DensityFunctions.HolderHolder(Holder<DensityFunction> holder)) {
|
||||
root = holder.value();
|
||||
}
|
||||
return root instanceof BinaryFunction(BinaryFunction.Type type, DensityFunction terrain, DensityFunction ignored) && type == BinaryFunction.Type.MIN
|
||||
? terrain : root;
|
||||
}
|
||||
|
||||
private static byte classify(Block block) {
|
||||
String id = BuiltInRegistries.BLOCK.getKey(block).toString();
|
||||
if (FidelityBlocks.open(id)) {
|
||||
return OPEN;
|
||||
}
|
||||
if (FidelityBlocks.lava(id)) {
|
||||
return LAVA;
|
||||
}
|
||||
return FidelityBlocks.fluid(id) ? FLUID : SOLID;
|
||||
}
|
||||
|
||||
private record Column(int surfaceY, String biome, byte[] kinds) {
|
||||
}
|
||||
|
||||
private final class TerrainChunk implements FidelityChunk {
|
||||
private final ProtoChunk terrain;
|
||||
private final int blockX;
|
||||
private final int blockZ;
|
||||
private final Column[] columns = new Column[CHUNK_WIDTH * CHUNK_WIDTH];
|
||||
private final Map<Block, Byte> kinds = new IdentityHashMap<>();
|
||||
private float[] masses;
|
||||
|
||||
private TerrainChunk(ProtoChunk terrain, int blockX, int blockZ) {
|
||||
this.terrain = terrain;
|
||||
this.blockX = blockX;
|
||||
this.blockZ = blockZ;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int surfaceY(int localX, int localZ) {
|
||||
return column(localX, localZ).surfaceY();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String surfaceBiome(int localX, int localZ) {
|
||||
return column(localX, localZ).biome();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean open(int localX, int y, int localZ) {
|
||||
return kind(localX, y, localZ) == OPEN;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean fluid(int localX, int y, int localZ) {
|
||||
byte kind = kind(localX, y, localZ);
|
||||
return kind == FLUID || kind == LAVA;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean lava(int localX, int y, int localZ) {
|
||||
return kind(localX, y, localZ) == LAVA;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String block(int localX, int y, int localZ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean terrainMass(int localX, int y, int localZ) {
|
||||
if (y < minY || y >= maxY) {
|
||||
throw new IllegalArgumentException("Y " + y + " is outside [" + minY + ", " + maxY + ")");
|
||||
}
|
||||
if (localX < 0 || localX >= CHUNK_WIDTH || localZ < 0 || localZ >= CHUNK_WIDTH) {
|
||||
throw new IllegalArgumentException("Column " + localX + "," + localZ + " is outside the chunk");
|
||||
}
|
||||
if (masses == null) {
|
||||
masses = sampler.densityChunk(massDensity, Math.floorDiv(blockX, CHUNK_WIDTH), Math.floorDiv(blockZ, CHUNK_WIDTH));
|
||||
}
|
||||
return masses[(localZ * CHUNK_WIDTH + localX) * (maxY - minY) + y - minY] > 0.0F;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean wallsKnown() {
|
||||
return walls;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean wall(int localX, int y, int localZ) {
|
||||
return walls && sampler.density(wallShell, blockX + localX, y, blockZ + localZ) > 0.0F;
|
||||
}
|
||||
|
||||
private byte kind(int localX, int y, int localZ) {
|
||||
if (y < minY || y >= maxY) {
|
||||
throw new IllegalArgumentException("Y " + y + " is outside [" + minY + ", " + maxY + ")");
|
||||
}
|
||||
return column(localX, localZ).kinds()[y - minY];
|
||||
}
|
||||
|
||||
private Column column(int localX, int localZ) {
|
||||
if (localX < 0 || localX >= CHUNK_WIDTH || localZ < 0 || localZ >= CHUNK_WIDTH) {
|
||||
throw new IllegalArgumentException("Column " + localX + "," + localZ + " is outside the chunk");
|
||||
}
|
||||
int index = localZ * CHUNK_WIDTH + localX;
|
||||
Column column = columns[index];
|
||||
if (column == null) {
|
||||
column = sampleColumn(localX, localZ);
|
||||
columns[index] = column;
|
||||
}
|
||||
return column;
|
||||
}
|
||||
|
||||
private Column sampleColumn(int localX, int localZ) {
|
||||
byte[] column = new byte[maxY - minY];
|
||||
int surfaceY = minY - 1;
|
||||
for (int y = maxY - 1; y >= minY; y--) {
|
||||
LevelChunkSection section = terrain.getSection(terrain.getSectionIndex(y));
|
||||
byte kind = kinds.computeIfAbsent(section.getBlockState(localX, y & (CHUNK_WIDTH - 1), localZ).getBlock(), VanillaNoiseFidelitySource::classify);
|
||||
column[y - minY] = kind;
|
||||
if (kind == SOLID && surfaceY < minY) {
|
||||
surfaceY = y;
|
||||
}
|
||||
}
|
||||
return new Column(surfaceY, loadKey(blockX + localX, Math.max(minY, surfaceY), blockZ + localZ), column);
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
-2
@@ -245,9 +245,15 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
for (int y = groundY + 1; y <= groundY + 32; y++) {
|
||||
assertTrue("Roof at " + x + "," + y, state(blocks, x, y, 0).isAir());
|
||||
}
|
||||
for (int y = 12; y < groundY; y++) {
|
||||
int supportY = x < 2 ? groundY - 33 : 12;
|
||||
for (int y = supportY; y < groundY; y++) {
|
||||
assertTrue("Missing foundation at " + x + "," + y, state(blocks, x, y, 0).isSolid());
|
||||
}
|
||||
if (x < 2) {
|
||||
for (int y = 13; y < supportY; y++) {
|
||||
assertTrue("Cave beneath support at " + x + "," + y, state(blocks, x, y, 0).isAir());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +274,57 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
assertTrue(state(blocks, 0, groundY + 1, 0).isAir());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenPreservesCavesBelowIntactGroundAtTheSkirtEdge() {
|
||||
StructureStart start = desertStart();
|
||||
int groundY = start.getBoundingBox().minY() - 1;
|
||||
int x = start.getBoundingBox().maxX() + 23;
|
||||
int z = start.getBoundingBox().minZ();
|
||||
BoundingBox area = new BoundingBox(x, 0, z, x, 127, z);
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
for (int y = groundY - 35; y <= groundY; y++) {
|
||||
BlockState block = y >= groundY - 34 && y <= groundY - 13
|
||||
? Blocks.CAVE_AIR.defaultBlockState() : Blocks.STONE.defaultBlockState();
|
||||
put(blocks, x, y, z, block);
|
||||
}
|
||||
put(blocks, x, groundY - 1, z, Blocks.DIRT.defaultBlockState());
|
||||
put(blocks, x, groundY, z, Blocks.GRASS_BLOCK.defaultBlockState());
|
||||
NativeStructureTerrainIntegrator.TerrainTarget target = surfaceTarget(start, StructureTerrainMode.FLATTEN);
|
||||
((IrisStructureTerrain) target.terrain()).setHorizontalPadding(24).setFlattenRange(64);
|
||||
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area, List.of(target), (px, pz) -> groundY);
|
||||
|
||||
for (int y = groundY - 34; y <= groundY - 13; y++) {
|
||||
assertEquals("Cave at " + y, Blocks.CAVE_AIR.defaultBlockState(), state(blocks, x, y, z));
|
||||
}
|
||||
assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, x, groundY - 1, z));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenBridgesTheFoundationGapWithoutFillingACaveBelowItsSupport() {
|
||||
StructureStart start = desertStart();
|
||||
int groundY = start.getBoundingBox().minY() - 1;
|
||||
BoundingBox area = new BoundingBox(0, 0, 0, 0, 127, 0);
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
put(blocks, 0, groundY, 0, Blocks.GRASS_BLOCK.defaultBlockState());
|
||||
for (int y = groundY - 11; y <= groundY - 9; y++) {
|
||||
put(blocks, 0, y, 0, Blocks.STONE.defaultBlockState());
|
||||
}
|
||||
for (int y = groundY - 20; y <= groundY - 12; y++) {
|
||||
put(blocks, 0, y, 0, Blocks.CAVE_AIR.defaultBlockState());
|
||||
}
|
||||
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area,
|
||||
List.of(surfaceTarget(start, StructureTerrainMode.FLATTEN)), (x, z) -> groundY);
|
||||
|
||||
for (int y = groundY - 8; y < groundY; y++) {
|
||||
assertEquals("Foundation at " + y, Blocks.STONE.defaultBlockState(), state(blocks, 0, y, 0));
|
||||
}
|
||||
for (int y = groundY - 20; y <= groundY - 12; y++) {
|
||||
assertEquals("Cave at " + y, Blocks.CAVE_AIR.defaultBlockState(), state(blocks, 0, y, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenLeavesBuriedAndSubmergedInstancesUntouched() {
|
||||
StructureStart start = desertStart();
|
||||
@@ -619,10 +676,18 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
for (int z = 0; z <= 8; z++) {
|
||||
int groundY = plan.flattenedHeights().get(NativeStructureTemplateOccupancy.columnKey(x, z)) - 1;
|
||||
assertTrue("Village grade at " + x + "," + z + ": " + groundY, groundY >= 64 && groundY <= 70);
|
||||
for (int y = 20; y <= groundY; y++) {
|
||||
boolean originalFoundation = (x == 0 || x == 8) && (z == 0 || z == 8);
|
||||
int supportY = originalFoundation ? 63 : 20;
|
||||
for (int y = supportY; y <= groundY; y++) {
|
||||
assertTrue("Disconnected village ground at " + x + "," + y + "," + z,
|
||||
state(blocks, x, y, z).isSolid());
|
||||
}
|
||||
if (originalFoundation) {
|
||||
for (int y = 21; y < supportY; y++) {
|
||||
assertTrue("Cave beneath foundation at " + x + "," + y + "," + z,
|
||||
state(blocks, x, y, z).isAir());
|
||||
}
|
||||
}
|
||||
for (int y = groundY + 1; y <= 96; y++) {
|
||||
assertTrue("Roof remains above village at " + x + "," + y + "," + z,
|
||||
state(blocks, x, y, z).isAir());
|
||||
|
||||
+10
-6
@@ -3,6 +3,7 @@ package art.arcane.iris.platform.bukkit.nms.v26_3_R1;
|
||||
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureWorldgenAccess;
|
||||
|
||||
import art.arcane.iris.structure.placement.IrisStructureTerrain;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
|
||||
import art.arcane.volmlib.util.structure.StructureTerrainMode;
|
||||
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructurePostProcessor;
|
||||
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureTerrainIntegrator;
|
||||
@@ -230,8 +231,9 @@ public class NativeStructureWorldgenAccessTest {
|
||||
recording.world(), generationCenter,
|
||||
(x, z) -> SURFACE_FIRST_FREE_Y,
|
||||
(x, z) -> FLOOR_FIRST_FREE_Y,
|
||||
true,
|
||||
position -> false);
|
||||
true, true,
|
||||
position -> false,
|
||||
NativeBlockChangeSet.disabled());
|
||||
int x = generationCenter.getMiddleBlockX();
|
||||
int z = generationCenter.getMiddleBlockZ();
|
||||
|
||||
@@ -248,8 +250,9 @@ public class NativeStructureWorldgenAccessTest {
|
||||
recording.world(), generationCenter(),
|
||||
(x, z) -> SURFACE_FIRST_FREE_Y,
|
||||
(x, z) -> FLOOR_FIRST_FREE_Y,
|
||||
false,
|
||||
position::equals);
|
||||
false, true,
|
||||
position::equals,
|
||||
NativeBlockChangeSet.disabled());
|
||||
|
||||
assertSame(Blocks.DIRT, access.getBlockState(position).getBlock());
|
||||
assertFalse(access.ensureCanWrite(position));
|
||||
@@ -319,8 +322,9 @@ public class NativeStructureWorldgenAccessTest {
|
||||
recording.world(), generationCenter(),
|
||||
(x, z) -> SURFACE_FIRST_FREE_Y,
|
||||
(x, z) -> FLOOR_FIRST_FREE_Y,
|
||||
false,
|
||||
position -> false);
|
||||
false, true,
|
||||
position -> false,
|
||||
NativeBlockChangeSet.disabled());
|
||||
}
|
||||
|
||||
private static ChunkPos generationCenter() {
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.spi.VanillaDataSource;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.packs.PackResources;
|
||||
import net.minecraft.server.packs.PackType;
|
||||
import net.minecraft.server.packs.repository.ServerPacksSource;
|
||||
import net.minecraft.server.packs.resources.IoSupplier;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class BukkitVanillaDataTest {
|
||||
private static final String PLAINS = "data/minecraft/worldgen/biome/plains.json";
|
||||
private static final String BIOME_TAGS = "data/minecraft/tags/worldgen/biome";
|
||||
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
HeadlessNativeBootstrap.initialize();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bukkitPlatformReadsTheServerPlainsBiome() throws IOException {
|
||||
byte[] plains = new BukkitPlatform().vanillaData().read(PLAINS).orElseThrow();
|
||||
assertTrue(new String(plains, StandardCharsets.UTF_8).contains("\"temperature\""));
|
||||
try (PackResources vanilla = ServerPacksSource.createVanillaPackSource().fullResources()) {
|
||||
IoSupplier<InputStream> resource = vanilla.getResource(PackType.SERVER_DATA,
|
||||
Identifier.withDefaultNamespace("worldgen/biome/plains.json"));
|
||||
try (InputStream input = resource.get()) {
|
||||
assertArrayEquals(input.readAllBytes(), plains);
|
||||
}
|
||||
}
|
||||
assertNull(Bukkit.getServer());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bukkitPlatformListsTheBiomeTagsTheGameLoads() {
|
||||
List<String> expected = new ArrayList<>();
|
||||
try (PackResources vanilla = ServerPacksSource.createVanillaPackSource().fullResources()) {
|
||||
vanilla.listResources(PackType.SERVER_DATA, "minecraft", "tags/worldgen/biome",
|
||||
(id, resource) -> expected.add("data/" + id.getNamespace() + "/" + id.getPath()));
|
||||
}
|
||||
Collections.sort(expected);
|
||||
List<String> listed = new BukkitPlatform().vanillaData().list(BIOME_TAGS);
|
||||
assertFalse(listed.isEmpty());
|
||||
assertTrue(listed.contains(BIOME_TAGS + "/is_overworld.json"));
|
||||
assertEquals(expected, listed);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void headlessPlatformReadsTheSameVanillaData() {
|
||||
HeadlessNativePlatform platform = new HeadlessNativePlatform(new File("unused-native-platform"),
|
||||
HeadlessNativeTestRegistries::get);
|
||||
VanillaDataSource bukkit = new BukkitPlatform().vanillaData();
|
||||
assertArrayEquals(bukkit.read(PLAINS).orElseThrow(), platform.vanillaData().read(PLAINS).orElseThrow());
|
||||
assertEquals(bukkit.list(BIOME_TAGS), platform.vanillaData().list(BIOME_TAGS));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.CachingDensitySampler;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensitySampler;
|
||||
import net.minecraft.world.level.levelgen.material.rule.ConditionRule;
|
||||
import net.minecraft.world.level.levelgen.material.rule.MaterialRule;
|
||||
import net.minecraft.world.level.levelgen.material.rule.OreVeinRule;
|
||||
import net.minecraft.world.level.levelgen.material.rule.SequenceRule;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Deque;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
final class CompiledCaches {
|
||||
private static final String MINECRAFT_PACKAGE = "net.minecraft.";
|
||||
private static final String DENSITY_PACKAGE = "net.minecraft.world.level.levelgen.densityfunction.";
|
||||
private static final String SPLINE_PACKAGE = "net.minecraft.util.";
|
||||
private static final String SPLINE_NAME = "Spline";
|
||||
|
||||
private final RegistryAccess registries;
|
||||
private final NoiseGeneratorSettings settings;
|
||||
private final RandomState state;
|
||||
|
||||
private CompiledCaches(RegistryAccess registries, NoiseGeneratorSettings settings, long seed) {
|
||||
this.registries = registries;
|
||||
this.settings = settings;
|
||||
this.state = RandomState.create(registries.lookupOrThrow(Registries.NOISE), seed, settings);
|
||||
}
|
||||
|
||||
static CompiledCaches open(RegistryAccess registries, long seed) {
|
||||
if (!(DatapackCostProbe.exportedOverworld(registries).generator() instanceof NoiseBasedChunkGenerator generator)) {
|
||||
throw new IllegalStateException("Exported overworld has no noise generator");
|
||||
}
|
||||
return new CompiledCaches(registries, generator.generatorSettings().value(), seed);
|
||||
}
|
||||
|
||||
DensityFunction finalDensity() {
|
||||
return settings.noiseRouter().finalDensity();
|
||||
}
|
||||
|
||||
DensityFunction function(String id) {
|
||||
return registries.lookupOrThrow(Registries.DENSITY_FUNCTION)
|
||||
.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, Identifier.parse(id))).value();
|
||||
}
|
||||
|
||||
List<OreVeinRule> oreVeins() {
|
||||
List<OreVeinRule> veins = new ArrayList<>();
|
||||
Deque<MaterialRule> pending = new ArrayDeque<>();
|
||||
pending.push(settings.materialRule().value());
|
||||
while (!pending.isEmpty()) {
|
||||
MaterialRule rule = pending.pop();
|
||||
switch (rule) {
|
||||
case OreVeinRule vein -> veins.add(vein);
|
||||
case SequenceRule sequence -> sequence.sequence().reversed().forEach(pending::push);
|
||||
case ConditionRule condition -> pending.push(condition.thenRun());
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
return veins;
|
||||
}
|
||||
|
||||
int rootId(DensityFunction function) {
|
||||
if (!(state.getSampler(function) instanceof CachingDensitySampler cache)) {
|
||||
throw new IllegalStateException("Density function " + function + " does not compile to a cache");
|
||||
}
|
||||
return cache.id();
|
||||
}
|
||||
|
||||
Set<Integer> ids(DensityFunction function) {
|
||||
Set<Integer> ids = new TreeSet<>();
|
||||
Set<Object> visited = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
Deque<Object> pending = new ArrayDeque<>();
|
||||
pending.push(state.getSampler(function));
|
||||
while (!pending.isEmpty()) {
|
||||
Object node = pending.pop();
|
||||
if (node == null || !visited.add(node)) {
|
||||
continue;
|
||||
}
|
||||
if (node instanceof CachingDensitySampler cache) {
|
||||
ids.add(cache.id());
|
||||
}
|
||||
for (Object child : children(node)) {
|
||||
pending.push(child);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static List<Object> children(Object node) {
|
||||
List<Object> children = new ArrayList<>();
|
||||
if (node instanceof Object[] array) {
|
||||
Collections.addAll(children, array);
|
||||
return children;
|
||||
}
|
||||
if (node instanceof Collection<?> collection) {
|
||||
children.addAll(collection);
|
||||
return children;
|
||||
}
|
||||
if (!traversed(node)) {
|
||||
return children;
|
||||
}
|
||||
for (Class<?> type = node.getClass(); type != null && type.getName().startsWith(MINECRAFT_PACKAGE); type = type.getSuperclass()) {
|
||||
for (Field field : type.getDeclaredFields()) {
|
||||
if (Modifier.isStatic(field.getModifiers()) || field.getType().isPrimitive()) {
|
||||
continue;
|
||||
}
|
||||
children.add(read(field, node));
|
||||
}
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
private static boolean traversed(Object node) {
|
||||
if (node instanceof Enum<?>) {
|
||||
return false;
|
||||
}
|
||||
String name = node.getClass().getName();
|
||||
return node instanceof DensitySampler || name.startsWith(DENSITY_PACKAGE) || name.startsWith(SPLINE_PACKAGE) && name.contains(SPLINE_NAME);
|
||||
}
|
||||
|
||||
private static Object read(Field field, Object owner) {
|
||||
try {
|
||||
field.setAccessible(true);
|
||||
return field.get(owner);
|
||||
} catch (IllegalAccessException | RuntimeException failure) {
|
||||
throw new IllegalStateException("Cannot read " + field + " of " + owner.getClass().getName(), failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.probe.fidelity.FidelityReport;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class DatapackFidelityProbeBiomeModeTest {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporary = new TemporaryFolder();
|
||||
|
||||
@Test(timeout = 300_000L)
|
||||
public void biomeModeJudgesTheSeedMeanWithoutTheChunkScan() throws Exception {
|
||||
Path pack = DatapackFidelityProbeTest.pack(temporary);
|
||||
Path output = temporary.getRoot().toPath().resolve("reports").resolve("biomes.json");
|
||||
|
||||
FidelityReport report = DatapackFidelityProbe.execute(
|
||||
new String[]{pack.toString(), "main", "1337", "0", "1", output.toString(), "256", "32", "3", "biomes"}, DatapackFidelityProbeTest.exporter());
|
||||
|
||||
JsonObject json = JsonParser.parseString(Files.readString(output, StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
assertTrue(report.toTable(), report.passed());
|
||||
assertTrue(json.get("passed").getAsBoolean());
|
||||
assertEquals(1.0D, DatapackFidelityProbeTest.metric(report, "biome.share.plains").export(), 0.0D);
|
||||
assertEquals(1, report.sampling().size());
|
||||
assertEquals(List.of(1337L, 1338L, 1339L), report.sampling().getFirst().seeds());
|
||||
assertEquals(3, json.getAsJsonArray("seeds").size());
|
||||
assertEquals(3, json.getAsJsonObject("seedsPassing").getAsJsonObject("biome.share.plains").get("passing").getAsInt());
|
||||
assertTrue(report.observations().stream().anyMatch(observation -> observation.name().equals("biome.type.land")
|
||||
&& observation.iris() == 1.0D && observation.export() == 1.0D));
|
||||
assertTrue(RealPackProbeSupport.drainReported().isEmpty());
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter;
|
||||
import art.arcane.iris.pack.datapack.export.compile.DimensionStage;
|
||||
import art.arcane.iris.pack.datapack.export.compile.ImageMapStage;
|
||||
import art.arcane.iris.pack.datapack.export.compile.PackCompiler;
|
||||
import art.arcane.iris.pack.datapack.export.emit.WorldgenJsonWriter;
|
||||
import art.arcane.iris.probe.fidelity.FidelityMetric;
|
||||
import art.arcane.iris.probe.fidelity.FidelityReport;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class DatapackFidelityProbeTest {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporary = new TemporaryFolder();
|
||||
|
||||
@Test(timeout = 300_000L)
|
||||
public void probeExportsTheSyntheticPackAndComparesBothGenerators() throws Exception {
|
||||
Path pack = pack(temporary);
|
||||
Path output = temporary.getRoot().toPath().resolve("reports").resolve("fidelity.json");
|
||||
|
||||
FidelityReport report = DatapackFidelityProbe.execute(
|
||||
new String[]{pack.toString(), "main", "1337", "1", "8", output.toString(), "256", "32", "2", "full"}, exporter());
|
||||
|
||||
JsonObject json = JsonParser.parseString(Files.readString(output, StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
assertTrue(report.toTable(), report.passed());
|
||||
assertTrue(json.get("passed").getAsBoolean());
|
||||
assertEquals(report.metrics().size(), json.getAsJsonArray("metrics").size());
|
||||
FidelityMetric biome = metric(report, "biome.share.plains");
|
||||
assertEquals(1.0D, biome.iris(), 0.0D);
|
||||
assertEquals(1.0D, biome.export(), 0.0D);
|
||||
assertTrue(biome.pass());
|
||||
assertEquals(0.0D, metric(report, "biome.totalVariation").delta(), 0.0D);
|
||||
assertEquals(1.0D, metric(report, "height.landFraction").export(), 0.0D);
|
||||
JsonObject columns = json.getAsJsonArray("sampling").get(0).getAsJsonObject();
|
||||
assertEquals("columns", columns.get("scope").getAsString());
|
||||
assertEquals(256, columns.get("windowBlocks").getAsInt());
|
||||
assertEquals(32, columns.get("strideBlocks").getAsInt());
|
||||
assertEquals(2, columns.getAsJsonArray("seeds").size());
|
||||
assertEquals(128L, columns.get("columns").getAsLong());
|
||||
JsonObject chunks = json.getAsJsonArray("sampling").get(1).getAsJsonObject();
|
||||
assertEquals("chunks", chunks.get("scope").getAsString());
|
||||
assertEquals(48, chunks.get("windowBlocks").getAsInt());
|
||||
assertTrue(RealPackProbeSupport.drainReported().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void malformedArgumentsAreRejectedBeforeThePackOpens() throws Exception {
|
||||
Path pack = pack(temporary);
|
||||
String output = temporary.getRoot().toPath().resolve("unused.json").toString();
|
||||
|
||||
IllegalArgumentException missing = assertThrows(IllegalArgumentException.class,
|
||||
() -> DatapackFidelityProbe.execute(new String[]{pack.toString(), "main"}, exporter()));
|
||||
IllegalArgumentException stride = assertThrows(IllegalArgumentException.class,
|
||||
() -> DatapackFidelityProbe.execute(new String[]{pack.toString(), "main", "1337", "1", "17", output, "256", "32", "1", "full"}, exporter()));
|
||||
IllegalArgumentException survey = assertThrows(IllegalArgumentException.class,
|
||||
() -> DatapackFidelityProbe.execute(new String[]{pack.toString(), "main", "1337", "1", "8", output, "250", "32", "1", "full"}, exporter()));
|
||||
IllegalArgumentException seeds = assertThrows(IllegalArgumentException.class,
|
||||
() -> DatapackFidelityProbe.execute(new String[]{pack.toString(), "main", "1337", "1", "8", output, "256", "32", "0", "full"}, exporter()));
|
||||
IllegalArgumentException mode = assertThrows(IllegalArgumentException.class,
|
||||
() -> DatapackFidelityProbe.execute(new String[]{pack.toString(), "main", "1337", "1", "8", output, "256", "32", "1", "caves"}, exporter()));
|
||||
|
||||
assertTrue(missing.getMessage(), missing.getMessage().startsWith("Usage:"));
|
||||
assertTrue(stride.getMessage(), stride.getMessage().contains("stride"));
|
||||
assertTrue(survey.getMessage(), survey.getMessage().contains("multiple"));
|
||||
assertTrue(seeds.getMessage(), seeds.getMessage().contains("surveySeeds"));
|
||||
assertTrue(mode.getMessage(), mode.getMessage().contains("caves"));
|
||||
assertFalse(Files.exists(Path.of(output)));
|
||||
}
|
||||
|
||||
static WorldgenDatapackExporter exporter() {
|
||||
return new WorldgenDatapackExporter(WorldgenJsonWriter::forTarget, new PackCompiler(List.of(new DimensionStage(), new ImageMapStage())));
|
||||
}
|
||||
|
||||
static FidelityMetric metric(FidelityReport report, String name) {
|
||||
for (FidelityMetric metric : report.metrics()) {
|
||||
if (metric.name().equals(name)) {
|
||||
return metric;
|
||||
}
|
||||
}
|
||||
throw new AssertionError("Report has no metric " + name + ": " + report.toJson());
|
||||
}
|
||||
|
||||
static Path pack(TemporaryFolder temporary) throws Exception {
|
||||
Path pack = temporary.newFolder().toPath();
|
||||
for (String directory : List.of("dimensions", "regions", "biomes", "generators")) {
|
||||
Files.createDirectories(pack.resolve(directory));
|
||||
}
|
||||
Files.writeString(pack.resolve("dimensions/main.json"), """
|
||||
{"name":"Fidelity","focus":"plains","regions":["owned"],"landChance":1,"carvingEnabled":false}
|
||||
""");
|
||||
Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"plains\"]}");
|
||||
Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}");
|
||||
Files.writeString(pack.resolve("biomes/plains.json"), """
|
||||
{"derivative":"minecraft:plains","generators":[{"generator":"flat","min":0,"max":0}]}
|
||||
""");
|
||||
return pack;
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public final class DensityCostAnalysisTest {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
private static final double EPSILON = 1.0E-9;
|
||||
private static final String LEAF = "{\"type\":\"noise\",\"noise\":\"minecraft:temperature\",\"xz_scale\":1.0,\"y_scale\":0.0}";
|
||||
|
||||
private static HeadlessNativeRegistries registries;
|
||||
|
||||
@BeforeClass
|
||||
public static void loadGraphs() throws IOException {
|
||||
Map<String, String> definitions = new LinkedHashMap<>();
|
||||
definitions.put(path("leaf"), LEAF);
|
||||
definitions.put(path("pair"), "{\"type\":\"add\",\"left\":\"t:leaf\",\"right\":\"t:leaf\"}");
|
||||
definitions.put(path("quad"), "{\"type\":\"mul\",\"left\":\"t:pair\",\"right\":\"t:pair\"}");
|
||||
definitions.put(path("cached"), "{\"type\":\"cache\",\"input\":\"t:pair\"}");
|
||||
definitions.put(path("twice"), "{\"type\":\"add\",\"left\":\"t:cached\",\"right\":\"t:cached\"}");
|
||||
definitions.put(path("select"), "{\"type\":\"interval_select\",\"input\":\"t:leaf\",\"thresholds\":[0.0],\"functions\":[\"t:quad\",\"t:pair\"]}");
|
||||
definitions.put(path("spline"), "{\"type\":\"spline\",\"spline\":{\"coordinate\":\"t:leaf\",\"points\":["
|
||||
+ "{\"location\":-1.0,\"value\":" + nested("t:quad") + ",\"derivative\":0.0},"
|
||||
+ "{\"location\":0.0,\"value\":0.0,\"derivative\":0.0},"
|
||||
+ "{\"location\":1.0,\"value\":" + nested("t:quad") + ",\"derivative\":0.0}]}}");
|
||||
definitions.put(path("grid"), "{\"type\":\"interpolated\",\"input\":\"t:pair\",\"cell_size_xz\":4,\"cell_size_y\":8}");
|
||||
registries = HeadlessNativeRegistries.load(definitions);
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void closeRegistries() throws IOException {
|
||||
if (registries != null) {
|
||||
registries.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void referencesExpandWhileTheDagSharesThem() {
|
||||
DensityCostAnalysis.RootCost quad = analysis().analyze("quad", function("quad"));
|
||||
assertEquals(3L, quad.dagNodes());
|
||||
assertEquals(7.0, quad.expandedNodes(), EPSILON);
|
||||
assertEquals(7.0, quad.cachedNodes(), EPSILON);
|
||||
assertEquals(2, quad.namedSubtrees());
|
||||
assertEquals("t:leaf", quad.exclusive().getFirst().name());
|
||||
assertEquals(4.0, quad.exclusive().getFirst().multiplicity(), EPSILON);
|
||||
assertEquals(4.0, quad.exclusive().getFirst().contribution(), EPSILON);
|
||||
assertEquals("t:pair", quad.inclusive().getFirst().name());
|
||||
assertEquals(6.0, quad.inclusive().getFirst().inclusive(), EPSILON);
|
||||
assertEquals(1.0 / 3.0, quad.dagShare().get("leaf"), EPSILON);
|
||||
assertEquals(1.0 / 3.0, quad.dagShare().get("quad"), EPSILON);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cachesEvaluateTheirBodyOnceWhenEnabled() {
|
||||
DensityCostAnalysis.RootCost twice = analysis().analyze("twice", function("twice"));
|
||||
assertEquals(4L, twice.dagNodes());
|
||||
assertEquals(9.0, twice.expandedNodes(), EPSILON);
|
||||
assertEquals(6.0, twice.cachedNodes(), EPSILON);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void uncachedPointEvaluationTakesOneSelectedBranch() {
|
||||
DensityCostAnalysis analysis = analysis();
|
||||
DensityCostAnalysis.RootCost select = analysis.analyze("select", function("select"));
|
||||
assertEquals(12.0, select.expandedNodes(), EPSILON);
|
||||
assertEquals(9.0, select.uncachedPointWorst(), EPSILON);
|
||||
DensityCostAnalysis.RootCost spline = analysis.analyze("spline", function("spline"));
|
||||
assertEquals(19.0, spline.expandedNodes(), EPSILON);
|
||||
assertEquals(11.0, spline.uncachedPointWorst(), EPSILON);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void offLatticeGridPointsSampleEightCorners() {
|
||||
DensityCostAnalysis.RootCost grid = analysis().analyze("grid", function("grid"));
|
||||
assertEquals(4.0, grid.expandedNodes(), EPSILON);
|
||||
assertEquals(25.0, grid.uncachedPointWorst(), EPSILON);
|
||||
}
|
||||
|
||||
private static DensityCostAnalysis analysis() {
|
||||
return new DensityCostAnalysis(id -> DensityJsonBreakdown.category("overworld", id), 10);
|
||||
}
|
||||
|
||||
private static DensityFunction function(String name) {
|
||||
return registries.registries().lookupOrThrow(Registries.DENSITY_FUNCTION)
|
||||
.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, Identifier.parse("t:" + name))).value();
|
||||
}
|
||||
|
||||
private static String nested(String coordinate) {
|
||||
return "{\"coordinate\":\"" + coordinate + "\",\"points\":[{\"location\":0.0,\"value\":1.0,\"derivative\":0.0},"
|
||||
+ "{\"location\":1.0,\"value\":2.0,\"derivative\":0.0}]}";
|
||||
}
|
||||
|
||||
private static String path(String name) {
|
||||
return "data/t/worldgen/density_function/" + name + ".json";
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.SortedMap;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public final class DensityJsonBreakdownTest {
|
||||
private static final String TEMPERATURE = "{\"type\":\"add\",\"left\":\"x:biome/selector/0\",\"right\":1.0}";
|
||||
private static final String SELECTOR = "{\"type\":\"mul\",\"left\":\"x:cell/cellular/a/value\",\"right\":2.0}";
|
||||
private static final String CELL_A = "{\"type\":\"noise\",\"noise\":\"x:hash_a\",\"xz_scale\":0.5,\"y_scale\":0.0}";
|
||||
private static final String CELL_B = "{\"type\":\"noise\",\"noise\":\"x:hash_b\",\"xz_scale\":0.25,\"y_scale\":0.0}";
|
||||
private static final String FINAL_DENSITY = "{\"type\":\"min\",\"left\":\"x:cell/cellular/b/value\",\"right\":0.0}";
|
||||
private static final String NOISE = "{\"firstOctave\":-1,\"amplitudes\":[1.0]}";
|
||||
|
||||
@Test
|
||||
public void categoriesFollowProducingCompilers() {
|
||||
assertEquals("router", DensityJsonBreakdown.category("overworld", "x:overworld/final_density"));
|
||||
assertEquals("cellular", DensityJsonBreakdown.category("overworld", "x:cell/cracks/abc/distance"));
|
||||
assertEquals("biome selector", DensityJsonBreakdown.category("overworld", "x:biome/selector/12"));
|
||||
assertEquals("biome decision", DensityJsonBreakdown.category("overworld", "x:biome/base_code"));
|
||||
assertEquals("terrain height", DensityJsonBreakdown.category("overworld", "x:height"));
|
||||
assertEquals("terrain bands", DensityJsonBreakdown.category("overworld", "x:terrain/plains/blend/3"));
|
||||
assertEquals("noise style", DensityJsonBreakdown.category("overworld", "x:style/shared/ab12"));
|
||||
assertEquals("cellular offset copies", DensityJsonBreakdown.category("overworld", "x:cell/cellular/abc/vx_0_0/offset_x4_z0"));
|
||||
assertEquals("biome selector offset copies", DensityJsonBreakdown.category("overworld", "x:biome/selector/4/offset_x0_z4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reportsBytesShapesAndCellularOwners() {
|
||||
SortedMap<String, byte[]> files = new TreeMap<>();
|
||||
put(files, "density_function/overworld/temperature", TEMPERATURE);
|
||||
put(files, "density_function/biome/selector/0", SELECTOR);
|
||||
put(files, "density_function/cell/cellular/a/value", CELL_A);
|
||||
put(files, "density_function/cell/cellular/b/value", CELL_B);
|
||||
put(files, "density_function/overworld/final_density", FINAL_DENSITY);
|
||||
put(files, "noise/hash_a", NOISE);
|
||||
put(files, "noise/hash_b", NOISE);
|
||||
|
||||
DensityJsonBreakdown.Report report = DensityJsonBreakdown.analyze(files, "overworld").report();
|
||||
|
||||
assertEquals(5, report.files());
|
||||
assertEquals(bytes(TEMPERATURE, SELECTOR, CELL_A, CELL_B, FINAL_DENSITY), report.bytes());
|
||||
DensityJsonBreakdown.CategoryBytes cellular = category(report, "cellular");
|
||||
assertEquals(2, cellular.files());
|
||||
assertEquals(1, cellular.distinctShapes());
|
||||
assertEquals(bytes(CELL_B), cellular.structurallyDuplicateBytes());
|
||||
assertEquals(2, category(report, "router").files());
|
||||
assertEquals(2, report.cellularInstances());
|
||||
assertEquals(1, report.cellularShapes().size());
|
||||
assertEquals("biome selector+router", report.cellularShapes().getFirst().owners());
|
||||
assertEquals(bytes(CELL_A), owner(report, "cellular via biome selector"));
|
||||
assertEquals(bytes(CELL_B), owner(report, "cellular via router"));
|
||||
}
|
||||
|
||||
private static DensityJsonBreakdown.CategoryBytes category(DensityJsonBreakdown.Report report, String name) {
|
||||
for (DensityJsonBreakdown.CategoryBytes category : report.categories()) {
|
||||
if (category.category().equals(name)) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
throw new AssertionError("No category " + name + " in " + report.categories());
|
||||
}
|
||||
|
||||
private static long owner(DensityJsonBreakdown.Report report, String name) {
|
||||
for (DensityJsonBreakdown.Bytes owner : report.libraryOwners()) {
|
||||
if (owner.name().equals(name)) {
|
||||
return owner.bytes();
|
||||
}
|
||||
}
|
||||
throw new AssertionError("No owner " + name + " in " + report.libraryOwners());
|
||||
}
|
||||
|
||||
private static long bytes(String... bodies) {
|
||||
long total = 0;
|
||||
for (String body : bodies) {
|
||||
total += body.getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private static void put(SortedMap<String, byte[]> files, String path, String body) {
|
||||
files.put("data/x/worldgen/" + path + ".json", body.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -137,7 +137,7 @@ public class HeadlessChunkGeneratorTest {
|
||||
}
|
||||
Files.writeString(pack.resolve("dimensions/main.json"), """
|
||||
{"name":"Native columns","focus":"swamp","regions":["owned"],"fluidHeight":40,"logicalHeight":256,
|
||||
"landChance":1,"carvingEnabled":false,"hydrology":{"rivers":{"enabled":false}}}
|
||||
"landChance":1,"carvingEnabled":false}
|
||||
""");
|
||||
Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"swamp\"]}");
|
||||
Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}");
|
||||
|
||||
+5
-7
@@ -1,7 +1,6 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.structure.nativegen.NativeStructureVolumeIndex;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyTileCache;
|
||||
import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
@@ -68,11 +67,10 @@ public final class HeadlessRegionTerrainTest {
|
||||
OfflineRegionGenerator.Configuration export = new OfflineRegionGenerator.Configuration(
|
||||
workspace.pack(), "main", 69420L, -3, 2, 1, 2, 3, 4, configuration,
|
||||
"probe", Level.OVERWORLD, NoiseGeneratorSettings.OVERWORLD, root.resolve("export"));
|
||||
HydrologyTileCache.PregenerationArea bounds = export.hydrologyArea();
|
||||
assertEquals(-80L, bounds.minimumBlockX());
|
||||
assertEquals(0L, bounds.minimumBlockZ());
|
||||
assertEquals(15L, bounds.maximumBlockX());
|
||||
assertEquals(111L, bounds.maximumBlockZ());
|
||||
List<long[]> regions = export.hydrologyRegions();
|
||||
assertEquals(2, regions.size());
|
||||
assertArrayEquals(new long[]{-1L, 0L}, regions.get(0));
|
||||
assertArrayEquals(new long[]{0L, 0L}, regions.get(1));
|
||||
assertEquals(1, result.targetChunks());
|
||||
assertEquals(529, result.chunks().size());
|
||||
long terrain = result.chunks().stream().filter(chunk -> chunk.getPersistedStatus() == ChunkStatus.TERRAIN).count();
|
||||
@@ -189,7 +187,7 @@ public final class HeadlessRegionTerrainTest {
|
||||
}
|
||||
Files.writeString(pack.resolve("dimensions/main.json"), """
|
||||
{"name":"Native region","focus":"swamp","regions":["owned"],"fluidHeight":40,"logicalHeight":256,
|
||||
"landChance":1,"carvingEnabled":false,"hydrology":{"rivers":{"enabled":false}}}
|
||||
"landChance":1,"carvingEnabled":false}
|
||||
""");
|
||||
Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"swamp\"]}");
|
||||
Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}");
|
||||
|
||||
+1
-19
@@ -1,37 +1,19 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import io.papermc.paper.registry.PaperRegistryAccess;
|
||||
import org.junit.rules.TestRule;
|
||||
import org.junit.runner.Description;
|
||||
import org.junit.runners.model.Statement;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public final class PaperRegistryIsolation implements TestRule {
|
||||
@Override
|
||||
public Statement apply(Statement base, Description description) {
|
||||
return new Statement() {
|
||||
@Override
|
||||
public void evaluate() throws Throwable {
|
||||
Map<Object, Object> registries = registries();
|
||||
Map<Object, Object> previous = new HashMap<>(registries);
|
||||
registries.clear();
|
||||
try {
|
||||
try (PaperRegistryScope scope = PaperRegistryScope.open()) {
|
||||
base.evaluate();
|
||||
} finally {
|
||||
registries.clear();
|
||||
registries.putAll(previous);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<Object, Object> registries() throws ReflectiveOperationException {
|
||||
Field field = PaperRegistryAccess.class.getDeclaredField("registries");
|
||||
field.setAccessible(true);
|
||||
return (Map<Object, Object>) field.get(PaperRegistryAccess.instance());
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.pack.datapack.DataVersion;
|
||||
import art.arcane.iris.pack.datapack.export.emit.DensityFunctionEmitter;
|
||||
import art.arcane.iris.pack.datapack.export.emit.FunctionTable;
|
||||
import art.arcane.iris.pack.datapack.export.ir.Axis;
|
||||
import art.arcane.iris.pack.datapack.export.ir.Field;
|
||||
import art.arcane.iris.pack.datapack.export.ir.NoiseDef;
|
||||
import art.arcane.iris.pack.datapack.export.ir.ValueRange;
|
||||
import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities;
|
||||
import art.arcane.iris.pack.datapack.export.lower.VanillaBounds;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.util.Interval;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class VanillaBoundsParity263Test {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
private static final String NAMESPACE = "iris_export";
|
||||
private static final List<NoiseDef> NOISES = List.of(new NoiseDef("parity/base", -3, List.of(1.0, 0.5, 0.25)),
|
||||
new NoiseDef("parity/sparse", 0, List.of(0.0, 1.5, 0.0, 0.7)));
|
||||
private static final Field BASE = new Field.Noise(NAMESPACE + ":parity/base", 0.25, 0.0, null, null, null);
|
||||
private static final Field SPARSE = new Field.Noise(NAMESPACE + ":parity/sparse", 0.5, 0.25, null, null, null);
|
||||
private static final Field UNIT = new Field.Clamp(BASE, 0.0, 1.0);
|
||||
private static final Field BAND = new Field.Clamp(SPARSE, 0.3, 0.9);
|
||||
|
||||
@Test
|
||||
public void boundsMatchMinecraftForEveryEmittedNode() throws Exception {
|
||||
List<Field> fields = fields();
|
||||
VanillaBounds bounds = VanillaBounds.of(NAMESPACE, NOISES);
|
||||
DensityFunctionEmitter emitter = DensityFunctionEmitter.forTarget(ExportCapabilities.forTarget(DataVersion.V26_3), NAMESPACE, bounds);
|
||||
FunctionTable table = new FunctionTable(NAMESPACE);
|
||||
List<String> references = new ArrayList<>(fields.size());
|
||||
for (int index = 0; index < fields.size(); index++) {
|
||||
JsonElement emitted = emitter.emit(fields.get(index), table);
|
||||
boolean reference = emitted.isJsonPrimitive() && emitted.getAsJsonPrimitive().isString();
|
||||
references.add(reference ? emitted.getAsString() : table.register("parity/field_" + index, emitted));
|
||||
}
|
||||
Map<String, String> definitions = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, JsonElement> function : table.files().entrySet()) {
|
||||
definitions.put("data/" + NAMESPACE + "/worldgen/density_function/" + function.getKey() + ".json", function.getValue().toString());
|
||||
}
|
||||
for (NoiseDef noise : NOISES) {
|
||||
definitions.put("data/" + NAMESPACE + "/worldgen/noise/" + noise.id() + ".json", noiseJson(noise).toString());
|
||||
}
|
||||
try (HeadlessNativeRegistries registries = HeadlessNativeRegistries.load(definitions)) {
|
||||
for (int index = 0; index < fields.size(); index++) {
|
||||
Interval vanilla = registries.registries().lookupOrThrow(Registries.DENSITY_FUNCTION)
|
||||
.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, Identifier.parse(references.get(index)))).value().range();
|
||||
Optional<ValueRange> modelled = bounds.range(fields.get(index));
|
||||
assertTrue("field " + index + " " + fields.get(index) + " has no modelled range, Minecraft " + vanilla, modelled.isPresent());
|
||||
assertEquals("field " + index + " " + fields.get(index), new ValueRange(vanilla.min(), vanilla.max()), modelled.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Field> fields() {
|
||||
Field scaled = new Field.Binary(Field.BinaryOp.MUL, new Field.Const(0.3), BAND);
|
||||
Field curve = new Field.Curve(new Field.Binary(Field.BinaryOp.MUL, BASE, new Field.Const(1.7)), List.of(
|
||||
new Field.Knot(-1.0, new Field.Const(0.2), 0.75),
|
||||
new Field.Knot(0.1, new Field.Curve(SPARSE, List.of(new Field.Knot(-0.5, new Field.Const(-1.0), 0.0),
|
||||
new Field.Knot(0.5, new Field.Const(1.3), 2.0))), 0.0),
|
||||
new Field.Knot(0.8, new Field.Const(0.6), -1.5)));
|
||||
List<Field> fields = new ArrayList<>();
|
||||
fields.add(BASE);
|
||||
fields.add(SPARSE);
|
||||
fields.add(new Field.Const(0.1));
|
||||
fields.add(new Field.Coord(Axis.Y, Field.Tiling.CLAMP, -64.0, 320.0, 1.5, -0.7));
|
||||
fields.add(new Field.Clamp(BASE, -0.3, 0.8));
|
||||
for (Field.BinaryOp op : Field.BinaryOp.values()) {
|
||||
fields.add(new Field.Binary(op, BASE, new Field.Binary(Field.BinaryOp.ADD, BAND, new Field.Const(0.6))));
|
||||
}
|
||||
fields.add(new Field.Binary(Field.BinaryOp.POW, UNIT, new Field.Const(2.5)));
|
||||
fields.add(new Field.Binary(Field.BinaryOp.MAX, scaled, new Field.Const(0.0)));
|
||||
fields.add(new Field.Binary(Field.BinaryOp.MIN, new Field.Const(2.0), scaled));
|
||||
fields.add(new Field.Binary(Field.BinaryOp.MIN, new Field.Const(0.2), scaled));
|
||||
for (Field.UnaryOp op : Field.UnaryOp.values()) {
|
||||
fields.add(new Field.Unary(op, new Field.Binary(Field.BinaryOp.SUB, SPARSE, new Field.Const(0.4))));
|
||||
}
|
||||
fields.add(new Field.Lerp(UNIT, BASE, new Field.Binary(Field.BinaryOp.MUL, SPARSE, new Field.Const(3.0))));
|
||||
for (Field.RoundMode mode : Field.RoundMode.values()) {
|
||||
fields.add(new Field.Round(mode, new Field.Binary(Field.BinaryOp.MUL, BASE, new Field.Const(10.3)), new Field.Const(2.5)));
|
||||
}
|
||||
fields.add(curve);
|
||||
fields.add(new Field.Curve(UNIT, List.of(new Field.Knot(0.0, new Field.Const(0.0), -3.0), new Field.Knot(1.0, new Field.Const(1.0), -3.0))));
|
||||
fields.add(new Field.Partition(BASE, List.of(-0.2, 0.4), List.of(new Field.Const(-3.0), new Field.Const(0.5), new Field.Const(7.25))));
|
||||
fields.add(new Field.Mux(new Field.Binary(Field.BinaryOp.MUL, UNIT, new Field.Const(2.0)), List.of(new Field.Const(4.0), BAND, scaled)));
|
||||
fields.add(new Field.Gate(BASE, new Field.Binary(Field.BinaryOp.ADD, BAND, new Field.Const(40.0))));
|
||||
fields.add(new Field.RangeIf(BASE, -0.1, 0.2, new Field.Const(-2.0), BAND));
|
||||
fields.add(new Field.Grid(new Field.Binary(Field.BinaryOp.ADD, BASE, SPARSE), 4, 8));
|
||||
fields.add(new Field.Shared("parity/shared", new Field.Binary(Field.BinaryOp.SUB, BAND, BASE)));
|
||||
fields.add(new Field.Slice(new Field.Binary(Field.BinaryOp.MUL, SPARSE, new Field.Const(-2.0)), Axis.Y, 64));
|
||||
fields.add(new Field.TopSurface(new Field.Binary(Field.BinaryOp.SUB, BASE, new Field.Const(0.1)), 200, -48, 4));
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static JsonObject noiseJson(NoiseDef noise) {
|
||||
JsonArray modifiers = new JsonArray(noise.amplitudes().size());
|
||||
for (double amplitude : noise.amplitudes()) {
|
||||
modifiers.add(amplitude);
|
||||
}
|
||||
JsonObject json = new JsonObject();
|
||||
json.addProperty("base_octave", noise.firstOctave());
|
||||
json.addProperty("octave_count", noise.amplitudes().size());
|
||||
json.add("amplitude_modifiers", modifiers);
|
||||
json.addProperty("base_amplitude", 1.0);
|
||||
json.addProperty("normalize", false);
|
||||
return json;
|
||||
}
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.pack.datapack.DataVersion;
|
||||
import art.arcane.iris.pack.datapack.export.compile.noise.VanillaNoiseModel;
|
||||
import net.minecraft.core.HolderGetter;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
|
||||
import net.minecraft.world.level.levelgen.PositionalRandomFactory;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.XoroshiroRandomSource;
|
||||
import net.minecraft.world.level.levelgen.synth.Noise;
|
||||
import net.minecraft.world.level.levelgen.synth.NormalNoise;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.SplittableRandom;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class VanillaNoiseModelParity263Test {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
private static final int POINTS = 4096;
|
||||
private static final double TOLERANCE = 1.0E-5D;
|
||||
private static final double RUNTIME_TOLERANCE = 1.0E-4D;
|
||||
private static final List<Long> WORLD_SEEDS = List.of(0L, 8_675_309L, -4_611_686_018_427_387_904L);
|
||||
private static final List<ParityNoise> NOISES = List.of(
|
||||
ParityNoise.mapped("iris_export:parity/single", -7, List.of(1.0D)),
|
||||
ParityNoise.mapped("iris_export:parity/fbm", -9, List.of(1.0D, 1.0D, 0.5D, 0.25D)),
|
||||
ParityNoise.mapped("iris_export:parity/sparse", -10, List.of(1.5D, 0.0D, 1.0D, 0.0D, 0.0D, 0.0D)),
|
||||
ParityNoise.mapped("iris_export:parity/leading_zero", -6, List.of(0.0D, 0.0D, 1.0D, 2.0D)),
|
||||
ParityNoise.mapped("iris_export:parity/positive", 2, List.of(1.0D, 0.5D)),
|
||||
ParityNoise.profiled("iris_export:parity/enabled",
|
||||
"{\"base_octave\":-10,\"octave_count\":6,\"amplitude_modifiers\":[1.5,0.0,1.0,0.0,0.0,0.0],"
|
||||
+ "\"base_amplitude\":1.2453007926713473}",
|
||||
new VanillaNoiseModel.OctaveProfile(-10, 6, List.of(1.5D, 0.0D, 1.0D, 0.0D, 0.0D, 0.0D),
|
||||
1.2453007926713473D, VanillaNoiseModel.Normalization.ENABLED)),
|
||||
ParityNoise.profiled("iris_export:parity/legacy",
|
||||
"{\"base_octave\":-9,\"octave_count\":3,\"amplitude_modifiers\":[1.0,0.0,1.0],\"normalize\":\"legacy\"}",
|
||||
new VanillaNoiseModel.OctaveProfile(-9, 3, List.of(1.0D, 0.0D, 1.0D), 1.0D,
|
||||
VanillaNoiseModel.Normalization.LEGACY)),
|
||||
ParityNoise.profiled("iris_export:parity/defaults", "{\"base_octave\":-5}",
|
||||
new VanillaNoiseModel.OctaveProfile(-5, 1, List.of(), 1.0D, VanillaNoiseModel.Normalization.ENABLED)),
|
||||
ParityNoise.profiled("iris_export:parity/scaled",
|
||||
"{\"base_octave\":-6,\"octave_count\":2,\"normalize\":false,\"base_amplitude\":0.5}",
|
||||
new VanillaNoiseModel.OctaveProfile(-6, 2, List.of(), 0.5D, VanillaNoiseModel.Normalization.DISABLED)));
|
||||
|
||||
private static HeadlessNativeRegistries registries;
|
||||
|
||||
@BeforeClass
|
||||
public static void loadNoiseDefinitions() throws IOException {
|
||||
Map<String, String> definitions = new LinkedHashMap<>();
|
||||
for (ParityNoise noise : NOISES) {
|
||||
definitions.put(resourcePath(noise.id()), noise.json());
|
||||
}
|
||||
registries = HeadlessNativeRegistries.load(definitions);
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void closeRegistries() throws IOException {
|
||||
if (registries != null) {
|
||||
registries.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void modelMatchesNoiseBuiltFromLoadedDefinitions() {
|
||||
List<String> report = new ArrayList<>();
|
||||
for (long seed : WORLD_SEEDS) {
|
||||
PositionalRandomFactory world = new VanillaPrecisionRandom(new XoroshiroRandomSource(seed)).forkPositional();
|
||||
for (ParityNoise noise : NOISES) {
|
||||
NormalNoise definition = registries.registries().lookupOrThrow(Registries.NOISE).getOrThrow(key(noise.id())).value();
|
||||
Noise real = definition.create(world.fromHashOf(Identifier.parse(noise.id())));
|
||||
report.add(noise.id() + "@" + seed + "=" + assertParity(noise, seed, real, TOLERANCE));
|
||||
}
|
||||
}
|
||||
System.out.println("26.3 vanilla-precision parity " + report);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void modelMatchesRuntimeRandomStateNoise() {
|
||||
List<String> report = new ArrayList<>();
|
||||
for (long seed : WORLD_SEEDS) {
|
||||
RandomState state = randomState(seed);
|
||||
for (ParityNoise noise : NOISES) {
|
||||
Noise real = state.getOrCreateNoise(key(noise.id()));
|
||||
report.add(noise.id() + "@" + seed + "=" + assertParity(noise, seed, real, RUNTIME_TOLERANCE));
|
||||
}
|
||||
}
|
||||
System.out.println("26.3 runtime parity " + report);
|
||||
}
|
||||
|
||||
private static double assertParity(ParityNoise noise, long seed, Noise real, double tolerance) {
|
||||
VanillaNoiseModel model = noise.model(seed);
|
||||
SplittableRandom random = new SplittableRandom(seed ^ noise.id().hashCode());
|
||||
double worst = 0.0D;
|
||||
double peak = 0.0D;
|
||||
for (int index = 0; index < POINTS; index++) {
|
||||
double range = random.nextInt(4) == 0 ? 30_000_000.0D : 100_000.0D;
|
||||
double x = random.nextDouble(-range, range);
|
||||
double y = random.nextDouble(-2_048.0D, 2_048.0D);
|
||||
double z = random.nextDouble(-range, range);
|
||||
double expected = real.get(x, y, z);
|
||||
worst = Math.max(worst, Math.abs(expected - model.sample(x, y, z)));
|
||||
peak = Math.max(peak, Math.abs(expected));
|
||||
}
|
||||
assertTrue(noise.id() + " seed " + seed + " differs by " + worst, worst <= tolerance);
|
||||
assertTrue(noise.id() + " seed " + seed + " is flat", peak > 0.05D);
|
||||
return worst;
|
||||
}
|
||||
|
||||
private static RandomState randomState(long seed) {
|
||||
HolderGetter<NormalNoise> noises = registries.registries().lookupOrThrow(Registries.NOISE);
|
||||
NoiseGeneratorSettings settings = registries.registries().lookupOrThrow(Registries.NOISE_SETTINGS)
|
||||
.getOrThrow(NoiseGeneratorSettings.OVERWORLD).value();
|
||||
return RandomState.create(noises, seed, settings);
|
||||
}
|
||||
|
||||
private static ResourceKey<NormalNoise> key(String id) {
|
||||
return ResourceKey.create(Registries.NOISE, Identifier.parse(id));
|
||||
}
|
||||
|
||||
private static String resourcePath(String id) {
|
||||
Identifier identifier = Identifier.parse(id);
|
||||
return "data/" + identifier.getNamespace() + "/worldgen/noise/" + identifier.getPath() + ".json";
|
||||
}
|
||||
|
||||
private record ParityNoise(String id, String json, int firstOctave, List<Double> amplitudes,
|
||||
VanillaNoiseModel.OctaveProfile profile) {
|
||||
private static ParityNoise mapped(String id, int firstOctave, List<Double> amplitudes) {
|
||||
String modifiers = amplitudes.stream().map(String::valueOf).collect(Collectors.joining(","));
|
||||
String json = "{\"base_octave\":" + firstOctave + ",\"octave_count\":" + amplitudes.size()
|
||||
+ ",\"amplitude_modifiers\":[" + modifiers + "],\"base_amplitude\":1.0,\"normalize\":false}";
|
||||
return new ParityNoise(id, json, firstOctave, amplitudes, null);
|
||||
}
|
||||
|
||||
private static ParityNoise profiled(String id, String json, VanillaNoiseModel.OctaveProfile profile) {
|
||||
return new ParityNoise(id, json, 0, List.of(), profile);
|
||||
}
|
||||
|
||||
private VanillaNoiseModel model(long seed) {
|
||||
if (profile == null) {
|
||||
return VanillaNoiseModel.create(id, firstOctave, amplitudes, seed, DataVersion.V26_3);
|
||||
}
|
||||
return VanillaNoiseModel.create(id, profile, seed);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class VanillaPrecisionRandom implements RandomSource {
|
||||
private static final double DOUBLE_UNIT = 1.1102230246251565E-16D;
|
||||
|
||||
private final RandomSource source;
|
||||
|
||||
private VanillaPrecisionRandom(RandomSource source) {
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RandomSource fork() {
|
||||
return new VanillaPrecisionRandom(source.fork());
|
||||
}
|
||||
|
||||
@Override
|
||||
public PositionalRandomFactory forkPositional() {
|
||||
return new VanillaPrecisionPositional(source.forkPositional());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSeed(long seed) {
|
||||
source.setSeed(seed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int nextInt() {
|
||||
return source.nextInt();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int nextInt(int bound) {
|
||||
return source.nextInt(bound);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long nextLong() {
|
||||
return source.nextLong();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean nextBoolean() {
|
||||
return source.nextBoolean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public float nextFloat() {
|
||||
return source.nextFloat();
|
||||
}
|
||||
|
||||
@Override
|
||||
public double nextDouble() {
|
||||
return (source.nextLong() >>> 11) * DOUBLE_UNIT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double nextGaussian() {
|
||||
return source.nextGaussian();
|
||||
}
|
||||
}
|
||||
|
||||
private record VanillaPrecisionPositional(PositionalRandomFactory factory) implements PositionalRandomFactory {
|
||||
@Override
|
||||
public RandomSource at(int x, int y, int z) {
|
||||
return new VanillaPrecisionRandom(factory.at(x, y, z));
|
||||
}
|
||||
|
||||
@Override
|
||||
public RandomSource fromHashOf(String name) {
|
||||
return new VanillaPrecisionRandom(factory.fromHashOf(name));
|
||||
}
|
||||
|
||||
@Override
|
||||
public RandomSource fromSeed(long seed) {
|
||||
return new VanillaPrecisionRandom(factory.fromSeed(seed));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void parityConfigString(StringBuilder builder) {
|
||||
factory.parityConfigString(builder);
|
||||
}
|
||||
}
|
||||
}
|
||||
+700
@@ -0,0 +1,700 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.pack.datapack.DataVersion;
|
||||
import art.arcane.iris.pack.datapack.export.AtomicDatapackPublisher;
|
||||
import art.arcane.iris.pack.datapack.export.DatapackExportFormat;
|
||||
import art.arcane.iris.pack.datapack.export.ExportIrFixtures;
|
||||
import art.arcane.iris.pack.datapack.export.GenerationCost;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportProgress;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportResult;
|
||||
import art.arcane.iris.pack.datapack.export.compile.PackCompiler;
|
||||
import art.arcane.iris.pack.datapack.export.compile.surface.MaterialProgramCompiler;
|
||||
import art.arcane.iris.pack.datapack.export.compile.terrain.FieldMacros;
|
||||
import art.arcane.iris.pack.datapack.export.compile.terrain.HeightCompiler;
|
||||
import art.arcane.iris.pack.datapack.export.emit.NestingSplitter;
|
||||
import art.arcane.iris.pack.datapack.export.emit.WorldgenJsonWriter;
|
||||
import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic;
|
||||
import art.arcane.iris.pack.datapack.export.ir.ExportIr;
|
||||
import art.arcane.iris.pack.datapack.export.ir.ExportLimit;
|
||||
import art.arcane.iris.pack.datapack.export.ir.Field;
|
||||
import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator;
|
||||
import art.arcane.iris.pack.datapack.export.ir.NoiseDef;
|
||||
import art.arcane.iris.pack.datapack.export.lower.GenerationBudget;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.tags.WorldPresetTags;
|
||||
import net.minecraft.util.Interval;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import net.minecraft.world.level.dimension.LevelStem;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DfRewriteRule;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.op.BinaryFunction;
|
||||
import net.minecraft.world.level.levelgen.material.rule.OreVeinRule;
|
||||
import net.minecraft.world.level.levelgen.presets.WorldPreset;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.Assume;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Deque;
|
||||
import java.util.Enumeration;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.SortedMap;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class WorldgenExport263CodecTest {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
@ClassRule
|
||||
public static final TemporaryFolder overworldFolder = new TemporaryFolder();
|
||||
|
||||
private static final long SEED = 1337L;
|
||||
private static final String DIMENSION_ID = ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.DIMENSION;
|
||||
private static final String HEIGHT_ID = ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.DIMENSION + "/height";
|
||||
private static final double SURFACE_TOLERANCE = 2.0;
|
||||
private static final String BIOME_PREFIX = "data/" + ExportIrFixtures.NAMESPACE + "/worldgen/biome/";
|
||||
private static final int[] SAMPLE_Y = {-64, 0, 64, 200};
|
||||
private static final int LATTICE = 16;
|
||||
private static final int LATTICE_STEP = 61;
|
||||
private static final int HEIGHT_POINTS = 8;
|
||||
private static final int HEIGHT_STEP = 97;
|
||||
private static final String PROBE_PACK = "iris.exportProbePack";
|
||||
private static final String OVERWORLD = "overworld";
|
||||
private static final String OVERWORLD_ID = WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + OVERWORLD;
|
||||
private static final String OVERWORLD_BIOME_PREFIX = "data/" + WorldgenExportRequest.DEFAULT_NAMESPACE + "/worldgen/biome/";
|
||||
private static final String LOG_PREFIX = "[worldgen-export]";
|
||||
private static final int OVERWORLD_LATTICE = 4;
|
||||
private static final int OVERWORLD_STEP = 509;
|
||||
private static final int MAX_NESTING = 255;
|
||||
private static final String CAVE_FIELD = "cave/field";
|
||||
private static final String WALL_FALLBACK = "uses the nearest kept palette (minecraft:";
|
||||
private static final double WALL_SHELL_BAND_LIMIT = 0.25;
|
||||
private static final String DENSITY_FUNCTION_RESOURCE = "worldgen/density_function";
|
||||
private static final double MEGABYTE = 1024.0 * 1024.0;
|
||||
private static final int OVERWORLD_LAVA_Y = -248;
|
||||
private static final int LAVA_LINE = -54;
|
||||
private static final int DEEP_CHUNKS = 2;
|
||||
private static final int DEEP_STRIDE = 4;
|
||||
private static final int CHUNK_WIDTH = 16;
|
||||
private static final float OPEN_MARGIN = 1.0e-3f;
|
||||
private static final int LAVA_SEARCH_RADIUS = 1024;
|
||||
private static final int LAVA_SEARCH_STEP = 16;
|
||||
private static final int LAVA_CELLS = 4;
|
||||
private static final int AGREEMENT_POINTS = 16;
|
||||
private static final double AGREEMENT_TOLERANCE = 1.0;
|
||||
private static final int AGREEMENT_OUTLIERS = 2;
|
||||
private static final String SUBTERRAIN_STRUCTURES = "data/" + WorldgenExportRequest.DEFAULT_NAMESPACE + "/worldgen/structure/subterrain/";
|
||||
private static final String SUBTERRAIN_TEMPLATES = "data/" + WorldgenExportRequest.DEFAULT_NAMESPACE + "/structure/subterrain/";
|
||||
private static final int ROOM_GRID = 24;
|
||||
private static final int ROOM_STEP_CHUNKS = 8;
|
||||
private static final int ROOM_ATTEMPTS = 4;
|
||||
private static final int ROOM_REACH = 128;
|
||||
private static final Map<WorldgenExportMode, SortedMap<String, byte[]>> FILES = new EnumMap<>(WorldgenExportMode.class);
|
||||
private static final Map<WorldgenExportMode, WorldgenExportSampler> SAMPLERS = new EnumMap<>(WorldgenExportMode.class);
|
||||
private static SortedMap<String, byte[]> exportedOverworld;
|
||||
private static WorldgenExportResult exportedOverworldResult;
|
||||
private static ExportIr exportedOverworldIr;
|
||||
|
||||
@BeforeClass
|
||||
public static void loadExports() throws IOException {
|
||||
for (WorldgenExportMode mode : WorldgenExportMode.values()) {
|
||||
SortedMap<String, byte[]> files = WorldgenJsonWriter.forTarget(DataVersion.V26_3).write(ExportIrFixtures.flatNoise(DataVersion.V26_3, mode));
|
||||
FILES.put(mode, files);
|
||||
SAMPLERS.put(mode, WorldgenExportSampler.open(files, mode, DIMENSION_ID, SEED));
|
||||
}
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void closeExports() throws IOException {
|
||||
for (WorldgenExportSampler sampler : SAMPLERS.values()) {
|
||||
sampler.close();
|
||||
}
|
||||
SAMPLERS.clear();
|
||||
FILES.clear();
|
||||
exportedOverworld = null;
|
||||
exportedOverworldResult = null;
|
||||
exportedOverworldIr = null;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fixtureLoadsInEveryMode() {
|
||||
for (WorldgenExportMode mode : WorldgenExportMode.values()) {
|
||||
WorldgenExportSampler sampler = SAMPLERS.get(mode);
|
||||
assertEquals(ExportIrFixtures.MIN_Y, sampler.minY());
|
||||
assertEquals(ExportIrFixtures.HEIGHT, sampler.height());
|
||||
assertEquals(ExportIrFixtures.SEA_LEVEL, sampler.seaLevel());
|
||||
assertFalse(sampler.densityFunctionIds().isEmpty());
|
||||
for (String id : sampler.densityFunctionIds()) {
|
||||
assertTrue(mode + " " + id, sampler.registries().lookupOrThrow(Registries.DENSITY_FUNCTION).containsKey(Identifier.parse(id)));
|
||||
}
|
||||
assertTrue(sampler.registries().lookupOrThrow(Registries.NOISE_SETTINGS).containsKey(Identifier.parse(DIMENSION_ID)));
|
||||
assertTrue(sampler.registries().lookupOrThrow(Registries.DIMENSION_TYPE).containsKey(Identifier.parse(DIMENSION_ID)));
|
||||
assertTrue(sampler.registries().lookupOrThrow(Registries.MATERIAL_RULE).containsKey(Identifier.parse(DIMENSION_ID)));
|
||||
boolean preset = sampler.registries().lookupOrThrow(Registries.WORLD_PRESET).containsKey(Identifier.parse(DIMENSION_ID));
|
||||
boolean dimension = sampler.registries().lookupOrThrow(Registries.LEVEL_STEM).containsKey(Identifier.parse(DIMENSION_ID));
|
||||
boolean overworld = sampler.registries().lookupOrThrow(Registries.LEVEL_STEM).containsKey(LevelStem.OVERWORLD.identifier());
|
||||
assertEquals(mode.toString(), mode == WorldgenExportMode.PRESET, preset);
|
||||
assertEquals(mode.toString(), mode == WorldgenExportMode.DIMENSION, dimension);
|
||||
assertEquals(mode.toString(), mode == WorldgenExportMode.OVERWORLD, overworld);
|
||||
}
|
||||
WorldgenExportSampler presetSampler = SAMPLERS.get(WorldgenExportMode.PRESET);
|
||||
Holder<WorldPreset> preset = presetSampler.registries().lookupOrThrow(Registries.WORLD_PRESET)
|
||||
.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, Identifier.parse(DIMENSION_ID)));
|
||||
assertTrue(preset.value().createWorldDimensions().get(LevelStem.NETHER).isPresent());
|
||||
assertTrue(preset.value().createWorldDimensions().get(LevelStem.END).isPresent());
|
||||
HolderSet.Named<WorldPreset> normal = presetSampler.registries().lookupOrThrow(Registries.WORLD_PRESET).getOrThrow(WorldPresetTags.NORMAL);
|
||||
assertTrue(normal.contains(preset));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyDensityFunctionEvaluatesFinite() {
|
||||
for (WorldgenExportMode mode : WorldgenExportMode.values()) {
|
||||
WorldgenExportSampler sampler = SAMPLERS.get(mode);
|
||||
for (String id : sampler.densityFunctionIds()) {
|
||||
for (int y : SAMPLE_Y) {
|
||||
for (int xIndex = 0; xIndex < LATTICE; xIndex++) {
|
||||
for (int zIndex = 0; zIndex < LATTICE; zIndex++) {
|
||||
int x = (xIndex - LATTICE / 2) * LATTICE_STEP;
|
||||
int z = (zIndex - LATTICE / 2) * LATTICE_STEP;
|
||||
float value = sampler.density(id, x, y, z);
|
||||
assertTrue(mode + " " + id + " at " + x + "," + y + "," + z + " = " + value, Float.isFinite(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void baseHeightWithinDimension() {
|
||||
for (WorldgenExportMode mode : WorldgenExportMode.values()) {
|
||||
WorldgenExportSampler sampler = SAMPLERS.get(mode);
|
||||
Set<Integer> heights = new HashSet<>();
|
||||
int land = 0;
|
||||
for (int xIndex = 0; xIndex < HEIGHT_POINTS; xIndex++) {
|
||||
for (int zIndex = 0; zIndex < HEIGHT_POINTS; zIndex++) {
|
||||
int x = (xIndex - HEIGHT_POINTS / 2) * HEIGHT_STEP;
|
||||
int z = (zIndex - HEIGHT_POINTS / 2) * HEIGHT_STEP;
|
||||
int height = sampler.baseHeight(x, z);
|
||||
assertTrue(mode + " height " + height + " at " + x + "," + z,
|
||||
height >= sampler.minY() && height <= sampler.minY() + sampler.height());
|
||||
float surface = sampler.density(HEIGHT_ID, x, 0, z);
|
||||
int floor = sampler.floorHeight(x, z);
|
||||
assertTrue(mode + " height " + height + " below floor " + floor, height >= floor);
|
||||
if (surface > sampler.seaLevel() + 1) {
|
||||
assertTrue(mode + " floor " + floor + " against surface " + surface, Math.abs(floor - surface) <= SURFACE_TOLERANCE);
|
||||
land++;
|
||||
}
|
||||
heights.add(height);
|
||||
}
|
||||
}
|
||||
assertTrue(mode + " surface never varies", heights.size() > 1);
|
||||
assertTrue(mode + " has no land column above sea level", land > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolverReturnsOnlyExportedBiomes() {
|
||||
for (WorldgenExportMode mode : WorldgenExportMode.values()) {
|
||||
WorldgenExportSampler sampler = SAMPLERS.get(mode);
|
||||
Set<String> exported = exportedBiomes(FILES.get(mode), BIOME_PREFIX, ExportIrFixtures.NAMESPACE);
|
||||
assertFalse(exported.isEmpty());
|
||||
assertTrue(mode + " " + sampler.possibleBiomes(), exported.containsAll(sampler.possibleBiomes()));
|
||||
for (int y : SAMPLE_Y) {
|
||||
for (int xIndex = 0; xIndex < LATTICE; xIndex++) {
|
||||
for (int zIndex = 0; zIndex < LATTICE; zIndex++) {
|
||||
String biome = sampler.biome((xIndex - LATTICE / 2) * LATTICE_STEP, y, (zIndex - LATTICE / 2) * LATTICE_STEP);
|
||||
assertTrue(mode + " " + biome, exported.contains(biome));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void overworldPackLoads() throws Exception {
|
||||
SortedMap<String, byte[]> files = overworldFiles();
|
||||
assertGenerationBudget(exportedOverworldResult);
|
||||
assertWallPalettesFallBack(exportedOverworldResult);
|
||||
for (Map.Entry<String, byte[]> file : files.entrySet()) {
|
||||
if (file.getKey().endsWith(".json")) {
|
||||
int depth = NestingSplitter.depth(JsonParser.parseString(new String(file.getValue(), StandardCharsets.UTF_8)));
|
||||
assertTrue(file.getKey() + " nests " + depth, depth <= MAX_NESTING);
|
||||
}
|
||||
if (file.getKey().endsWith(".nbt")) {
|
||||
assertCompleteGzip(file.getKey(), file.getValue());
|
||||
}
|
||||
}
|
||||
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(files, WorldgenExportMode.OVERWORLD, OVERWORLD_ID, SEED)) {
|
||||
Set<String> roots = new TreeSet<>();
|
||||
for (String id : sampler.densityFunctionIds()) {
|
||||
if (id.startsWith(OVERWORLD_ID + "/")) {
|
||||
roots.add(id);
|
||||
}
|
||||
}
|
||||
assertTrue(roots.toString(), roots.contains(OVERWORLD_ID + "/final_density"));
|
||||
for (String id : roots) {
|
||||
for (int y : SAMPLE_Y) {
|
||||
for (int xIndex = 0; xIndex < OVERWORLD_LATTICE; xIndex++) {
|
||||
for (int zIndex = 0; zIndex < OVERWORLD_LATTICE; zIndex++) {
|
||||
int x = (xIndex - OVERWORLD_LATTICE / 2) * OVERWORLD_STEP;
|
||||
int z = (zIndex - OVERWORLD_LATTICE / 2) * OVERWORLD_STEP;
|
||||
float value = sampler.density(id, x, y, z);
|
||||
assertTrue(id + " at " + x + "," + y + "," + z + " = " + value, Float.isFinite(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertNaturalHeightMatchesTheEvaluator(exportedOverworldIr, sampler);
|
||||
Set<String> exported = exportedBiomes(files, OVERWORLD_BIOME_PREFIX, WorldgenExportRequest.DEFAULT_NAMESPACE);
|
||||
assertFalse(exported.isEmpty());
|
||||
assertTrue(sampler.possibleBiomes().toString(), exported.containsAll(sampler.possibleBiomes()));
|
||||
for (int xIndex = 0; xIndex < HEIGHT_POINTS; xIndex++) {
|
||||
for (int zIndex = 0; zIndex < HEIGHT_POINTS; zIndex++) {
|
||||
int x = (xIndex - HEIGHT_POINTS / 2) * HEIGHT_STEP;
|
||||
int z = (zIndex - HEIGHT_POINTS / 2) * HEIGHT_STEP;
|
||||
int height = sampler.baseHeight(x, z);
|
||||
assertTrue("height " + height + " at " + x + "," + z, height >= sampler.minY() && height <= sampler.minY() + sampler.height());
|
||||
for (int y : SAMPLE_Y) {
|
||||
String biome = sampler.biome(x, y, z);
|
||||
assertTrue(biome + " at " + x + "," + y + "," + z, exported.contains(biome));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void overworldHasNoDisjointMinMax() throws Exception {
|
||||
SortedMap<String, byte[]> files = overworldFiles();
|
||||
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(files, WorldgenExportMode.OVERWORLD, OVERWORLD_ID, SEED)) {
|
||||
MinMaxAudit audit = new MinMaxAudit();
|
||||
for (String id : sampler.densityFunctionIds()) {
|
||||
audit.rewrite(sampler.registries().lookupOrThrow(Registries.DENSITY_FUNCTION)
|
||||
.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, Identifier.parse(id))).value());
|
||||
}
|
||||
System.out.println(LOG_PREFIX + " min/max nodes " + audit.checked() + ", disjoint " + audit.disjoint().size());
|
||||
assertTrue(audit.checked() > 0);
|
||||
assertTrue(audit.disjoint().size() + " disjoint min/max nodes: " + audit.disjoint(), audit.disjoint().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void overworldDeepCavesOpenWithoutLava() throws Exception {
|
||||
SortedMap<String, byte[]> files = overworldFiles();
|
||||
String field = WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + CAVE_FIELD;
|
||||
long open = 0L;
|
||||
long lava = 0L;
|
||||
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(files, WorldgenExportMode.OVERWORLD, OVERWORLD_ID, SEED)) {
|
||||
assertTrue(field, sampler.densityFunctionIds().contains(field));
|
||||
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
|
||||
for (int chunkX = -DEEP_CHUNKS; chunkX < DEEP_CHUNKS; chunkX++) {
|
||||
for (int chunkZ = -DEEP_CHUNKS; chunkZ < DEEP_CHUNKS; chunkZ++) {
|
||||
ProtoChunk chunk = sampler.terrainChunk(chunkX, chunkZ);
|
||||
for (int x = 0; x < CHUNK_WIDTH; x++) {
|
||||
for (int z = 0; z < CHUNK_WIDTH; z++) {
|
||||
for (int y = OVERWORLD_LAVA_Y + 1; y < LAVA_LINE; y++) {
|
||||
BlockState state = chunk.getBlockState(position.set(x, y, z));
|
||||
assertFalse(state + " at chunk " + chunkX + "," + chunkZ + " " + x + "," + y + "," + z, state.is(Blocks.LAVA));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int x = 0; x < CHUNK_WIDTH; x += DEEP_STRIDE) {
|
||||
for (int z = 0; z < CHUNK_WIDTH; z += DEEP_STRIDE) {
|
||||
int blockX = (chunkX << 4) + x;
|
||||
int blockZ = (chunkZ << 4) + z;
|
||||
for (int y = OVERWORLD_LAVA_Y + 1; y < LAVA_LINE; y++) {
|
||||
if (sampler.density(field, blockX, y, blockZ) > -OPEN_MARGIN) {
|
||||
continue;
|
||||
}
|
||||
BlockState state = chunk.getBlockState(position.set(x, y, z));
|
||||
assertTrue(state + " at " + blockX + "," + y + "," + blockZ, state.isAir());
|
||||
open++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (BlockPos cell : openLavaBandCells(sampler, field)) {
|
||||
ProtoChunk chunk = sampler.terrainChunk(cell.getX() >> 4, cell.getZ() >> 4);
|
||||
BlockState state = chunk.getBlockState(position.set(cell.getX() & (CHUNK_WIDTH - 1), cell.getY(), cell.getZ() & (CHUNK_WIDTH - 1)));
|
||||
assertTrue(state + " at " + cell, state.is(Blocks.LAVA));
|
||||
lava++;
|
||||
}
|
||||
}
|
||||
System.out.println(LOG_PREFIX + " deep cave cells open " + open + ", lava band cells " + lava);
|
||||
assertTrue("no deep cave cell opened", open > 0L);
|
||||
assertTrue("no lava band cell opened", lava > 0L);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void overworldMaterialPassComputesTheCaveFieldOnce() throws Exception {
|
||||
SortedMap<String, byte[]> files = overworldFiles();
|
||||
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(files, WorldgenExportMode.OVERWORLD, OVERWORLD_ID, SEED)) {
|
||||
CompiledCaches caches = CompiledCaches.open(sampler.registries(), SEED);
|
||||
int field = caches.rootId(caches.function(WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + CAVE_FIELD));
|
||||
List<OreVeinRule> veins = caches.oreVeins();
|
||||
DensityFunction opening = veins.getFirst().density();
|
||||
assertTrue(caches.ids(opening).contains(field));
|
||||
assertTrue(caches.ids(caches.finalDensity()).contains(field));
|
||||
Set<Integer> caveCaches = caches.ids(caches.function(WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + CAVE_FIELD));
|
||||
int shells = 0;
|
||||
for (OreVeinRule vein : veins) {
|
||||
if (!vein.oreBlock().isAir()) {
|
||||
assertTrue("wall shell " + shells + " " + vein.oreBlock(), caches.ids(vein.density()).contains(field));
|
||||
assertTrue("wall shell " + shells + " " + vein.oreBlock(), caveCaches.containsAll(caches.ids(vein.density())));
|
||||
shells++;
|
||||
}
|
||||
}
|
||||
JsonObject shell = JsonParser.parseString(new String(files.get("data/" + WorldgenExportRequest.DEFAULT_NAMESPACE + "/" + DENSITY_FUNCTION_RESOURCE
|
||||
+ "/" + MaterialProgramCompiler.WALL_SHELL + ".json"), StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
double band = shell.get("max_exclusive").getAsDouble();
|
||||
System.out.println(LOG_PREFIX + " material pass: " + veins.size() + " ore vein rules, " + shells + " wall shells read cave field cache " + field
|
||||
+ " within band " + band);
|
||||
assertTrue(String.valueOf(shells), shells > 0);
|
||||
assertTrue(String.valueOf(band), band > 0.0 && band < WALL_SHELL_BAND_LIMIT);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void overworldSubterrainRoomsPlace() throws Exception {
|
||||
SortedMap<String, byte[]> files = overworldFiles();
|
||||
SortedMap<String, JsonObject> structures = new TreeMap<>();
|
||||
for (Map.Entry<String, byte[]> file : files.entrySet()) {
|
||||
if (file.getKey().startsWith(SUBTERRAIN_STRUCTURES) && file.getKey().endsWith(".json")) {
|
||||
String path = file.getKey().substring(SUBTERRAIN_STRUCTURES.length(), file.getKey().length() - ".json".length());
|
||||
structures.put(WorldgenExportRequest.DEFAULT_NAMESPACE + ":subterrain/" + path,
|
||||
JsonParser.parseString(new String(file.getValue(), StandardCharsets.UTF_8)).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
long templateBytes = 0L;
|
||||
for (Map.Entry<String, byte[]> file : files.entrySet()) {
|
||||
if (file.getKey().startsWith(SUBTERRAIN_TEMPLATES)) {
|
||||
templateBytes += file.getValue().length;
|
||||
}
|
||||
}
|
||||
System.out.println(LOG_PREFIX + " subterrain structures " + structures.size() + ", structure sets "
|
||||
+ count(files, "/worldgen/structure_set/subterrain/", ".json") + ", templates " + count(files, "/structure/subterrain/", ".nbt")
|
||||
+ ", template bytes " + templateBytes);
|
||||
assertFalse(structures.isEmpty());
|
||||
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(files, WorldgenExportMode.OVERWORLD, OVERWORLD_ID, SEED)) {
|
||||
for (Map.Entry<String, JsonObject> entry : structures.entrySet()) {
|
||||
Structure structure = sampler.registries().lookupOrThrow(Registries.STRUCTURE)
|
||||
.getOrThrow(ResourceKey.create(Registries.STRUCTURE, Identifier.parse(entry.getKey()))).value();
|
||||
JsonObject height = entry.getValue().getAsJsonObject("start_height");
|
||||
int y = (height.getAsJsonObject("min_inclusive").get("absolute").getAsInt()
|
||||
+ height.getAsJsonObject("max_inclusive").get("absolute").getAsInt()) / 2;
|
||||
List<ChunkPos> matches = new ArrayList<>();
|
||||
for (int xIndex = 0; xIndex < ROOM_GRID; xIndex++) {
|
||||
for (int zIndex = 0; zIndex < ROOM_GRID; zIndex++) {
|
||||
ChunkPos chunk = new ChunkPos((xIndex - ROOM_GRID / 2) * ROOM_STEP_CHUNKS, (zIndex - ROOM_GRID / 2) * ROOM_STEP_CHUNKS);
|
||||
String biome = sampler.biome(chunk.getMinBlockX(), y, chunk.getMinBlockZ());
|
||||
Holder<Biome> holder = sampler.registries().lookupOrThrow(Registries.BIOME)
|
||||
.getOrThrow(ResourceKey.create(Registries.BIOME, Identifier.parse(biome)));
|
||||
if (structure.biomes().contains(holder)) {
|
||||
matches.add(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
System.out.println(String.format(Locale.ROOT, "%s subterrain %s biome match %d/%d at y %d", LOG_PREFIX, entry.getKey(),
|
||||
matches.size(), ROOM_GRID * ROOM_GRID, y));
|
||||
assertFalse(entry.getKey() + " finds no biome at y " + y, matches.isEmpty());
|
||||
StructureStart start = null;
|
||||
for (ChunkPos chunk : matches.subList(0, Math.min(ROOM_ATTEMPTS, matches.size()))) {
|
||||
StructureStart candidate = sampler.structureStart(entry.getKey(), chunk.x(), chunk.z());
|
||||
if (candidate.isValid()) {
|
||||
start = candidate;
|
||||
BoundingBox box = start.getBoundingBox();
|
||||
assertTrue(entry.getKey() + " " + box, chunk.getMinBlockX() - box.minX() <= ROOM_REACH && box.maxX() - chunk.getMinBlockX() <= ROOM_REACH);
|
||||
assertTrue(entry.getKey() + " " + box, chunk.getMinBlockZ() - box.minZ() <= ROOM_REACH && box.maxZ() - chunk.getMinBlockZ() <= ROOM_REACH);
|
||||
assertTrue(entry.getKey() + " " + box, box.minY() >= sampler.minY() && box.maxY() < sampler.minY() + sampler.height());
|
||||
break;
|
||||
}
|
||||
}
|
||||
assertTrue(entry.getKey() + " never starts", start != null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<BlockPos> openLavaBandCells(WorldgenExportSampler sampler, String field) {
|
||||
List<BlockPos> cells = new ArrayList<>(LAVA_CELLS);
|
||||
for (int x = -LAVA_SEARCH_RADIUS; x < LAVA_SEARCH_RADIUS; x += LAVA_SEARCH_STEP) {
|
||||
for (int z = -LAVA_SEARCH_RADIUS; z < LAVA_SEARCH_RADIUS; z += LAVA_SEARCH_STEP) {
|
||||
for (int y = sampler.minY(); y <= OVERWORLD_LAVA_Y; y++) {
|
||||
if (sampler.density(field, x, y, z) > -OPEN_MARGIN) {
|
||||
continue;
|
||||
}
|
||||
cells.add(new BlockPos(x, y, z));
|
||||
if (cells.size() == LAVA_CELLS) {
|
||||
return cells;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
private static synchronized SortedMap<String, byte[]> overworldFiles() throws Exception {
|
||||
String probePack = System.getProperty(PROBE_PACK);
|
||||
Assume.assumeTrue("Set -P" + PROBE_PACK + " to export a real pack", probePack != null && !probePack.isBlank());
|
||||
if (exportedOverworld != null) {
|
||||
return exportedOverworld;
|
||||
}
|
||||
Path root = overworldFolder.newFolder("exports").toPath();
|
||||
SortedMap<String, byte[]> untrimmed = new TreeMap<>();
|
||||
AtomicReference<ExportIr> compiled = new AtomicReference<>();
|
||||
WorldgenExportResult result;
|
||||
try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace(new RealPackProbeSupport.WorkspaceOptions(
|
||||
new File(probePack), OVERWORLD, LOG_PREFIX, new HeadlessNativeRuntime(), overworldFolder.newFolder("workspace").toPath()))) {
|
||||
WorldgenExportRequest request = WorldgenExportRequest.builder()
|
||||
.pack(workspace.pack().toPath())
|
||||
.dimension(OVERWORLD)
|
||||
.exportRoot(root)
|
||||
.objects(true)
|
||||
.build();
|
||||
result = new WorldgenDatapackExporter(target -> ir -> {
|
||||
compiled.set(ir);
|
||||
SortedMap<String, byte[]> written = WorldgenJsonWriter.forTarget(target).write(ir);
|
||||
untrimmed.putAll(written);
|
||||
return written;
|
||||
}, PackCompiler.create()).export(request, WorldgenExportProgress.NONE);
|
||||
}
|
||||
assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess());
|
||||
SortedMap<String, byte[]> files = zipEntries(result.output());
|
||||
Path untrimmedZip = root.resolve("untrimmed.zip");
|
||||
new AtomicDatapackPublisher().publish(untrimmed, untrimmedZip, DatapackExportFormat.ZIP, false, ".untrimmed-");
|
||||
report(result, untrimmed, Files.size(untrimmedZip), files);
|
||||
exportedOverworld = files;
|
||||
exportedOverworldResult = result;
|
||||
exportedOverworldIr = compiled.get();
|
||||
return files;
|
||||
}
|
||||
|
||||
private static void assertGenerationBudget(WorldgenExportResult result) {
|
||||
GenerationCost cost = result.cost();
|
||||
Set<String> flagged = new TreeSet<>();
|
||||
for (ExportDiagnostic diagnostic : result.diagnostics()) {
|
||||
if (diagnostic.limit() == ExportLimit.GENERATION_COST) {
|
||||
flagged.add(diagnostic.resource());
|
||||
}
|
||||
}
|
||||
for (GenerationCost.Root root : GenerationCost.Root.values()) {
|
||||
GenerationCost.RootCost rootCost = cost.root(root).orElseThrow();
|
||||
System.out.println(String.format(Locale.ROOT, "%s cost %s worst case %.3e expanded %.3e distinct %d", LOG_PREFIX, root.id(),
|
||||
rootCost.worstCaseNodes(), rootCost.expandedNodes(), rootCost.distinctNodes()));
|
||||
}
|
||||
for (GenerationCost.Root root : GenerationCost.Root.values()) {
|
||||
if (!root.climate()) {
|
||||
continue;
|
||||
}
|
||||
double limit = GenerationBudget.worstCaseLimit(root).orElseThrow();
|
||||
double nodes = cost.root(root).orElseThrow().worstCaseNodes();
|
||||
String resource = "noise_settings/" + OVERWORLD + "/" + root.id();
|
||||
assertTrue(root.id() + " evaluates " + nodes + " nodes, budget " + limit, nodes <= limit);
|
||||
assertFalse(resource + " " + flagged, flagged.contains(resource));
|
||||
}
|
||||
long finalDensity = cost.root(GenerationCost.Root.FINAL_DENSITY).orElseThrow().distinctNodes();
|
||||
String finalResource = "noise_settings/" + OVERWORLD + "/" + GenerationCost.Root.FINAL_DENSITY.id();
|
||||
System.out.println(String.format(Locale.ROOT, "%s final_density distinct %d of %d, density function JSON %.1f of %.1f MB", LOG_PREFIX, finalDensity,
|
||||
GenerationBudget.FINAL_DENSITY_DISTINCT_NODES, cost.densityFunctionBytes() / MEGABYTE, GenerationBudget.DENSITY_FUNCTION_JSON_BYTES / MEGABYTE));
|
||||
assertEquals(finalResource + " " + flagged, finalDensity > GenerationBudget.FINAL_DENSITY_DISTINCT_NODES, flagged.contains(finalResource));
|
||||
assertTrue(cost.densityFunctionBytes() + " bytes", cost.densityFunctionBytes() > 0L);
|
||||
assertTrue(cost.densityFunctionBytes() + " bytes", cost.densityFunctionBytes() <= GenerationBudget.DENSITY_FUNCTION_JSON_BYTES);
|
||||
assertFalse(flagged.toString(), flagged.contains(DENSITY_FUNCTION_RESOURCE));
|
||||
}
|
||||
|
||||
private static void assertWallPalettesFallBack(WorldgenExportResult result) {
|
||||
int fallbacks = 0;
|
||||
for (ExportDiagnostic diagnostic : result.diagnostics()) {
|
||||
if (diagnostic.limit() == ExportLimit.WALL_PALETTE && diagnostic.severity() == ExportDiagnostic.Severity.WARNING) {
|
||||
assertTrue(diagnostic.resource() + " " + diagnostic.message(), diagnostic.message().contains(WALL_FALLBACK));
|
||||
fallbacks++;
|
||||
}
|
||||
}
|
||||
System.out.println(LOG_PREFIX + " wall palettes over the ore vein budget " + fallbacks);
|
||||
}
|
||||
|
||||
private static void assertNaturalHeightMatchesTheEvaluator(ExportIr ir, WorldgenExportSampler sampler) {
|
||||
Field.Shared height = shared(ir.router().chunkSurfaceLevel(), HeightCompiler.HEIGHT);
|
||||
Map<String, NoiseDef> noises = new HashMap<>();
|
||||
for (NoiseDef noise : ir.noises()) {
|
||||
noises.put(ir.namespace() + ":" + noise.id(), noise);
|
||||
}
|
||||
String heightId = ir.namespace() + ":" + HeightCompiler.HEIGHT;
|
||||
int outliers = 0;
|
||||
StringBuilder detail = new StringBuilder();
|
||||
for (int xIndex = 0; xIndex < AGREEMENT_POINTS; xIndex++) {
|
||||
for (int zIndex = 0; zIndex < AGREEMENT_POINTS; zIndex++) {
|
||||
int x = (xIndex - AGREEMENT_POINTS / 2) * OVERWORLD_STEP;
|
||||
int z = (zIndex - AGREEMENT_POINTS / 2) * OVERWORLD_STEP;
|
||||
double evaluated = FieldEvaluator.sample(height.expr(), noises, SEED, DataVersion.V26_3, x, 0, z);
|
||||
double exported = sampler.density(heightId, x, 0, z);
|
||||
if (Math.abs(evaluated - exported) > AGREEMENT_TOLERANCE) {
|
||||
outliers++;
|
||||
detail.append(' ').append(x).append(',').append(z).append('=').append(evaluated).append('/').append(exported);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(outliers + " columns differ:" + detail, outliers <= AGREEMENT_OUTLIERS);
|
||||
}
|
||||
|
||||
private static Field.Shared shared(Field root, String name) {
|
||||
Set<Field> visited = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
Deque<Field> pending = new ArrayDeque<>();
|
||||
pending.push(root);
|
||||
while (!pending.isEmpty()) {
|
||||
Field field = pending.pop();
|
||||
if (!visited.add(field)) {
|
||||
continue;
|
||||
}
|
||||
if (field instanceof Field.Shared candidate && candidate.name().equals(name)) {
|
||||
return candidate;
|
||||
}
|
||||
for (Field child : FieldMacros.children(field)) {
|
||||
pending.push(child);
|
||||
}
|
||||
}
|
||||
throw new AssertionError("Router has no shared field " + name);
|
||||
}
|
||||
|
||||
private static void assertCompleteGzip(String path, byte[] bytes) throws IOException {
|
||||
try (InputStream input = new GZIPInputStream(new ByteArrayInputStream(bytes))) {
|
||||
input.readAllBytes();
|
||||
assertEquals(path, -1, input.read());
|
||||
}
|
||||
}
|
||||
|
||||
private static void report(WorldgenExportResult result, SortedMap<String, byte[]> untrimmed, long untrimmedZipBytes,
|
||||
SortedMap<String, byte[]> files) throws IOException {
|
||||
SortedMap<ExportLimit, Integer> limits = new TreeMap<>();
|
||||
for (ExportDiagnostic diagnostic : result.diagnostics()) {
|
||||
limits.merge(diagnostic.limit(), 1, Integer::sum);
|
||||
}
|
||||
System.out.println(LOG_PREFIX + " untrimmed zip bytes " + untrimmedZipBytes + ", files " + untrimmed.size()
|
||||
+ ", trimmed zip bytes " + Files.size(result.output()) + ", files " + files.size() + ", trim " + result.trim());
|
||||
System.out.println(LOG_PREFIX + " diagnostics " + result.diagnostics().size() + " " + limits);
|
||||
reportCounts("untrimmed", untrimmed);
|
||||
reportCounts("trimmed", files);
|
||||
}
|
||||
|
||||
private static void reportCounts(String label, SortedMap<String, byte[]> files) {
|
||||
System.out.println(LOG_PREFIX + " " + label + " templates " + count(files, "/structure/", ".nbt")
|
||||
+ ", structures " + count(files, "/worldgen/structure/", ".json")
|
||||
+ ", structure sets " + count(files, "/worldgen/structure_set/", ".json")
|
||||
+ ", placed features " + count(files, "/worldgen/placed_feature/", ".json")
|
||||
+ ", features " + count(files, "/worldgen/feature/", ".json")
|
||||
+ ", density functions " + count(files, "/worldgen/density_function/", ".json")
|
||||
+ ", noises " + count(files, "/worldgen/noise/", ".json")
|
||||
+ ", biomes " + count(files, "/worldgen/biome/", ".json"));
|
||||
}
|
||||
|
||||
private static int count(SortedMap<String, byte[]> files, String folder, String suffix) {
|
||||
int count = 0;
|
||||
for (String path : files.keySet()) {
|
||||
if (path.contains(folder) && path.endsWith(suffix)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static SortedMap<String, byte[]> zipEntries(Path zip) throws IOException {
|
||||
SortedMap<String, byte[]> files = new TreeMap<>();
|
||||
try (ZipFile archive = new ZipFile(zip.toFile())) {
|
||||
Enumeration<? extends ZipEntry> entries = archive.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
ZipEntry entry = entries.nextElement();
|
||||
if (!entry.isDirectory()) {
|
||||
files.put(entry.getName(), archive.getInputStream(entry).readAllBytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
private static Set<String> exportedBiomes(SortedMap<String, byte[]> files, String prefix, String namespace) {
|
||||
Set<String> biomes = new TreeSet<>();
|
||||
for (String path : files.keySet()) {
|
||||
if (path.startsWith(prefix)) {
|
||||
biomes.add(namespace + ":" + path.substring(prefix.length(), path.length() - ".json".length()));
|
||||
}
|
||||
}
|
||||
return biomes;
|
||||
}
|
||||
|
||||
private static final class MinMaxAudit implements DfRewriteRule {
|
||||
private final Set<DensityFunction> visited = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
private final List<String> disjoint = new ArrayList<>();
|
||||
private int checked;
|
||||
|
||||
@Override
|
||||
public DensityFunction rewrite(DensityFunction function) {
|
||||
if (!visited.add(function)) {
|
||||
return function;
|
||||
}
|
||||
if (function instanceof BinaryFunction binary && (binary.type() == BinaryFunction.Type.MIN || binary.type() == BinaryFunction.Type.MAX)) {
|
||||
checked++;
|
||||
Interval left = binary.left().range();
|
||||
Interval right = binary.right().range();
|
||||
if (left.max() < right.min() || right.max() < left.min()) {
|
||||
disjoint.add(binary.type() + " " + left + " " + right);
|
||||
}
|
||||
}
|
||||
function.rewriteChildren(this);
|
||||
return function;
|
||||
}
|
||||
|
||||
private int checked() {
|
||||
return checked;
|
||||
}
|
||||
|
||||
private List<String> disjoint() {
|
||||
return disjoint;
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.pack.datapack.export.ExportTestPacks;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportProgress;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportResult;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class WorldgenExportDeepCaves263Test {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
private static final long SEED = 1337L;
|
||||
private static final String NAMESPACE = WorldgenExportRequest.DEFAULT_NAMESPACE;
|
||||
private static final String DIMENSION_ID = NAMESPACE + ":" + ExportTestPacks.DIMENSION;
|
||||
private static final int MIN_Y = -64;
|
||||
private static final int LAVA_Y = -62;
|
||||
private static final int LAVA_LINE = -54;
|
||||
private static final int CAVE_TOP = -14;
|
||||
private static final int CHUNK_WIDTH = 16;
|
||||
private static final String CAVE_FIELD = "cave/field";
|
||||
private static final String DIMENSION = """
|
||||
{
|
||||
"regions": ["region"],
|
||||
"dimensionHeight": {"min": -64, "max": 192},
|
||||
"fluidHeight": 63,
|
||||
"caveLavaHeight": 2,
|
||||
"caveProfile": {
|
||||
"enabled": true,
|
||||
"verticalRange": {"min": 0, "max": 50},
|
||||
"verticalEdgeFade": 0,
|
||||
"baseDensityStyle": {"style": "SIMPLEX", "zoom": 0.6},
|
||||
"detailDensityStyle": {"style": "SIMPLEX", "zoom": 0.3},
|
||||
"baseWeight": 1.0,
|
||||
"detailWeight": 0.0,
|
||||
"densityThreshold": {"min": 5.0, "max": 5.0, "style": {"style": "SIMPLEX"}},
|
||||
"thresholdBias": 0.0,
|
||||
"surfaceClearance": 0,
|
||||
"allowSurfaceBreak": false,
|
||||
"allowFluid": false,
|
||||
"allowLava": true,
|
||||
"modules": []
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Rule
|
||||
public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform();
|
||||
|
||||
@Test
|
||||
public void deepCavesOpenAsAirAboveTheIrisLavaBand() throws IOException {
|
||||
WorldgenExportResult result = export();
|
||||
|
||||
assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess());
|
||||
JsonObject head = JsonParser.parseString(Files.readString(result.output().resolve("data/" + NAMESPACE + "/worldgen/material_rule/"
|
||||
+ ExportTestPacks.DIMENSION + ".json"), StandardCharsets.UTF_8)).getAsJsonObject().getAsJsonArray("sequence").get(0).getAsJsonObject();
|
||||
assertEquals("minecraft:ore_vein", head.get("type").getAsString());
|
||||
String opening = head.get("density").getAsString();
|
||||
assertTrue(opening, Files.isRegularFile(densityFunction(result.output(), opening)));
|
||||
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(result.output(), WorldgenExportMode.OVERWORLD, DIMENSION_ID, SEED)) {
|
||||
ProtoChunk chunk = sampler.terrainChunk(0, 0);
|
||||
for (int x = 0; x < CHUNK_WIDTH; x++) {
|
||||
for (int z = 0; z < CHUNK_WIDTH; z++) {
|
||||
for (int y = MIN_Y; y <= CAVE_TOP; y++) {
|
||||
BlockState state = chunk.getBlockState(new BlockPos(x, y, z));
|
||||
String where = state + " at " + x + "," + y + "," + z;
|
||||
if (y <= LAVA_Y) {
|
||||
assertTrue(where, state.is(Blocks.LAVA));
|
||||
} else {
|
||||
assertTrue(where, state.isAir());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(chunk.getBlockState(new BlockPos(0, LAVA_LINE - 1, 0)).isAir());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void deepCaveRuleSharesTheCaveFieldCacheWithTheFinalDensity() throws IOException {
|
||||
WorldgenExportResult result = export();
|
||||
assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess());
|
||||
|
||||
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(result.output(), WorldgenExportMode.OVERWORLD, DIMENSION_ID, SEED)) {
|
||||
CompiledCaches caches = CompiledCaches.open(sampler.registries(), SEED);
|
||||
DensityFunction opening = caches.oreVeins().getFirst().density();
|
||||
int field = caches.rootId(caches.function(NAMESPACE + ":" + CAVE_FIELD));
|
||||
Set<Integer> finalDensity = caches.ids(caches.finalDensity());
|
||||
|
||||
assertTrue(caches.ids(opening).toString(), caches.ids(opening).contains(field));
|
||||
assertTrue(finalDensity.toString(), finalDensity.contains(field));
|
||||
assertTrue(finalDensity.toString(), finalDensity.contains(caches.rootId(opening)));
|
||||
}
|
||||
}
|
||||
|
||||
private WorldgenExportResult export() throws IOException {
|
||||
File pack = ExportTestPacks.withDimension(temporaryFolder.newFolder("pack"), DIMENSION);
|
||||
WorldgenExportRequest request = WorldgenExportRequest.builder()
|
||||
.pack(pack.toPath())
|
||||
.dimension(ExportTestPacks.DIMENSION)
|
||||
.exportRoot(temporaryFolder.newFolder("exports").toPath())
|
||||
.format("directory")
|
||||
.build();
|
||||
return WorldgenDatapackExporter.create().export(request, WorldgenExportProgress.NONE);
|
||||
}
|
||||
|
||||
private static Path densityFunction(Path datapack, String id) {
|
||||
int separator = id.indexOf(':');
|
||||
return datapack.resolve("data/" + id.substring(0, separator) + "/worldgen/density_function/" + id.substring(separator + 1) + ".json");
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.pack.datapack.export.ExportTestPacks;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportProgress;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportResult;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class WorldgenExportPipeline263Test {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
private static final long SEED = 1337L;
|
||||
private static final String DIMENSION_ID = WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + ExportTestPacks.DIMENSION;
|
||||
private static final int[] SAMPLE_Y = {-64, 0, 64, 200};
|
||||
private static final int LATTICE = 8;
|
||||
private static final int LATTICE_STEP = 61;
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Rule
|
||||
public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform();
|
||||
|
||||
@Test
|
||||
public void exportedPackLoadsAndSamples() throws IOException {
|
||||
File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack"));
|
||||
Path root = temporaryFolder.newFolder("exports").toPath();
|
||||
WorldgenExportRequest request = WorldgenExportRequest.builder()
|
||||
.pack(pack.toPath())
|
||||
.dimension(ExportTestPacks.DIMENSION)
|
||||
.exportRoot(root)
|
||||
.format("directory")
|
||||
.build();
|
||||
|
||||
WorldgenExportResult result = WorldgenDatapackExporter.create().export(request, WorldgenExportProgress.NONE);
|
||||
|
||||
assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess());
|
||||
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(result.output(), WorldgenExportMode.OVERWORLD, DIMENSION_ID, SEED)) {
|
||||
assertFalse(sampler.densityFunctionIds().isEmpty());
|
||||
for (String id : sampler.densityFunctionIds()) {
|
||||
for (int y : SAMPLE_Y) {
|
||||
for (int xIndex = 0; xIndex < LATTICE; xIndex++) {
|
||||
for (int zIndex = 0; zIndex < LATTICE; zIndex++) {
|
||||
int x = (xIndex - LATTICE / 2) * LATTICE_STEP;
|
||||
int z = (zIndex - LATTICE / 2) * LATTICE_STEP;
|
||||
float value = sampler.density(id, x, y, z);
|
||||
assertTrue(id + " at " + x + "," + y + "," + z + " = " + value, Float.isFinite(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int xIndex = 0; xIndex < LATTICE; xIndex++) {
|
||||
for (int zIndex = 0; zIndex < LATTICE; zIndex++) {
|
||||
int x = (xIndex - LATTICE / 2) * LATTICE_STEP;
|
||||
int z = (zIndex - LATTICE / 2) * LATTICE_STEP;
|
||||
int height = sampler.baseHeight(x, z);
|
||||
assertTrue("height " + height + " at " + x + "," + z, height >= sampler.minY() && height <= sampler.minY() + sampler.height());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.pack.datapack.export.ExportTestPacks;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportProgress;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportResult;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class WorldgenExportSamplerThreadsTest {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
private static final long SEED = 1337L;
|
||||
private static final String DIMENSION_ID = WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + ExportTestPacks.DIMENSION;
|
||||
private static final String FINAL_DENSITY_ID = DIMENSION_ID + "/final_density";
|
||||
private static final int THREADS = 8;
|
||||
private static final int POINTS = 2048;
|
||||
private static final int ROW = 64;
|
||||
private static final int STEP = 37;
|
||||
private static final int ROUNDS = 3;
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Rule
|
||||
public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform();
|
||||
|
||||
@Test
|
||||
public void concurrentSamplingMatchesSequentialSampling() throws Exception {
|
||||
File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack"));
|
||||
WorldgenExportRequest request = WorldgenExportRequest.builder()
|
||||
.pack(pack.toPath())
|
||||
.dimension(ExportTestPacks.DIMENSION)
|
||||
.exportRoot(temporaryFolder.newFolder("exports").toPath())
|
||||
.format("directory")
|
||||
.build();
|
||||
WorldgenExportResult result = WorldgenDatapackExporter.create().export(request, WorldgenExportProgress.NONE);
|
||||
assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess());
|
||||
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(result.output(), WorldgenExportMode.OVERWORLD, DIMENSION_ID, SEED)) {
|
||||
assertTrue(sampler.densityFunctionIds().toString(), sampler.densityFunctionIds().contains(FINAL_DENSITY_ID));
|
||||
Sample expected = sweep(sampler, 0, 1).call();
|
||||
ExecutorService executor = Executors.newFixedThreadPool(THREADS);
|
||||
try {
|
||||
List<Future<Sample>> futures = new ArrayList<>(THREADS);
|
||||
for (int thread = 0; thread < THREADS; thread++) {
|
||||
futures.add(executor.submit(sweep(sampler, thread * (POINTS / THREADS), ROUNDS)));
|
||||
}
|
||||
for (Future<Sample> future : futures) {
|
||||
Sample sample = future.get();
|
||||
assertArrayEquals(expected.density(), sample.density(), 0.0F);
|
||||
assertEquals(List.of(expected.biome()), List.of(sample.biome()));
|
||||
}
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Callable<Sample> sweep(WorldgenExportSampler sampler, int offset, int rounds) {
|
||||
return () -> {
|
||||
float[] density = new float[POINTS];
|
||||
String[] biome = new String[POINTS];
|
||||
for (int round = 0; round < rounds; round++) {
|
||||
for (int step = 0; step < POINTS; step++) {
|
||||
int index = (offset + step) % POINTS;
|
||||
int x = (index % ROW - ROW / 2) * STEP;
|
||||
int y = (index * 7) % 256 - 64;
|
||||
int z = (index / ROW - POINTS / ROW / 2) * STEP;
|
||||
density[index] = sampler.density(FINAL_DENSITY_ID, x, y, z);
|
||||
biome[index] = sampler.biome(x, y, z);
|
||||
}
|
||||
}
|
||||
return new Sample(density, biome);
|
||||
};
|
||||
}
|
||||
|
||||
private record Sample(float[] density, String[] biome) {
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.pack.datapack.export.ExportTestPacks;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportProgress;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportResult;
|
||||
import art.arcane.iris.testsupport.CompatTestPlatform;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class WorldgenExportSeaLevel263Test {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
private static final long SEED = 1337L;
|
||||
private static final String DIMENSION_ID = WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + ExportTestPacks.DIMENSION;
|
||||
private static final int FLUID_HEIGHT = 40;
|
||||
private static final int SEA_FLOOR = 28;
|
||||
private static final int CHUNK_WIDTH = 16;
|
||||
private static final String DIMENSION = """
|
||||
{
|
||||
"regions": ["region"],
|
||||
"dimensionHeight": {"min": -64, "max": 192},
|
||||
"fluidHeight": 40,
|
||||
"landChance": 0,
|
||||
"carvingEnabled": false
|
||||
}
|
||||
""";
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Rule
|
||||
public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform();
|
||||
|
||||
@Test
|
||||
public void waterFillsThroughTheIrisFluidHeight() throws IOException {
|
||||
WorldgenExportResult result = export();
|
||||
|
||||
assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess());
|
||||
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(result.output(), WorldgenExportMode.OVERWORLD, DIMENSION_ID, SEED)) {
|
||||
ProtoChunk chunk = sampler.terrainChunk(0, 0);
|
||||
for (int x = 0; x < CHUNK_WIDTH; x++) {
|
||||
for (int z = 0; z < CHUNK_WIDTH; z++) {
|
||||
assertSolid(chunk.getBlockState(new BlockPos(x, SEA_FLOOR, z)), x, z);
|
||||
for (int y = SEA_FLOOR + 1; y <= FLUID_HEIGHT; y++) {
|
||||
BlockState state = chunk.getBlockState(new BlockPos(x, y, z));
|
||||
assertTrue(state + " at " + x + "," + y + "," + z, state.is(Blocks.WATER));
|
||||
}
|
||||
BlockState above = chunk.getBlockState(new BlockPos(x, FLUID_HEIGHT + 1, z));
|
||||
assertTrue(above + " above the fluid height at " + x + "," + z, above.isAir());
|
||||
}
|
||||
}
|
||||
assertEquals(FLUID_HEIGHT + 1, sampler.seaLevel());
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertSolid(BlockState state, int x, int z) {
|
||||
assertTrue(state + " sea floor at " + x + "," + z, !state.isAir() && state.getFluidState().isEmpty());
|
||||
}
|
||||
|
||||
private WorldgenExportResult export() throws IOException {
|
||||
File pack = ExportTestPacks.withDimension(temporaryFolder.newFolder("pack"), DIMENSION);
|
||||
CompatTestPlatform.write(pack, "generators/flat.json", "{\"composite\":[]}");
|
||||
CompatTestPlatform.write(pack, "biomes/ocean.json", """
|
||||
{"name":"Ocean","generators":[{"generator":"flat","min":-12,"max":-12}]}
|
||||
""");
|
||||
WorldgenExportRequest request = WorldgenExportRequest.builder()
|
||||
.pack(pack.toPath())
|
||||
.dimension(ExportTestPacks.DIMENSION)
|
||||
.exportRoot(temporaryFolder.newFolder("exports").toPath())
|
||||
.format("directory")
|
||||
.build();
|
||||
return WorldgenDatapackExporter.create().export(request, WorldgenExportProgress.NONE);
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.pack.datapack.export.ExportTestPacks;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportProgress;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportResult;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.NbtAccounter;
|
||||
import net.minecraft.nbt.NbtIo;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.Rotation;
|
||||
import net.minecraft.world.level.levelgen.GenerationStep;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureSet;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.ListPoolElement;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.JigsawStructure;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class WorldgenExportSubterrain263Test {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
private static final long SEED = 1337L;
|
||||
private static final String DIMENSION_ID = WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + ExportTestPacks.DIMENSION;
|
||||
private static final String POOLS = WorldgenExportRequest.DEFAULT_NAMESPACE + ":subterrain/pools";
|
||||
private static final String TUBES = WorldgenExportRequest.DEFAULT_NAMESPACE + ":subterrain/tubes";
|
||||
private static final String ANCHOR = WorldgenExportRequest.DEFAULT_NAMESPACE + ":subterrain/anchor";
|
||||
private static final String TEMPLATES = "data/" + WorldgenExportRequest.DEFAULT_NAMESPACE + "/structure/subterrain/";
|
||||
private static final int CHUNK_X = 3;
|
||||
private static final int CHUNK_Z = -2;
|
||||
private static final int POOLS_CENTER_Y = -49;
|
||||
private static final int POOLS_BELOW_CENTER = 10;
|
||||
private static final int POOLS_EXTENT = 10;
|
||||
private static final int MAX_REACH = 128;
|
||||
private static final String DIMENSION = """
|
||||
{"regions": ["region"], "subterrainFeatures": [
|
||||
{"id": "pools", "family": "CENOTE", "worldYRange": {"min": -60, "max": -40}, "spacing": 64, "probability": 1.0,
|
||||
"radius": 8, "height": 12, "fluid": "WATER", "fluidDepth": 2, "shapeWarp": 0.5, "pillarSpacing": 0,
|
||||
"formationFraction": 0.0, "solid": "minecraft:stone"},
|
||||
{"id": "tubes", "family": "LAVA_TUBE", "allowedRegions": ["region"], "worldYRange": {"min": -60, "max": 40},
|
||||
"spacing": 320, "probability": 0.5, "length": 300, "radius": 6, "height": 8, "fluidDepth": 2, "chimneyHeight": 0,
|
||||
"shapeWarp": 0.0, "pillarSpacing": 0, "formationFraction": 0.0, "solid": "minecraft:stone"}]}
|
||||
""";
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Rule
|
||||
public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform();
|
||||
|
||||
@Test
|
||||
public void subterrainRoomsLoadAndPlaceThroughRealCodecs() throws IOException {
|
||||
File pack = ExportTestPacks.withDimension(temporaryFolder.newFolder("pack"), DIMENSION);
|
||||
WorldgenExportRequest request = WorldgenExportRequest.builder()
|
||||
.pack(pack.toPath())
|
||||
.dimension(ExportTestPacks.DIMENSION)
|
||||
.exportRoot(temporaryFolder.newFolder("exports").toPath())
|
||||
.format("directory")
|
||||
.build();
|
||||
|
||||
WorldgenExportResult result = WorldgenDatapackExporter.create().export(request, WorldgenExportProgress.NONE);
|
||||
|
||||
assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess());
|
||||
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(result.output(), WorldgenExportMode.OVERWORLD, DIMENSION_ID, SEED)) {
|
||||
assertRegistered(sampler, POOLS, 4, 2);
|
||||
assertRegistered(sampler, TUBES, 20, 10);
|
||||
int cornerX = CHUNK_X << 4;
|
||||
int cornerZ = CHUNK_Z << 4;
|
||||
|
||||
BoundingBox pools = placedBox(sampler, POOLS);
|
||||
assertEquals(cornerX - POOLS_EXTENT, pools.minX());
|
||||
assertEquals(cornerX + POOLS_EXTENT, pools.maxX());
|
||||
assertEquals(cornerZ - POOLS_EXTENT, pools.minZ());
|
||||
assertEquals(cornerZ + POOLS_EXTENT, pools.maxZ());
|
||||
assertEquals(POOLS_CENTER_Y - POOLS_BELOW_CENTER, pools.minY());
|
||||
|
||||
BoundingBox tubes = placedBox(sampler, TUBES);
|
||||
assertEquals(2 * MAX_REACH + 1, Math.max(tubes.getXSpan(), tubes.getZSpan()));
|
||||
assertTrue(tubes.toString(), tubes.minX() >= cornerX - MAX_REACH && tubes.maxX() <= cornerX + MAX_REACH);
|
||||
assertTrue(tubes.toString(), tubes.minZ() >= cornerZ - MAX_REACH && tubes.maxZ() <= cornerZ + MAX_REACH);
|
||||
assertTrue(tubes.toString(), tubes.minY() >= -60 && tubes.maxY() <= 40);
|
||||
}
|
||||
assertTemplatesResolve(result.output());
|
||||
}
|
||||
|
||||
private static void assertRegistered(WorldgenExportSampler sampler, String id, int spacing, int separation) {
|
||||
Identifier identifier = Identifier.parse(id);
|
||||
Structure structure = sampler.registries().lookupOrThrow(Registries.STRUCTURE)
|
||||
.getOrThrow(ResourceKey.create(Registries.STRUCTURE, identifier)).value();
|
||||
assertTrue(id, structure instanceof JigsawStructure);
|
||||
assertEquals(id, GenerationStep.Decoration.UNDERGROUND_STRUCTURES, structure.step());
|
||||
StructureSet set = sampler.registries().lookupOrThrow(Registries.STRUCTURE_SET)
|
||||
.getOrThrow(ResourceKey.create(Registries.STRUCTURE_SET, identifier)).value();
|
||||
RandomSpreadStructurePlacement placement = (RandomSpreadStructurePlacement) set.placement();
|
||||
assertEquals(id, spacing, placement.spacing());
|
||||
assertEquals(id, separation, placement.separation());
|
||||
assertTrue(id, sampler.registries().lookupOrThrow(Registries.TEMPLATE_POOL).containsKey(identifier));
|
||||
}
|
||||
|
||||
private static BoundingBox placedBox(WorldgenExportSampler sampler, String id) throws IOException {
|
||||
StructureStart start = sampler.structureStart(id, CHUNK_X, CHUNK_Z);
|
||||
assertTrue(id, start.isValid());
|
||||
assertEquals(id, 1, start.getPieces().size());
|
||||
PoolElementStructurePiece piece = (PoolElementStructurePiece) start.getPieces().getFirst();
|
||||
assertTrue(id + " " + piece.getElement(), piece.getElement() instanceof ListPoolElement);
|
||||
return start.getBoundingBox();
|
||||
}
|
||||
|
||||
private static void assertTemplatesResolve(Path datapack) throws IOException {
|
||||
List<Path> templates;
|
||||
try (Stream<Path> paths = Files.walk(datapack.resolve(TEMPLATES))) {
|
||||
templates = paths.filter(path -> path.toString().endsWith(".nbt")).sorted().toList();
|
||||
}
|
||||
assertEquals(12, templates.size());
|
||||
boolean lava = false;
|
||||
for (Path path : templates) {
|
||||
CompoundTag tag;
|
||||
try (InputStream input = Files.newInputStream(path)) {
|
||||
tag = NbtIo.readCompressed(input, NbtAccounter.unlimitedHeap());
|
||||
}
|
||||
StructureTemplate template = new StructureTemplate();
|
||||
template.load(BuiltInRegistries.BLOCK, tag);
|
||||
StructurePlaceSettings settings = new StructurePlaceSettings();
|
||||
assertTrue(path.toString(), template.filterBlocks(BlockPos.ZERO, settings, Blocks.AIR).isEmpty());
|
||||
if (path.getFileName().toString().endsWith("_core.nbt")) {
|
||||
assertFalse(path.toString(), template.filterBlocks(BlockPos.ZERO, settings, Blocks.CAVE_AIR).isEmpty());
|
||||
List<StructureTemplate.JigsawBlockInfo> jigsaws = template.getJigsaws(BlockPos.ZERO, Rotation.NONE);
|
||||
assertEquals(path.toString(), 1, jigsaws.size());
|
||||
assertEquals(ANCHOR, jigsaws.getFirst().name().toString());
|
||||
lava |= !template.filterBlocks(BlockPos.ZERO, settings, Blocks.LAVA).isEmpty();
|
||||
} else {
|
||||
assertFalse(path.toString(), template.filterBlocks(BlockPos.ZERO, settings, Blocks.STONE).isEmpty());
|
||||
}
|
||||
}
|
||||
assertTrue("no lava tube template holds lava", lava);
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import art.arcane.iris.pack.datapack.DataVersion;
|
||||
import art.arcane.iris.pack.datapack.export.ExportIrFixtures;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
|
||||
import art.arcane.iris.pack.datapack.export.emit.WorldgenJsonWriter;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.SortedMap;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class WorldgenStageBenchTest {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
private static final long SEED = 1337L;
|
||||
private static final int SIDE = 2;
|
||||
private static final WorldgenStageBench.CallBudget BUDGET = new WorldgenStageBench.CallBudget(32, 8, 0L);
|
||||
|
||||
private static HeadlessNativeRegistries loaded;
|
||||
|
||||
@BeforeClass
|
||||
public static void loadFixture() throws IOException {
|
||||
HeadlessNativeBootstrap.initialize();
|
||||
SortedMap<String, byte[]> files = WorldgenJsonWriter.forTarget(DataVersion.V26_3)
|
||||
.write(ExportIrFixtures.flatNoise(DataVersion.V26_3, WorldgenExportMode.PRESET));
|
||||
Path directory = Files.createTempDirectory("iris-stage-bench-");
|
||||
for (Map.Entry<String, byte[]> file : files.entrySet()) {
|
||||
Path target = directory.resolve(file.getKey());
|
||||
Files.createDirectories(target.getParent());
|
||||
Files.write(target, file.getValue());
|
||||
}
|
||||
try (PaperRegistryScope scope = PaperRegistryScope.open()) {
|
||||
loaded = HeadlessNativeRegistries.load(directory);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void closeFixture() throws IOException {
|
||||
if (loaded != null) {
|
||||
loaded.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exportedFixtureRunsEveryStage() {
|
||||
assertStages(bench("export", ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.DIMENSION));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void vanillaOverworldRunsEveryStage() {
|
||||
assertStages(bench("vanilla", DatapackCostProbe.VANILLA_PRESET));
|
||||
}
|
||||
|
||||
private static void assertStages(WorldgenStageBench bench) {
|
||||
WorldgenStageBench.ChunkSamples samples = new WorldgenStageBench.ChunkSamples();
|
||||
bench.chunks(0, 0, SIDE, samples);
|
||||
bench.chunks(SIDE + 4, 0, SIDE, samples);
|
||||
WorldgenStageBench.ChunkTimings timings = samples.timings();
|
||||
assertEquals(2 * SIDE * SIDE, timings.chunks());
|
||||
assertEquals(2 * (SIDE + 2) * (SIDE + 2), timings.biomes().wall().count());
|
||||
assertEquals(2 * SIDE * SIDE, timings.noise().cpu().count());
|
||||
assertEquals(2 * SIDE * SIDE, timings.surface().wall().count());
|
||||
assertEquals(2 * SIDE * SIDE, timings.carverBiome().wall().count());
|
||||
assertEquals(0L, timings.biomeFallbacks());
|
||||
assertTrue(Double.isFinite(timings.totalMean()));
|
||||
assertTrue(Double.isFinite(timings.totalCpuMean()));
|
||||
assertCalls(samples(bench, Call.UNCACHED_BIOME));
|
||||
assertCalls(samples(bench, Call.BASE_HEIGHT));
|
||||
assertCalls(samples(bench, Call.BASE_COLUMN));
|
||||
}
|
||||
|
||||
private static void assertCalls(WorldgenStageBench.CallTiming timing) {
|
||||
assertEquals(2 * BUDGET.minimumCalls(), timing.calls());
|
||||
assertEquals(2 * BUDGET.minimumCalls(), timing.wall().count());
|
||||
assertTrue(Double.isFinite(timing.cpuMean()));
|
||||
}
|
||||
|
||||
private static WorldgenStageBench.CallTiming samples(WorldgenStageBench bench, Call call) {
|
||||
WorldgenStageBench.CallSamples samples = new WorldgenStageBench.CallSamples();
|
||||
for (int round = 0; round < 2; round++) {
|
||||
switch (call) {
|
||||
case UNCACHED_BIOME -> bench.uncachedBiome(BUDGET, samples);
|
||||
case BASE_HEIGHT -> bench.baseHeight(BUDGET, Heightmap.Types.WORLD_SURFACE_WG, samples);
|
||||
case BASE_COLUMN -> bench.baseColumn(BUDGET, samples);
|
||||
}
|
||||
}
|
||||
return samples.timing();
|
||||
}
|
||||
|
||||
private static WorldgenStageBench bench(String label, String preset) {
|
||||
return new WorldgenStageBench(new WorldgenStageBench.Options(label,
|
||||
DatapackCostProbe.presetOverworld(loaded.registries(), preset), loaded.registries(), SEED));
|
||||
}
|
||||
|
||||
private enum Call {
|
||||
UNCACHED_BIOME,
|
||||
BASE_HEIGHT,
|
||||
BASE_COLUMN
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package art.arcane.iris.probe.fidelity;
|
||||
|
||||
import art.arcane.iris.pack.datapack.DataVersion;
|
||||
import art.arcane.iris.pack.datapack.export.ExportIrFixtures;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
|
||||
import art.arcane.iris.pack.datapack.export.emit.WorldgenJsonWriter;
|
||||
import art.arcane.iris.pack.datapack.export.ir.AquiferDef;
|
||||
import art.arcane.iris.pack.datapack.export.ir.Axis;
|
||||
import art.arcane.iris.pack.datapack.export.ir.BiomeDef;
|
||||
import art.arcane.iris.pack.datapack.export.ir.ClimateLayout;
|
||||
import art.arcane.iris.pack.datapack.export.ir.DimensionDef;
|
||||
import art.arcane.iris.pack.datapack.export.ir.ExportIr;
|
||||
import art.arcane.iris.pack.datapack.export.ir.Field;
|
||||
import art.arcane.iris.pack.datapack.export.ir.MaterialProgram;
|
||||
import art.arcane.iris.pack.datapack.export.ir.RouterDef;
|
||||
import art.arcane.iris.probe.PaperRegistryIsolation;
|
||||
import art.arcane.iris.probe.WorldgenExportSampler;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.SortedMap;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class VanillaNoiseColumnSourceTest {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
private static final long SEED = 1337L;
|
||||
private static final String DIMENSION_ID = ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.DIMENSION;
|
||||
private static final String BIOME_ID = ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.BIOME;
|
||||
private static final String LOAD_KEY = "Flat Plains";
|
||||
private static final int LAND_HEIGHT = 80;
|
||||
private static final int SEA_FLOOR_HEIGHT = 40;
|
||||
private static final int MAX_Y = ExportIrFixtures.MIN_Y + ExportIrFixtures.HEIGHT;
|
||||
|
||||
@Test
|
||||
public void landColumnsReportTheTopSolidBlockAndStayDry() throws IOException {
|
||||
try (VanillaNoiseColumnSource source = new VanillaNoiseColumnSource(open(LAND_HEIGHT), Map.of(BIOME_ID, LOAD_KEY))) {
|
||||
assertEquals(ExportIrFixtures.MIN_Y, source.minY());
|
||||
assertEquals(MAX_Y, source.maxY());
|
||||
for (int x : new int[]{-37, 0, 113}) {
|
||||
FidelityColumn column = source.column(x, -x);
|
||||
assertEquals(new FidelityColumn(LOAD_KEY, LAND_HEIGHT - 1, false), column);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void seaColumnsReportTheFloorUnderWater() throws IOException {
|
||||
WorldgenExportSampler sampler = open(SEA_FLOOR_HEIGHT);
|
||||
try (VanillaNoiseColumnSource source = new VanillaNoiseColumnSource(sampler, Map.of(BIOME_ID, LOAD_KEY))) {
|
||||
FidelityStats stats = new FidelityStats(source.minY(), source.maxY());
|
||||
for (int x : new int[]{-37, 0, 113}) {
|
||||
FidelityColumn column = source.column(x, x);
|
||||
assertEquals(new FidelityColumn(LOAD_KEY, sampler.floorHeight(x, x) - 1, true), column);
|
||||
assertTrue(column.terrainY() + " under sea level", column.terrainY() < ExportIrFixtures.SEA_LEVEL - 1);
|
||||
assertEquals(ExportIrFixtures.SEA_LEVEL, sampler.baseHeight(x, x));
|
||||
stats.accept(column);
|
||||
}
|
||||
|
||||
assertEquals(0.0D, stats.landFraction(), 0.0D);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exportedBiomeWithoutALoadKeyIsRejected() throws IOException {
|
||||
try (VanillaNoiseColumnSource source = new VanillaNoiseColumnSource(open(LAND_HEIGHT), Map.of())) {
|
||||
IllegalStateException failure = assertThrows(IllegalStateException.class, () -> source.column(0, 0));
|
||||
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains(BIOME_ID));
|
||||
}
|
||||
}
|
||||
|
||||
private static WorldgenExportSampler open(int height) throws IOException {
|
||||
return WorldgenExportSampler.open(flatExport(height), WorldgenExportMode.PRESET, DIMENSION_ID, SEED);
|
||||
}
|
||||
|
||||
private static SortedMap<String, byte[]> flatExport(int height) {
|
||||
Field zero = new Field.Const(0.0);
|
||||
Field level = new Field.Const(height);
|
||||
Field y = new Field.Coord(Axis.Y, Field.Tiling.CLAMP, -4064.0, 4062.0, -4064.0, 4062.0);
|
||||
Field finalDensity = new Field.Grid(new Field.Binary(Field.BinaryOp.MUL, new Field.Const(0.125),
|
||||
new Field.Binary(Field.BinaryOp.SUB, level, y)), 4, 8);
|
||||
RouterDef router = new RouterDef(zero, zero, zero, zero, zero, zero, new Field.Grid(level, 16, 1), finalDensity, AquiferDef.dry(level));
|
||||
DimensionDef dimension = new DimensionDef(ExportIrFixtures.DIMENSION, ExportIrFixtures.MIN_Y, ExportIrFixtures.HEIGHT,
|
||||
ExportIrFixtures.HEIGHT, ExportIrFixtures.FLUID_HEIGHT, "minecraft:stone", "minecraft:water", ExportIrFixtures.dimensionType());
|
||||
ClimateLayout climate = new ClimateLayout(List.of(new ClimateLayout.ClimateEntry(BIOME_ID, ClimateLayout.ClimateAxis.TEMPERATURE, -2.0, 2.0)));
|
||||
ExportIr ir = new ExportIr(ExportIrFixtures.NAMESPACE, DataVersion.V26_3, WorldgenExportMode.PRESET, dimension, List.of(), router, climate,
|
||||
List.of(new BiomeDef(ExportIrFixtures.BIOME, ExportIrFixtures.biome())), MaterialProgram.rock("minecraft:stone"),
|
||||
List.of(), List.of(), List.of());
|
||||
return WorldgenJsonWriter.forTarget(DataVersion.V26_3).write(ir);
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
package art.arcane.iris.probe.fidelity;
|
||||
|
||||
import art.arcane.iris.pack.datapack.DataVersion;
|
||||
import art.arcane.iris.pack.datapack.export.ExportIrFixtures;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
|
||||
import art.arcane.iris.pack.datapack.export.emit.WorldgenJsonWriter;
|
||||
import art.arcane.iris.pack.datapack.export.ir.AquiferDef;
|
||||
import art.arcane.iris.pack.datapack.export.ir.Axis;
|
||||
import art.arcane.iris.pack.datapack.export.ir.BiomeDef;
|
||||
import art.arcane.iris.pack.datapack.export.ir.ClimateLayout;
|
||||
import art.arcane.iris.pack.datapack.export.ir.DimensionDef;
|
||||
import art.arcane.iris.pack.datapack.export.ir.ExportIr;
|
||||
import art.arcane.iris.pack.datapack.export.ir.Field;
|
||||
import art.arcane.iris.pack.datapack.export.ir.JsonResource;
|
||||
import art.arcane.iris.pack.datapack.export.ir.MaterialProgram;
|
||||
import art.arcane.iris.pack.datapack.export.ir.RouterDef;
|
||||
import art.arcane.iris.probe.PaperRegistryIsolation;
|
||||
import art.arcane.iris.probe.WorldgenExportSampler;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.SortedMap;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class VanillaNoiseFidelitySourceTest {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
private static final long SEED = 1337L;
|
||||
private static final String DIMENSION_ID = ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.DIMENSION;
|
||||
private static final String BIOME_ID = ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.BIOME;
|
||||
private static final String LOAD_KEY = "Flat Plains";
|
||||
private static final int LAND_HEIGHT = 80;
|
||||
private static final int MAX_Y = ExportIrFixtures.MIN_Y + ExportIrFixtures.HEIGHT;
|
||||
private static final String WALL_SHELL = ExportIrFixtures.NAMESPACE + ":cave/wall_shell";
|
||||
private static final int WALL_MIN_Y = 10;
|
||||
private static final int WALL_MAX_Y = 20;
|
||||
private static final int CARVED_HEIGHT = 120;
|
||||
private static final int VOID_MIN_Y = 96;
|
||||
private static final int CAVE_MIN_Y = 64;
|
||||
private static final Field BLOCK_Y = new Field.Coord(Axis.Y, Field.Tiling.CLAMP, -4064.0, 4062.0, -4064.0, 4062.0);
|
||||
|
||||
@Test
|
||||
public void flatExportReportsTheConstantSurface() throws IOException {
|
||||
WorldgenExportSampler sampler = open(LAND_HEIGHT);
|
||||
try (VanillaNoiseFidelitySource source = new VanillaNoiseFidelitySource(sampler, Map.of(BIOME_ID, LOAD_KEY), WALL_SHELL)) {
|
||||
assertEquals(ExportIrFixtures.MIN_Y, source.minY());
|
||||
assertEquals(MAX_Y, source.maxY());
|
||||
for (int x : new int[]{-37, 0, 113}) {
|
||||
assertEquals(LAND_HEIGHT, sampler.baseHeight(x, -x));
|
||||
}
|
||||
for (int[] chunkPosition : new int[][]{{0, 0}, {-3, 5}}) {
|
||||
FidelityChunk chunk = source.sample(chunkPosition[0], chunkPosition[1]);
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
assertEquals(LAND_HEIGHT - 1, chunk.surfaceY(x, z));
|
||||
assertEquals(LOAD_KEY, chunk.surfaceBiome(x, z));
|
||||
assertTrue(chunk.open(x, LAND_HEIGHT, z));
|
||||
assertNull(chunk.block(x, LAND_HEIGHT - 1, z));
|
||||
}
|
||||
}
|
||||
for (int y = ExportIrFixtures.MIN_Y; y < LAND_HEIGHT; y++) {
|
||||
assertFalse("y " + y, chunk.open(7, y, 9));
|
||||
assertFalse("y " + y, chunk.fluid(7, y, 9));
|
||||
}
|
||||
}
|
||||
|
||||
FidelityStats stats = FidelityScanner.scan(source, 0, 0, 1, 4, 2);
|
||||
|
||||
assertEquals(144L, stats.heightColumns());
|
||||
assertEquals(LAND_HEIGHT - 1, stats.heightQuantile(0.1D), 0.0D);
|
||||
assertEquals(LAND_HEIGHT - 1, stats.heightQuantile(0.9D), 0.0D);
|
||||
assertEquals(1.0D, stats.landFraction(), 0.0D);
|
||||
assertEquals(Map.of(LOAD_KEY, 144L), stats.biomeColumns());
|
||||
assertEquals(0L, stats.blockColumns());
|
||||
assertEquals(0L, stats.wallSolidVoxels());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void wallShellDensityMarksWallVoxels() throws IOException {
|
||||
WorldgenExportSampler sampler = WorldgenExportSampler.open(flatExport(LAND_HEIGHT, wallShell()), WorldgenExportMode.PRESET, DIMENSION_ID, SEED);
|
||||
try (VanillaNoiseFidelitySource source = new VanillaNoiseFidelitySource(sampler, Map.of(BIOME_ID, LOAD_KEY), WALL_SHELL)) {
|
||||
FidelityChunk chunk = source.sample(2, -1);
|
||||
|
||||
assertTrue(chunk.wallsKnown());
|
||||
assertTrue(chunk.wall(7, WALL_MIN_Y, 9));
|
||||
assertTrue(chunk.wall(7, WALL_MAX_Y - 1, 9));
|
||||
assertFalse(chunk.wall(7, WALL_MIN_Y - 1, 9));
|
||||
assertFalse(chunk.wall(7, WALL_MAX_Y, 9));
|
||||
|
||||
FidelityStats stats = FidelityScanner.scan(source, 0, 0, 1, 4, 2);
|
||||
|
||||
long interiorColumns = 9L * 9L;
|
||||
assertEquals(interiorColumns * (LAND_HEIGHT - 1 - ExportIrFixtures.MIN_Y), stats.wallSolidVoxels());
|
||||
assertEquals(interiorColumns * (WALL_MAX_Y - WALL_MIN_Y), stats.wallVoxels());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openSpaceOutsideTheTerrainOperandIsATerrainVoid() throws IOException {
|
||||
WorldgenExportSampler sampler = WorldgenExportSampler.open(carvedExport(), WorldgenExportMode.PRESET, DIMENSION_ID, SEED);
|
||||
try (VanillaNoiseFidelitySource source = new VanillaNoiseFidelitySource(sampler, Map.of(BIOME_ID, LOAD_KEY), WALL_SHELL)) {
|
||||
FidelityChunk chunk = source.sample(1, -2);
|
||||
|
||||
assertEquals(CARVED_HEIGHT - 1, chunk.surfaceY(7, 9));
|
||||
assertTrue(chunk.open(7, VOID_MIN_Y, 9));
|
||||
assertFalse(chunk.terrainMass(7, VOID_MIN_Y, 9));
|
||||
assertTrue(chunk.open(7, CAVE_MIN_Y + 3, 9));
|
||||
assertTrue(chunk.terrainMass(7, CAVE_MIN_Y + 3, 9));
|
||||
assertFalse(chunk.open(7, VOID_MIN_Y - 4, 9));
|
||||
assertTrue(chunk.terrainMass(7, VOID_MIN_Y - 4, 9));
|
||||
|
||||
FidelityStats stats = FidelityScanner.scan(source, 0, 0, 1, 4, 2);
|
||||
|
||||
int voidBand = (VOID_MIN_Y - ExportIrFixtures.MIN_Y) / 16;
|
||||
int caveBand = (CAVE_MIN_Y - ExportIrFixtures.MIN_Y) / 16;
|
||||
assertEquals(1.0D, stats.voidShare(voidBand), 0.0D);
|
||||
assertEquals(0.0D, stats.caveShare(voidBand), 0.0D);
|
||||
assertEquals(1.0D, stats.caveShare(caveBand), 0.0D);
|
||||
assertEquals(0.0D, stats.voidShare(caveBand), 0.0D);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exportWithoutAWallShellReportsNoWalls() throws IOException {
|
||||
try (VanillaNoiseFidelitySource source = new VanillaNoiseFidelitySource(open(LAND_HEIGHT), Map.of(BIOME_ID, LOAD_KEY), WALL_SHELL)) {
|
||||
assertFalse(source.sample(0, 0).wallsKnown());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exportedBiomeWithoutALoadKeyIsRejected() throws IOException {
|
||||
try (VanillaNoiseFidelitySource source = new VanillaNoiseFidelitySource(open(LAND_HEIGHT), Map.of(), WALL_SHELL)) {
|
||||
FidelityChunk chunk = source.sample(0, 0);
|
||||
|
||||
IllegalStateException failure = assertThrows(IllegalStateException.class, () -> chunk.surfaceBiome(0, 0));
|
||||
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains(BIOME_ID));
|
||||
}
|
||||
}
|
||||
|
||||
private static WorldgenExportSampler open(int height) throws IOException {
|
||||
return WorldgenExportSampler.open(flatExport(height), WorldgenExportMode.PRESET, DIMENSION_ID, SEED);
|
||||
}
|
||||
|
||||
private static JsonResource wallShell() {
|
||||
return new JsonResource("data/" + ExportIrFixtures.NAMESPACE + "/worldgen/density_function/cave/wall_shell.json", JsonParser.parseString(
|
||||
"{\"type\":\"minecraft:range_choice\",\"input\":{\"type\":\"minecraft:gradient\",\"axis\":\"y\",\"tiling\":\"clamp_to_edge\","
|
||||
+ "\"from_coordinate\":-4064,\"to_coordinate\":4062,\"from_value\":-4064.0,\"to_value\":4062.0},"
|
||||
+ "\"min_inclusive\":" + WALL_MIN_Y + ".0,\"max_exclusive\":" + WALL_MAX_Y + ".0,\"when_in_range\":1.0,\"when_out_of_range\":0.0}"));
|
||||
}
|
||||
|
||||
private static SortedMap<String, byte[]> flatExport(int height, JsonResource... resources) {
|
||||
Field level = new Field.Const(height);
|
||||
Field finalDensity = new Field.Grid(new Field.Binary(Field.BinaryOp.MUL, new Field.Const(0.125),
|
||||
new Field.Binary(Field.BinaryOp.SUB, level, BLOCK_Y)), 4, 8);
|
||||
return export(height, finalDensity, resources);
|
||||
}
|
||||
|
||||
private static SortedMap<String, byte[]> carvedExport() {
|
||||
Field ramp = new Field.Binary(Field.BinaryOp.MUL, new Field.Const(0.125), new Field.Binary(Field.BinaryOp.SUB, new Field.Const(CARVED_HEIGHT), BLOCK_Y));
|
||||
Field terrain = new Field.RangeIf(BLOCK_Y, VOID_MIN_Y, VOID_MIN_Y + 16, new Field.Const(-1.0), ramp);
|
||||
Field caves = new Field.RangeIf(BLOCK_Y, CAVE_MIN_Y, CAVE_MIN_Y + 16, new Field.Const(-1.0), new Field.Const(1.0));
|
||||
return export(CARVED_HEIGHT, new Field.Binary(Field.BinaryOp.MIN, terrain, caves));
|
||||
}
|
||||
|
||||
private static SortedMap<String, byte[]> export(int height, Field finalDensity, JsonResource... resources) {
|
||||
Field zero = new Field.Const(0.0);
|
||||
Field level = new Field.Const(height);
|
||||
RouterDef router = new RouterDef(zero, zero, zero, zero, zero, zero, new Field.Grid(level, 16, 1), finalDensity, AquiferDef.dry(level));
|
||||
DimensionDef dimension = new DimensionDef(ExportIrFixtures.DIMENSION, ExportIrFixtures.MIN_Y, ExportIrFixtures.HEIGHT,
|
||||
ExportIrFixtures.HEIGHT, ExportIrFixtures.FLUID_HEIGHT, "minecraft:stone", "minecraft:water", ExportIrFixtures.dimensionType());
|
||||
ClimateLayout climate = new ClimateLayout(List.of(new ClimateLayout.ClimateEntry(BIOME_ID, ClimateLayout.ClimateAxis.TEMPERATURE, -2.0, 2.0)));
|
||||
ExportIr ir = new ExportIr(ExportIrFixtures.NAMESPACE, DataVersion.V26_3, WorldgenExportMode.PRESET, dimension, List.of(), router, climate,
|
||||
List.of(new BiomeDef(ExportIrFixtures.BIOME, ExportIrFixtures.biome())), MaterialProgram.rock("minecraft:stone"),
|
||||
List.of(), List.of(resources), List.of());
|
||||
return WorldgenJsonWriter.forTarget(DataVersion.V26_3).write(ir);
|
||||
}
|
||||
}
|
||||
+19
-8
@@ -9,6 +9,7 @@ import art.arcane.iris.structure.nativegen.NativeStructureVolumeIndex;
|
||||
import art.arcane.iris.generation.runtime.IrisEngine;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume;
|
||||
import art.arcane.volmlib.nativelib.terrain.structure.StructureTerrainSettings;
|
||||
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import net.minecraft.SharedConstants;
|
||||
@@ -69,7 +70,8 @@ public class NativeStructureVolumeIndexTest {
|
||||
StructureStart start = swampHut(0, 0, 4, 6);
|
||||
BoundingBox bounds = start.getPieces().getFirst().getBoundingBox();
|
||||
|
||||
KList<NativeStructureVolume> volumes = NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, start);
|
||||
KList<NativeStructureVolume> volumes = NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, start,
|
||||
StructureTerrainSettings.SOURCE, (x, z) -> 63);
|
||||
|
||||
assertEquals(1, volumes.size());
|
||||
NativeStructureVolume volume = volumes.getFirst();
|
||||
@@ -84,8 +86,10 @@ public class NativeStructureVolumeIndexTest {
|
||||
|
||||
@Test
|
||||
public void invalidStartsContributeNoVolumes() {
|
||||
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, StructureStart.INVALID_START));
|
||||
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, null));
|
||||
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, StructureStart.INVALID_START,
|
||||
StructureTerrainSettings.SOURCE, (x, z) -> 63));
|
||||
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, null,
|
||||
StructureTerrainSettings.SOURCE, (x, z) -> 63));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -323,7 +327,7 @@ public class NativeStructureVolumeIndexTest {
|
||||
BlockingResolver coordination = new BlockingResolver();
|
||||
NativeStructureVolumeIndex.OriginResolver volumes = (engine, chunkX, chunkZ) ->
|
||||
new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ,
|
||||
-256, 0, -256, 256, 255, 256));
|
||||
-256, 0, -256, 256, 255, 256, null));
|
||||
NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting((engine, chunkX, chunkZ) -> {
|
||||
coordination.volumesAt(engine, chunkX, chunkZ);
|
||||
return volumes.volumesAt(engine, chunkX, chunkZ);
|
||||
@@ -369,7 +373,7 @@ public class NativeStructureVolumeIndexTest {
|
||||
terrain.join();
|
||||
}
|
||||
return new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ,
|
||||
-256, 0, -256, 256, 100, 256));
|
||||
-256, 0, -256, 256, 100, 256, null));
|
||||
});
|
||||
ExecutorService owner = Executors.newSingleThreadExecutor();
|
||||
ForkJoinPool workers = new ForkJoinPool(2);
|
||||
@@ -493,7 +497,13 @@ public class NativeStructureVolumeIndexTest {
|
||||
private static Map<?, ?> cache(NativeStructureVolumeIndex index, String name) throws Exception {
|
||||
Field field = NativeStructureVolumeIndex.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
return (Map<?, ?>) field.get(index);
|
||||
Object value = field.get(index);
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
return map;
|
||||
}
|
||||
Field entries = value.getClass().getDeclaredField("entries");
|
||||
entries.setAccessible(true);
|
||||
return (Map<?, ?>) entries.get(value);
|
||||
}
|
||||
|
||||
private static StructureStart swampHut(int chunkX, int chunkZ, int x, int z) {
|
||||
@@ -544,7 +554,8 @@ public class NativeStructureVolumeIndexTest {
|
||||
if (chunkX != originChunkX || chunkZ != originChunkZ) {
|
||||
return NativeStructureVolume.NONE;
|
||||
}
|
||||
return NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, swampHut(chunkX, chunkZ, 4, 6));
|
||||
return NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, swampHut(chunkX, chunkZ, 4, 6),
|
||||
StructureTerrainSettings.SOURCE, (x, z) -> 63);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,7 +607,7 @@ public class NativeStructureVolumeIndexTest {
|
||||
return new KList<>(new NativeStructureVolume(
|
||||
chunkX + ":" + chunkZ,
|
||||
0, 0, 0,
|
||||
15, 255, 15));
|
||||
15, 255, 15, null));
|
||||
}
|
||||
}
|
||||
private record ScopedEngine(IrisEngine engine, AtomicInteger scopes) {
|
||||
|
||||
@@ -14,7 +14,7 @@ dependencies {
|
||||
// log4j-core Filter interface, so its constructor reference needs the type resolvable.
|
||||
compileOnly(libs.log4j.core)
|
||||
testImplementation('junit:junit:4.13.2')
|
||||
testImplementation('org.mockito:mockito-core:5.23.0')
|
||||
testImplementation(libs.mockito.core)
|
||||
testImplementation(libs.paper.api)
|
||||
testImplementation(libs.bstats)
|
||||
testImplementation(project(':core'))
|
||||
@@ -28,6 +28,16 @@ dependencies {
|
||||
}
|
||||
}
|
||||
|
||||
String lwjglVersion = libs.versions.lwjgl.get()
|
||||
List<String> mavenLibraries = [
|
||||
"org.lwjgl:lwjgl:${lwjglVersion}",
|
||||
"org.lwjgl:lwjgl:jar:natives-linux:${lwjglVersion}",
|
||||
"org.lwjgl:lwjgl-opengl:${lwjglVersion}",
|
||||
"org.lwjgl:lwjgl-opengl:jar:natives-linux:${lwjglVersion}",
|
||||
"org.lwjgl:lwjgl-egl:${lwjglVersion}",
|
||||
"org.lwjgl:lwjgl-jawt:${lwjglVersion}",
|
||||
].collect { GString coordinate -> coordinate.toString() }
|
||||
|
||||
tasks.named('processResources').configure {
|
||||
def pluginProperties = [
|
||||
name : rootProject.name,
|
||||
@@ -35,6 +45,7 @@ tasks.named('processResources').configure {
|
||||
apiVersion: apiVersion,
|
||||
main : mainClass,
|
||||
bootstrapper: bootstrapperClass,
|
||||
libraries : mavenLibraries.collect { String coordinate -> "'${coordinate}'" }.join(', '),
|
||||
]
|
||||
inputs.properties(pluginProperties)
|
||||
filesMatching(['**/paper-plugin.yml', '**/plugin.yml']) {
|
||||
|
||||
@@ -57,9 +57,12 @@ import art.arcane.iris.localization.IrisLanguage;
|
||||
import art.arcane.volmlib.util.director.help.DirectorMiniMenu;
|
||||
import art.arcane.volmlib.util.localization.BukkitLanguageSwitcher;
|
||||
import art.arcane.iris.integration.MultiverseCoreLink;
|
||||
import art.arcane.iris.platform.agent.Agent;
|
||||
import art.arcane.iris.platform.agent.AttachSignal;
|
||||
import art.arcane.iris.platform.bukkit.nms.INMS;
|
||||
import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary;
|
||||
import art.arcane.iris.studio.view.BukkitGuiHost;
|
||||
import art.arcane.iris.studio.view.GuiHost;
|
||||
import art.arcane.iris.studio.view.PregeneratorJob;
|
||||
import art.arcane.iris.platform.bukkit.BoardSVC;
|
||||
import art.arcane.iris.command.CommandSVC;
|
||||
@@ -200,10 +203,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
private final AtomicBoolean postStopFinisherStarted = new AtomicBoolean(false);
|
||||
private final AtomicBoolean serverStopTeardownDeferred = new AtomicBoolean(false);
|
||||
private final AtomicBoolean servicesDisabled = new AtomicBoolean(false);
|
||||
private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false);
|
||||
private final AtomicBoolean sharedRuntimeClosed = new AtomicBoolean(false);
|
||||
private final AtomicBoolean startupBoundaryRestart = new AtomicBoolean(false);
|
||||
private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false);
|
||||
private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false);
|
||||
private volatile boolean generatorDrainCompleted;
|
||||
private volatile boolean enableFailed;
|
||||
private volatile PlaceholderRegistration papiRegistration;
|
||||
@@ -223,8 +226,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
private IrisUpdateNotifier updateNotifier;
|
||||
private BukkitDebugDump debugDump;
|
||||
private volatile SettingsHotloadWatch settingsHotloadWatch;
|
||||
private volatile Thread serverLifecycleThread;
|
||||
private volatile ServerShutdownBoundary serverShutdownBoundary;
|
||||
private boolean shutdownProtectionInitialized;
|
||||
|
||||
public static VolmitSender getSender() {
|
||||
VolmitSender current = sender;
|
||||
@@ -474,10 +477,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
String n = e.getClass().getCanonicalName() + "-" + e.getStackTrace()[0].getClassName() + "-" + e.getStackTrace()[0].getLineNumber();
|
||||
String n = e.getClass().getName();
|
||||
StackTraceElement[] trace = e.getStackTrace();
|
||||
if (trace.length > 0) {
|
||||
n += "-" + trace[0].getClassName() + "-" + trace[0].getLineNumber();
|
||||
}
|
||||
|
||||
if (e.getCause() != null) {
|
||||
n += "-" + e.getCause().getStackTrace()[0].getClassName() + "-" + e.getCause().getStackTrace()[0].getLineNumber();
|
||||
StackTraceElement[] causeTrace = e.getCause().getStackTrace();
|
||||
n += "-" + (causeTrace.length == 0 ? e.getCause().getClass().getName()
|
||||
: causeTrace[0].getClassName() + "-" + causeTrace[0].getLineNumber());
|
||||
}
|
||||
|
||||
File f = instance.getDataFile("debug", "caught-exceptions", n + ".txt");
|
||||
@@ -571,6 +580,17 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
this::getIrisVersion,
|
||||
this::getMCVersion));
|
||||
SlimJar.load();
|
||||
restoreAttachSignal();
|
||||
}
|
||||
|
||||
private static void restoreAttachSignal() {
|
||||
try {
|
||||
if (AttachSignal.restoreJvmHandler()) {
|
||||
info("Restored the JVM SIGQUIT handler that the server console replaced");
|
||||
}
|
||||
} catch (RuntimeException failure) {
|
||||
reportError("Could not restore the JVM SIGQUIT handler; the Iris agent may fail to attach.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static void bridgeLog(LogLevel level, String message) {
|
||||
@@ -627,10 +647,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
postStopFinisherStarted.set(false);
|
||||
serverStopTeardownDeferred.set(false);
|
||||
servicesDisabled.set(false);
|
||||
runtimeTeardownFailed.set(false);
|
||||
sharedRuntimeClosed.set(false);
|
||||
startupBoundaryRestart.set(false);
|
||||
terminalCleanupCompleted.set(false);
|
||||
runtimeTeardownFailed.set(false);
|
||||
generatorDrainCompleted = false;
|
||||
deferredShutdownGenerators.clear();
|
||||
MultiBurst.burst.reopen();
|
||||
@@ -832,7 +852,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
|
||||
public void addShutdownHook() {
|
||||
removeShutdownHook();
|
||||
serverLifecycleThread = Thread.currentThread();
|
||||
serverShutdownBoundary = INMS.isBound() ? INMS.get().createServerShutdownBoundary() : null;
|
||||
shutdownHook = new Thread(this::runShutdownHook, "Iris-ShutdownHook");
|
||||
try {
|
||||
@@ -912,6 +931,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
|
||||
public void onEnable() {
|
||||
enableFailed = false;
|
||||
shutdownProtectionInitialized = false;
|
||||
IrisStartupValidation.begin();
|
||||
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
|
||||
try {
|
||||
@@ -923,13 +943,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
refuseUnsupportedServer();
|
||||
return;
|
||||
}
|
||||
initializeShutdownProtection();
|
||||
enable();
|
||||
} catch (Throwable failure) {
|
||||
enableFailed = true;
|
||||
if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(),
|
||||
generatorResolver.refusals().startupWorldsCreated())) {
|
||||
throw failure;
|
||||
}
|
||||
enableFailed = true;
|
||||
return;
|
||||
}
|
||||
BukkitGuiHost.install();
|
||||
@@ -947,6 +968,17 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
reportLockedRuntime();
|
||||
}
|
||||
|
||||
private void initializeShutdownProtection() {
|
||||
if (!INMS.get().supportsIrisWorldGeneration()) {
|
||||
return;
|
||||
}
|
||||
if (!Agent.install()) {
|
||||
throw new IllegalStateException("Iris Java agent is unavailable; shutdown protection cannot be installed.");
|
||||
}
|
||||
INMS.get().initializeLifecycle(getClass().getClassLoader());
|
||||
shutdownProtectionInitialized = true;
|
||||
}
|
||||
|
||||
private void refuseUnsupportedServer() {
|
||||
Throwable bindFailure = INMS.bindFailure();
|
||||
IllegalStateException failure = new IllegalStateException("Iris cannot start: " + (bindFailure == null
|
||||
@@ -990,6 +1022,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
|
||||
public void onDisable() {
|
||||
pendingWorldReplacements.awaitStagingForShutdown();
|
||||
GuiHost.shutdown();
|
||||
preservePendingBlockDrops();
|
||||
papiLifecycleActive = false;
|
||||
if (updateNotifier != null) {
|
||||
@@ -1006,7 +1040,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
IrisLanguage.shutdown();
|
||||
teardownPapi();
|
||||
boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping());
|
||||
boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping())
|
||||
&& tryDeferPluginClassLoaderClose();
|
||||
boolean restartingAtStartupBoundary = startupBoundaryRestart.get();
|
||||
if (restartingAtStartupBoundary) {
|
||||
teardownRuntime("startup-boundary-restart", 30L);
|
||||
@@ -1038,7 +1073,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
preservePendingBlockDrops();
|
||||
papiLifecycleActive = false;
|
||||
teardownPapi();
|
||||
if (IrisToolbelt.isServerStopping()) {
|
||||
if (IrisToolbelt.isServerStopping() && tryDeferPluginClassLoaderClose()) {
|
||||
quiesceRuntimeForServerShutdown("pre-unload:" + reason);
|
||||
startPostStopFinisher();
|
||||
Iris.debug("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers.");
|
||||
@@ -1053,6 +1088,19 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
private boolean tryDeferPluginClassLoaderClose() {
|
||||
if (!shutdownProtectionInitialized) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
INMS.get().deferPluginClassLoaderClose();
|
||||
return true;
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
Iris.reportError("Failed to retain the Iris plugin class loader; draining shutdown inline.", failure);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drains the world generators exactly once. Serialized against the JVM shutdown hook so a
|
||||
* second caller cannot rip the pools or services out from under an in-flight drain.
|
||||
@@ -1099,19 +1147,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
try {
|
||||
MultiBurst.burst.close();
|
||||
} catch (Throwable failure) {
|
||||
runtimeTeardownFailed.set(true);
|
||||
Iris.reportError("Failed to close Iris generation workers.", failure);
|
||||
}
|
||||
try {
|
||||
MultiBurst.hydrology.close();
|
||||
} catch (Throwable failure) {
|
||||
runtimeTeardownFailed.set(true);
|
||||
Iris.reportError("Failed to close Iris hydrology workers.", failure);
|
||||
}
|
||||
try {
|
||||
MultiBurst.ioBurst.close();
|
||||
} catch (Throwable failure) {
|
||||
runtimeTeardownFailed.set(true);
|
||||
Iris.reportError("Failed to close Iris I/O workers.", failure);
|
||||
}
|
||||
clearQueues();
|
||||
@@ -1130,7 +1175,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
|
||||
private void quiesceRuntimeForServerShutdown(String reason) {
|
||||
serverStopTeardownDeferred.set(true);
|
||||
INMS.get().deferPluginClassLoaderClose();
|
||||
JigsawStudioService jigsawStudioService = IrisServices.getOrNull(JigsawStudioService.class);
|
||||
if (jigsawStudioService != null) {
|
||||
try {
|
||||
@@ -1164,7 +1208,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
if (generator == null) {
|
||||
continue;
|
||||
}
|
||||
IrisToolbelt.beginWorldMaintenance(world, reason, true);
|
||||
IrisToolbelt.beginWorldMaintenance(world, reason);
|
||||
if (!deferredShutdownGenerators.contains(generator)) {
|
||||
deferredShutdownGenerators.add(generator);
|
||||
}
|
||||
@@ -1176,52 +1220,32 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
if (!postStopFinisherStarted.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
Thread activeServerThread = serverLifecycleThread;
|
||||
if (activeServerThread == null) {
|
||||
Iris.warn("Iris could not start its post-stop runtime finisher because the server lifecycle thread is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
Thread finisher = new Thread(() -> {
|
||||
if (!awaitServerThreadTermination(activeServerThread)) {
|
||||
return;
|
||||
}
|
||||
finishDeferredRuntimeTeardown("post-server-stop", 30L);
|
||||
}, "Iris-PostStop-Finisher");
|
||||
Thread finisher = new Thread(() -> finishRuntimeAfterServerShutdown("post-server-stop"),
|
||||
"Iris-PostStop-Finisher");
|
||||
finisher.setDaemon(false);
|
||||
finisher.start();
|
||||
}
|
||||
|
||||
static boolean awaitServerThreadTermination(Thread serverThread) {
|
||||
if (serverThread == null || serverThread == Thread.currentThread()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
serverThread.join();
|
||||
return true;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
Iris.reportError("Iris post-stop runtime finisher was interrupted.", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void runShutdownHook() {
|
||||
if (startupBoundaryRestart.get()) {
|
||||
finishDeferredRuntimeTeardown("startup-boundary-restart-hook", 30L);
|
||||
return;
|
||||
}
|
||||
finishRuntimeAfterServerShutdown("shutdown-hook");
|
||||
}
|
||||
|
||||
private void finishRuntimeAfterServerShutdown(String reason) {
|
||||
if (!awaitServerShutdownBoundary()) {
|
||||
SHUTDOWN_ERRORS.println("[Iris] Paper did not reach its post-world-close boundary; retaining the runtime and plugin class loader until JVM exit.");
|
||||
SHUTDOWN_ERRORS.println("[Iris] The server did not reach its post-world-close boundary; retaining the runtime and plugin class loader until JVM exit.");
|
||||
return;
|
||||
}
|
||||
finishDeferredRuntimeTeardown("shutdown-hook", 30L);
|
||||
finishDeferredRuntimeTeardown(reason, 30L);
|
||||
}
|
||||
|
||||
private boolean awaitServerShutdownBoundary() {
|
||||
ServerShutdownBoundary boundary = serverShutdownBoundary;
|
||||
if (boundary == null) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return boundary.await(
|
||||
@@ -1248,30 +1272,37 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
if (!terminalCleanupCompleted.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
J.attempt(() -> INMS.get().uninjectBukkit());
|
||||
try {
|
||||
if (INMS.isBound()) {
|
||||
INMS.get().uninjectBukkit();
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Failed to remove Iris world-generation hooks.", failure);
|
||||
}
|
||||
try {
|
||||
runPostShutdown();
|
||||
} catch (Throwable e) {
|
||||
runtimeTeardownFailed.set(true);
|
||||
Iris.reportError("Failed to run Iris post-shutdown cleanup.", e);
|
||||
} finally {
|
||||
if (serverStopTeardownDeferred.get() && hasActiveShutdownResources()) {
|
||||
runtimeTeardownFailed.set(true);
|
||||
SHUTDOWN_ERRORS.println("[Iris] Owned background work remains active after cleanup; retaining the plugin class loader until JVM exit.");
|
||||
}
|
||||
boolean activeResources = serverStopTeardownDeferred.get()
|
||||
&& (runtimeTeardownFailed.get() || hasActiveShutdownResources());
|
||||
IrisPlatforms.unbind();
|
||||
if (serverStopTeardownDeferred.get() && runtimeTeardownFailed.get()) {
|
||||
SHUTDOWN_ERRORS.println("[Iris] Runtime cleanup failed; retaining the plugin class loader until JVM exit.");
|
||||
BukkitPlatform.releaseHost();
|
||||
if (activeResources) {
|
||||
SHUTDOWN_ERRORS.println("[Iris] Owned background work has not completed cleanup; retaining the plugin class loader until JVM exit.");
|
||||
} else {
|
||||
try {
|
||||
if (shutdownProtectionInitialized) {
|
||||
INMS.get().releasePluginClassLoaderClose();
|
||||
} else {
|
||||
SlimJar.closeNativeProviders();
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
SHUTDOWN_ERRORS.println("[Iris] Failed to release the plugin class loader after runtime cleanup.");
|
||||
failure.printStackTrace(SHUTDOWN_ERRORS);
|
||||
}
|
||||
}
|
||||
BukkitPlatform.releaseHost();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1508,13 +1539,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
public int getIrisVersion() {
|
||||
String input = getDescription().getVersion();
|
||||
int hyphenIndex = input.indexOf('-');
|
||||
if (hyphenIndex != -1) {
|
||||
String result = input.substring(0, hyphenIndex);
|
||||
result = result.replaceAll("\\.", "");
|
||||
return Integer.parseInt(result);
|
||||
}
|
||||
String release = hyphenIndex < 0 ? input : input.substring(0, hyphenIndex);
|
||||
if (!release.matches("[0-9]+(?:\\.[0-9]+)*")) {
|
||||
return -1;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(release.replace(".", ""));
|
||||
} catch (NumberFormatException invalid) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public int getMCVersion() {
|
||||
try {
|
||||
|
||||
@@ -6,12 +6,22 @@ import io.papermc.paper.plugin.bootstrap.PluginProviderContext;
|
||||
import io.papermc.paper.plugin.loader.PluginClasspathBuilder;
|
||||
import io.papermc.paper.plugin.loader.PluginLoader;
|
||||
import io.papermc.paper.plugin.loader.library.impl.JarLibrary;
|
||||
import io.papermc.paper.plugin.loader.library.impl.MavenLibraryResolver;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.eclipse.aether.artifact.DefaultArtifact;
|
||||
import org.eclipse.aether.graph.Dependency;
|
||||
import org.eclipse.aether.repository.RemoteRepository;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.Reader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
@SuppressWarnings("UnstableApiUsage")
|
||||
public final class IrisPluginLoader implements PluginLoader {
|
||||
@@ -40,6 +50,21 @@ public final class IrisPluginLoader implements PluginLoader {
|
||||
classpathBuilder.addLibrary(new JarLibrary(library));
|
||||
}
|
||||
}
|
||||
classpathBuilder.addLibrary(mavenResolver(mavenLibraries(context.getPluginSource())));
|
||||
}
|
||||
|
||||
static List<String> mavenLibraries(Path pluginJar) {
|
||||
try (ZipFile jar = new ZipFile(pluginJar.toFile())) {
|
||||
ZipEntry descriptor = jar.getEntry("plugin.yml");
|
||||
if (descriptor == null) {
|
||||
throw new IllegalStateException("The Iris jar " + pluginJar + " has no plugin.yml library list.");
|
||||
}
|
||||
try (Reader reader = new InputStreamReader(jar.getInputStream(descriptor), StandardCharsets.UTF_8)) {
|
||||
return YamlConfiguration.loadConfiguration(reader).getStringList("libraries");
|
||||
}
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Unable to read the Iris library list from " + pluginJar + ".", failure);
|
||||
}
|
||||
}
|
||||
|
||||
static List<Path> relocatedLibraries(Path libraryRoot) {
|
||||
@@ -59,6 +84,16 @@ public final class IrisPluginLoader implements PluginLoader {
|
||||
}
|
||||
}
|
||||
|
||||
private static MavenLibraryResolver mavenResolver(List<String> coordinates) {
|
||||
MavenLibraryResolver resolver = new MavenLibraryResolver();
|
||||
resolver.addRepository(new RemoteRepository.Builder("central", "default",
|
||||
MavenLibraryResolver.MAVEN_CENTRAL_DEFAULT_MIRROR).build());
|
||||
for (String coordinate : coordinates) {
|
||||
resolver.addDependency(new Dependency(new DefaultArtifact(coordinate), null));
|
||||
}
|
||||
return resolver;
|
||||
}
|
||||
|
||||
private static boolean isRelocatedLibrary(Path path) {
|
||||
int count = path.getNameCount();
|
||||
for (int i = 0; i < count - 2; i++) {
|
||||
|
||||
@@ -18,21 +18,41 @@
|
||||
|
||||
package art.arcane.iris.command;
|
||||
|
||||
import art.arcane.iris.pack.datapack.DatapackIngestService;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.command.handlers.DimensionHandler;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.localization.BukkitCommandMessages;
|
||||
import art.arcane.iris.localization.C;
|
||||
import art.arcane.iris.localization.DirectorCommandMessages;
|
||||
import art.arcane.iris.localization.IrisLanguage;
|
||||
import art.arcane.iris.pack.datapack.DataVersion;
|
||||
import art.arcane.iris.pack.datapack.DatapackIngestService;
|
||||
import art.arcane.iris.pack.datapack.export.DatapackExportDestinations;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportProgress;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportResult;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportService;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportServices;
|
||||
import art.arcane.iris.pack.datapack.export.WorldgenExportTargets;
|
||||
import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic;
|
||||
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.world.task.J;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.director.annotations.Director;
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
import art.arcane.volmlib.util.localization.LanguageAudience;
|
||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
import art.arcane.volmlib.util.localization.TextKey;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import art.arcane.iris.localization.IrisLanguage;
|
||||
import art.arcane.iris.localization.BukkitCommandMessages;
|
||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
@Director(name = "datapack", aliases = {"datapacks", "dp"}, description = "Download & manage external datapack imports", descriptionKey = "iris.director.commanddatapack.director.download_manage_external_datapack_imports_modrinth")
|
||||
public class CommandDatapack implements DirectorExecutor {
|
||||
private static final int RESOURCE_REPORTS = 4;
|
||||
|
||||
@Director(description = "Download/update every datapack listed in a pack dimension's 'datapackImports' and install it into the world so its structures register like vanilla", descriptionKey = "iris.director.commanddatapack.director.download_update_every_datapack_listed_pack_dimension_s_datapackimports_install_it_into", aliases = {"pull"})
|
||||
public void ingest(
|
||||
@Param(description = "Restart the server when new datapacks are installed (required for new structures to register and generate)", descriptionKey = "iris.director.commanddatapack.param.restart_server_when_new_datapacks_are_installed_required_new_structures_register_generate", defaultValue = "false")
|
||||
@@ -71,4 +91,128 @@ public class CommandDatapack implements DirectorExecutor {
|
||||
) {
|
||||
DatapackIngestService.remove(sender(), id);
|
||||
}
|
||||
|
||||
@Director(description = "Export a dimension as a vanilla worldgen datapack", descriptionKey = "iris.director.commanddatapack.director.export_dimension_vanilla_worldgen_datapack")
|
||||
public void export(
|
||||
@Param(name = "dimension", description = "The dimension to export", descriptionKey = "iris.director.commanddatapack.param.export_dimension", customHandler = DimensionHandler.class)
|
||||
IrisDimension dimension,
|
||||
@Param(name = "version", description = "Target Minecraft version, 26.3 or later (blank uses the latest)", descriptionKey = "iris.director.commanddatapack.param.export_version", defaultValue = "")
|
||||
String version,
|
||||
@Param(name = "mode", description = "Output mode: preset, dimension, or overworld", descriptionKey = "iris.director.commanddatapack.param.export_mode", defaultValue = WorldgenExportRequest.DEFAULT_MODE)
|
||||
String mode,
|
||||
@Param(name = "namespace", description = "Namespace for the exported resources", descriptionKey = "iris.director.commanddatapack.param.export_namespace", defaultValue = WorldgenExportRequest.DEFAULT_NAMESPACE)
|
||||
String namespace,
|
||||
@Param(name = "output", description = "Output name in the datapack exports folder (blank uses the dimension and version)", descriptionKey = "iris.director.commanddatapack.param.export_output", defaultValue = "")
|
||||
String output,
|
||||
@Param(name = "format", description = "zip or directory", descriptionKey = "iris.director.commanddatapack.param.export_format", defaultValue = WorldgenExportRequest.DEFAULT_FORMAT)
|
||||
String format,
|
||||
@Param(name = "objects", description = "Include objects as structure templates", descriptionKey = "iris.director.commanddatapack.param.export_objects", defaultValue = "true")
|
||||
boolean objects,
|
||||
@Param(name = "replace", description = "Replace an existing export with the same name", descriptionKey = "iris.director.commanddatapack.param.export_replace", defaultValue = "false")
|
||||
boolean replace
|
||||
) {
|
||||
VolmitSender sender = sender();
|
||||
WorldgenExportRequest request = WorldgenExportRequest.builder()
|
||||
.pack(dimension.getLoader().getDataFolder().toPath())
|
||||
.dimension(dimension.getLoadKey())
|
||||
.version(version)
|
||||
.mode(mode)
|
||||
.namespace(namespace)
|
||||
.output(output)
|
||||
.format(format)
|
||||
.objects(objects)
|
||||
.replace(replace)
|
||||
.exportRoot(IrisPlatforms.get().dataFolder("exports", "datapacks").toPath())
|
||||
.build();
|
||||
DataVersion target;
|
||||
try {
|
||||
target = validateExport(request);
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage(C.RED + IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_INVALID, MessageArgument.untrusted("reason", invalid.getMessage())));
|
||||
return;
|
||||
}
|
||||
WorldgenExportService service = WorldgenExportServices.get();
|
||||
sender.sendMessage(C.GRAY + IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_STARTED, MessageArgument.untrusted("dimension", request.dimension()), MessageArgument.untrusted("version", target.getVersion())));
|
||||
UUID audience = LanguageAudience.current();
|
||||
ExportFeedback feedback = new ExportFeedback(sender);
|
||||
J.a(() -> LanguageAudience.run(audience, () -> runExport(service, request, feedback)));
|
||||
}
|
||||
|
||||
static DataVersion validateExport(WorldgenExportRequest request) {
|
||||
DataVersion target = WorldgenExportTargets.resolve(request.version());
|
||||
WorldgenExportMode.parse(request.mode());
|
||||
DatapackExportDestinations.parseFormat(request.format());
|
||||
return target;
|
||||
}
|
||||
|
||||
private static void runExport(WorldgenExportService service, WorldgenExportRequest request, ExportFeedback feedback) {
|
||||
WorldgenExportResult result;
|
||||
try {
|
||||
result = service.export(request, feedback);
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Datapack export failed for '" + request.dimension() + "'.", failure);
|
||||
feedback.send(C.RED, IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_FAILED, MessageArgument.untrusted("reason", IrisLanguage.errorDetail(failure))));
|
||||
return;
|
||||
}
|
||||
reportExport(feedback, result);
|
||||
}
|
||||
|
||||
private static void reportExport(ExportFeedback feedback, WorldgenExportResult result) {
|
||||
for (ExportDiagnostic diagnostic : result.diagnostics()) {
|
||||
feedback.send(diagnosticColor(diagnostic.severity()), IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_DIAGNOSTIC, MessageArgument.untrusted("key", diagnosticLabel(diagnostic)), MessageArgument.untrusted("message", diagnostic.message())));
|
||||
}
|
||||
if (result.isSuccess()) {
|
||||
feedback.send(C.GREEN, IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_EXPORTED, MessageArgument.untrusted("path", result.output())));
|
||||
return;
|
||||
}
|
||||
TextKey outcome = result.status() == WorldgenExportResult.Status.REJECTED
|
||||
? DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_REJECTED
|
||||
: DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_FAILED;
|
||||
feedback.send(C.RED, IrisLanguage.text(outcome, MessageArgument.untrusted("reason", result.message())));
|
||||
}
|
||||
|
||||
private static C diagnosticColor(ExportDiagnostic.Severity severity) {
|
||||
return switch (severity) {
|
||||
case ERROR -> C.RED;
|
||||
case WARNING -> C.YELLOW;
|
||||
case INFO -> C.GRAY;
|
||||
};
|
||||
}
|
||||
|
||||
private static String diagnosticLabel(ExportDiagnostic diagnostic) {
|
||||
return diagnostic.resource().isEmpty()
|
||||
? diagnostic.limit().name()
|
||||
: diagnostic.limit().name() + " " + diagnostic.resource();
|
||||
}
|
||||
|
||||
private static final class ExportFeedback implements WorldgenExportProgress {
|
||||
private final VolmitSender sender;
|
||||
|
||||
private ExportFeedback(VolmitSender sender) {
|
||||
this.sender = sender;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stage(String stage) {
|
||||
send(C.GRAY, IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_STAGE, MessageArgument.untrusted("stage", stage)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resources(int done, int total) {
|
||||
int stride = Math.max(1, total / RESOURCE_REPORTS);
|
||||
if (done != total && done % stride != 0) {
|
||||
return;
|
||||
}
|
||||
send(C.GRAY, IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_RESOURCES, MessageArgument.untrusted("done", done), MessageArgument.untrusted("total", total)));
|
||||
}
|
||||
|
||||
private void send(C color, String message) {
|
||||
String line = color + message;
|
||||
if (sender.isPlayer()) {
|
||||
J.runEntity(sender.player(), () -> sender.sendMessage(line));
|
||||
return;
|
||||
}
|
||||
sender.sendMessage(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,7 @@ import art.arcane.iris.structure.placement.IrisStructureLocator;
|
||||
import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy;
|
||||
import art.arcane.iris.structure.placement.StructureReachability;
|
||||
import art.arcane.iris.world.history.GenerationSemanticQueries;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyFeatureQuery;
|
||||
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
|
||||
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.structure.nativegen.IrisNativeStructureDecision;
|
||||
import art.arcane.iris.generation.terrain.IrisRegion;
|
||||
@@ -196,9 +195,9 @@ public class CommandFind implements DirectorExecutor {
|
||||
EngineBukkitOps.gotoPOI(e, type, player(), teleport);
|
||||
}
|
||||
|
||||
@Director(description = "Find an accepted hydrology feature", sync = true)
|
||||
@Director(description = "Find a hydrology water feature", sync = true)
|
||||
public void river(
|
||||
@Param(description = "Feature type: surface, waterfall, underground, grotto, mouth, or a deep-fluid id", customHandler = HydrologyTypeHandler.class)
|
||||
@Param(description = "Feature type: stream, river, major_river, cascade, waterfall, mouth, pond, lake, dam, sea_inlet or cave_pool", customHandler = HydrologyTypeHandler.class)
|
||||
String type,
|
||||
@Param(description = "Should you be teleported", defaultValue = "true")
|
||||
boolean teleport
|
||||
@@ -212,42 +211,32 @@ public class CommandFind implements DirectorExecutor {
|
||||
}
|
||||
return;
|
||||
}
|
||||
IrisHydrologyRuntime runtime = activeEngine.getComplex().getHydrologyRuntime();
|
||||
HydrologyFeatureQuery query;
|
||||
HydrologyFeatureType featureType;
|
||||
try {
|
||||
query = HydrologyFeatureQuery.parse(type);
|
||||
featureType = HydrologyFeatureType.parse(type);
|
||||
} catch (IllegalArgumentException error) {
|
||||
commandSender.sendMessage(C.RED + error.getMessage());
|
||||
return;
|
||||
}
|
||||
String label = featureType.key().replace('_', ' ');
|
||||
Location origin = target.getLocation();
|
||||
World originWorld = origin.getWorld();
|
||||
int requestedDistance = runtime == null
|
||||
? 8192
|
||||
: Math.min(8192, runtime.settings().routing().tileSize() * 15);
|
||||
int maximumDistance = runtime == null
|
||||
? requestedDistance
|
||||
: runtime.maximumFeatureSearchDistance(
|
||||
origin.getBlockX(), origin.getBlockZ(), requestedDistance);
|
||||
commandSender.sendMessage(C.GRAY + "Searching accepted hydrology plans for " + type + "...");
|
||||
int maximumDistance = GenerationSemanticQueries.RIVER_SEARCH_DISTANCE;
|
||||
commandSender.sendMessage(C.GRAY + GenerationSemanticQueries.riverSearchMessage(featureType, maximumDistance));
|
||||
J.a(() -> {
|
||||
try {
|
||||
GenerationSemanticQueries.RiverResult feature = GenerationSemanticQueries.nearestRiver(
|
||||
activeEngine,
|
||||
query,
|
||||
featureType,
|
||||
origin.getBlockX(),
|
||||
origin.getBlockZ(),
|
||||
maximumDistance,
|
||||
(int visited) -> sendStructureMessage(target, commandSender,
|
||||
C.GRAY + "Searched " + visited + " hydrology tiles for " + type + "...")
|
||||
maximumDistance
|
||||
).orElse(null);
|
||||
if (feature == null) {
|
||||
sendStructureMessage(target, commandSender,
|
||||
C.YELLOW + "No accepted " + type + " hydrology feature found within "
|
||||
+ maximumDistance + " blocks.");
|
||||
C.YELLOW + GenerationSemanticQueries.riverNotFoundMessage(featureType, maximumDistance));
|
||||
return;
|
||||
}
|
||||
String label = type + " hydrology feature";
|
||||
if (!teleport) {
|
||||
sendStructureMessage(target, commandSender,
|
||||
C.GREEN + "Found " + label + " at " + feature.x() + ", " + feature.y() + ", " + feature.z() + ".");
|
||||
@@ -259,8 +248,9 @@ public class CommandFind implements DirectorExecutor {
|
||||
target, originWorld, commandSender, label, destination, true);
|
||||
} catch (Throwable error) {
|
||||
sendStructureMessage(target, commandSender,
|
||||
C.RED + "Could not locate " + type + " hydrology: " + error.getClass().getSimpleName());
|
||||
Iris.reportError("Could not locate accepted hydrology feature '" + type + "'.", error);
|
||||
C.RED + "Could not search for " + label + ": " + error.getClass().getSimpleName()
|
||||
+ (error.getMessage() == null ? "" : " - " + error.getMessage()));
|
||||
Iris.reportError("Could not locate hydrology feature '" + featureType.key() + "'.", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,8 +21,10 @@ import art.arcane.iris.structure.StructureImporter;
|
||||
import art.arcane.iris.structure.VillageImporter;
|
||||
import art.arcane.iris.structure.authoring.StructureKey;
|
||||
import art.arcane.iris.structure.authoring.StructureWriteResult;
|
||||
import art.arcane.iris.structure.export.VanillaJigsawExportFormat;
|
||||
import art.arcane.iris.pack.datapack.export.DatapackExportDestinations;
|
||||
import art.arcane.iris.pack.datapack.export.DatapackExportFormat;
|
||||
import art.arcane.iris.structure.export.VanillaJigsawExportRequest;
|
||||
import art.arcane.iris.structure.export.VanillaJigsawExportSettings;
|
||||
import art.arcane.iris.structure.export.VanillaJigsawExportSource;
|
||||
import art.arcane.iris.structure.graph.PlanarJigsawWorkcellResolver;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
@@ -419,9 +421,9 @@ public class CommandJigsaw implements DirectorExecutor {
|
||||
sendError("Wait for the pending autosave or discard the edits before exporting the on-disk graph.");
|
||||
return;
|
||||
}
|
||||
VanillaJigsawExportFormat exportFormat;
|
||||
DatapackExportFormat exportFormat;
|
||||
try {
|
||||
exportFormat = CommandJigsawExports.parseFormat(format);
|
||||
exportFormat = DatapackExportDestinations.parseFormat(format);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
sendError(exception.getMessage());
|
||||
return;
|
||||
@@ -430,20 +432,21 @@ public class CommandJigsaw implements DirectorExecutor {
|
||||
.toPath().toAbsolutePath().normalize();
|
||||
Path destination;
|
||||
try {
|
||||
destination = CommandJigsawExports.resolveExportDestination(exportRoot, output, exportFormat);
|
||||
destination = DatapackExportDestinations.resolve(exportRoot, output, exportFormat);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
sendError(exception.getMessage());
|
||||
return;
|
||||
}
|
||||
VanillaJigsawExportRequest request;
|
||||
try {
|
||||
request = VanillaJigsawExportRequest.builder(
|
||||
VanillaJigsawExportSource.forData(
|
||||
context.request().source(), context.request().structureKey()), destination)
|
||||
VanillaJigsawExportSource source = VanillaJigsawExportSource.forData(
|
||||
context.request().source(), context.request().structureKey());
|
||||
request = VanillaJigsawExportRequest.builder(source, destination)
|
||||
.namespace(namespace)
|
||||
.resourcePath(context.request().structureKey())
|
||||
.format(exportFormat)
|
||||
.replaceExisting(replace)
|
||||
.settings(VanillaJigsawExportSettings.runtimeDefaults(source.loadStructure()))
|
||||
.build();
|
||||
} catch (IllegalArgumentException exception) {
|
||||
sendError(exception.getMessage());
|
||||
|
||||
+4
-37
@@ -4,7 +4,7 @@ import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.studio.jigsaw.JigsawStudioService;
|
||||
import art.arcane.iris.structure.export.VanillaJigsawDatapackExporter;
|
||||
import art.arcane.iris.structure.export.VanillaJigsawExportDiagnostic;
|
||||
import art.arcane.iris.structure.export.VanillaJigsawExportFormat;
|
||||
import art.arcane.iris.pack.datapack.export.DatapackExportDestinations;
|
||||
import art.arcane.iris.structure.export.VanillaJigsawExportRequest;
|
||||
import art.arcane.iris.structure.export.VanillaJigsawExportResult;
|
||||
import art.arcane.iris.localization.C;
|
||||
@@ -13,18 +13,14 @@ import art.arcane.iris.world.task.J;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
final class CommandJigsawExports {
|
||||
private static final Pattern EXPORT_OUTPUT_NAME = Pattern.compile("[a-zA-Z0-9_-][a-zA-Z0-9._-]{0,127}");
|
||||
private static final Set<UUID> EXPORTING_PLAYERS = ConcurrentHashMap.newKeySet();
|
||||
private static final Set<Path> EXPORT_DESTINATIONS = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private CommandJigsawExports() {
|
||||
}
|
||||
@@ -45,11 +41,11 @@ final class CommandJigsawExports {
|
||||
|
||||
static boolean beginExport(UUID playerId, Path destination) {
|
||||
Objects.requireNonNull(playerId);
|
||||
Path normalizedDestination = Objects.requireNonNull(destination).toAbsolutePath().normalize();
|
||||
Objects.requireNonNull(destination);
|
||||
if (!EXPORTING_PLAYERS.add(playerId)) {
|
||||
return false;
|
||||
}
|
||||
if (EXPORT_DESTINATIONS.add(normalizedDestination)) {
|
||||
if (DatapackExportDestinations.claim(destination)) {
|
||||
return true;
|
||||
}
|
||||
EXPORTING_PLAYERS.remove(playerId);
|
||||
@@ -58,7 +54,7 @@ final class CommandJigsawExports {
|
||||
|
||||
static void finishExport(UUID playerId, Path destination) {
|
||||
EXPORTING_PLAYERS.remove(Objects.requireNonNull(playerId));
|
||||
EXPORT_DESTINATIONS.remove(Objects.requireNonNull(destination).toAbsolutePath().normalize());
|
||||
DatapackExportDestinations.release(destination);
|
||||
}
|
||||
|
||||
static void runExport(ExportOperation operation) {
|
||||
@@ -102,35 +98,6 @@ final class CommandJigsawExports {
|
||||
+ result.status() + ": " + result.output());
|
||||
}
|
||||
|
||||
static VanillaJigsawExportFormat parseFormat(String value) {
|
||||
return switch (value.trim().toLowerCase(Locale.ROOT)) {
|
||||
case "zip" -> VanillaJigsawExportFormat.ZIP;
|
||||
case "directory", "dir", "folder" -> VanillaJigsawExportFormat.DIRECTORY;
|
||||
default -> throw new IllegalArgumentException("Export format must be directory or zip.");
|
||||
};
|
||||
}
|
||||
|
||||
static Path resolveExportDestination(
|
||||
Path exportRoot,
|
||||
String output,
|
||||
VanillaJigsawExportFormat format
|
||||
) {
|
||||
Path root = Objects.requireNonNull(exportRoot, "Jigsaw export root").toAbsolutePath().normalize();
|
||||
String requested = Objects.requireNonNull(output, "Jigsaw export output name");
|
||||
String name = requested.trim();
|
||||
if (!name.equals(requested) || !EXPORT_OUTPUT_NAME.matcher(name).matches()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Export output must be one file name using letters, numbers, '.', '_', or '-'.");
|
||||
}
|
||||
String fileName = format == VanillaJigsawExportFormat.ZIP && !name.endsWith(".zip")
|
||||
? name + ".zip" : name;
|
||||
Path destination = root.resolve(fileName).normalize();
|
||||
if (destination.equals(root) || !root.equals(destination.getParent())) {
|
||||
throw new IllegalArgumentException("Export output must be one artifact inside the Studio exports folder.");
|
||||
}
|
||||
return destination;
|
||||
}
|
||||
|
||||
record ExportOperation(
|
||||
Player player,
|
||||
VolmitSender sender,
|
||||
|
||||
@@ -21,10 +21,9 @@ package art.arcane.iris.command;
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.studio.map.MapWindow;
|
||||
import art.arcane.iris.studio.map.PaletteExplorerStart;
|
||||
import art.arcane.iris.studio.view.GuiHost;
|
||||
import art.arcane.iris.studio.view.ImageMapStudioGUI;
|
||||
import art.arcane.iris.studio.view.NoiseExplorerGUI;
|
||||
import art.arcane.iris.studio.view.VisionGUI;
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.studio.workspace.IrisProject;
|
||||
import art.arcane.iris.studio.workspace.IrisCodeWorkspace;
|
||||
@@ -81,7 +80,6 @@ import org.bukkit.FluidCollisionMode;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
@@ -99,11 +97,14 @@ import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import art.arcane.iris.localization.IrisLanguage;
|
||||
import art.arcane.iris.localization.BukkitCommandMessages;
|
||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
import art.arcane.volmlib.util.localization.LanguageAudience;
|
||||
import art.arcane.iris.localization.BukkitCommandMessagesExtended;
|
||||
@Director(name = "studio", aliases = {"std", "s"}, description = "Studio Commands", descriptionKey = "iris.director.commandstudio.director.studio_commands")
|
||||
public class CommandStudio implements DirectorExecutor {
|
||||
@@ -203,6 +204,22 @@ public class CommandStudio implements DirectorExecutor {
|
||||
}));
|
||||
}
|
||||
|
||||
@Director(description = "Hotload the active Studio pack", descriptionKey = "iris.director.commandstudio.director.hotload_active_studio_pack", aliases = {"refresh"}, origin = DirectorOrigin.BOTH)
|
||||
public void hotload() {
|
||||
VolmitSender commandSender = sender();
|
||||
IrisProject project = Iris.service(StudioSVC.class).getActiveProject();
|
||||
PlatformChunkGenerator generator = project == null ? null : project.getActiveProvider();
|
||||
if (generator == null) {
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_NO_OPEN_STUDIO_PROJECTS));
|
||||
return;
|
||||
}
|
||||
|
||||
UUID audience = LanguageAudience.current();
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_HOTLOADING));
|
||||
generator.hotloadAsync().whenComplete((ignored, failure) ->
|
||||
LanguageAudience.run(audience, () -> sendHotloadResult(commandSender, failure)));
|
||||
}
|
||||
|
||||
@Director(description = "Toggle your Studio debug scoreboard", descriptionKey = "iris.director.commandstudio.director.toggle_your_studio_debug_scoreboard", aliases = {"board", "sidebar", "sb"}, origin = DirectorOrigin.PLAYER)
|
||||
public void scoreboard() {
|
||||
Player target = player();
|
||||
@@ -256,15 +273,10 @@ public class CommandStudio implements DirectorExecutor {
|
||||
long seed
|
||||
) {
|
||||
if (noGUI()) return;
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER));
|
||||
|
||||
if (generator == null) {
|
||||
NoiseExplorerGUI.launch();
|
||||
return;
|
||||
}
|
||||
|
||||
String generatorKey = generator.getLoadKey();
|
||||
NoiseExplorerGUI.launchGeneratorKey(generatorKey, generator, seed);
|
||||
VolmitSender commandSender = sender();
|
||||
UUID openerId = commandSender.isPlayer() ? commandSender.player().getUniqueId() : null;
|
||||
MapWindow.NoiseSelection selection = new MapWindow.NoiseSelection(generator == null ? null : generator.getLoadKey(), generator, seed);
|
||||
withPlayerContext(opener -> openNoiseAtPlayer(commandSender, openerId, opener, selection));
|
||||
}
|
||||
|
||||
@Director(description = "Open the image-map studio (External GUI)", descriptionKey = "iris.director.commandstudio.director.open_image_map_studio_external_gui", aliases = {"imap"})
|
||||
@@ -280,8 +292,7 @@ public class CommandStudio implements DirectorExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
Engine activeEngine = IrisToolbelt.access(world).getEngine();
|
||||
ImageMapStudioGUI.launch(activeEngine);
|
||||
requestMapStart(world, MapWindow::openImageMaps);
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_IMAGE_MAP_STUDIO));
|
||||
}
|
||||
|
||||
@@ -309,23 +320,33 @@ public class CommandStudio implements DirectorExecutor {
|
||||
O<Integer> ta = new O<>();
|
||||
ta.set(-1);
|
||||
|
||||
var sender = sender();
|
||||
var player = player();
|
||||
var engine = engine();
|
||||
VolmitSender sender = sender();
|
||||
Player player = player();
|
||||
Engine engine = engine();
|
||||
|
||||
ta.set(J.sr(() ->
|
||||
{
|
||||
if (!player.getOpenInventory().getType().equals(InventoryType.CHEST)) {
|
||||
ta.set(J.sr(() -> {
|
||||
if (!J.runEntity(player, () -> {
|
||||
if (player.getOpenInventory().getTopInventory() != inv) {
|
||||
J.csr(ta.get());
|
||||
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_OPENED_INVENTORY));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!add) {
|
||||
inv.clear();
|
||||
}
|
||||
|
||||
EngineBukkitOps.addItems(engine, true, inv, tables, InventorySlotType.STORAGE, player.getWorld(), player.getLocation().getBlockX(), player.getLocation().getBlockY(), player.getLocation().getBlockZ());
|
||||
Location location = player.getLocation();
|
||||
try {
|
||||
EngineBukkitOps.addItems(engine, true, inv, tables, InventorySlotType.STORAGE,
|
||||
location.getWorld(), location.getBlockX(), location.getBlockY(), location.getBlockZ());
|
||||
} catch (RuntimeException | Error failure) {
|
||||
J.csr(ta.get());
|
||||
Iris.reportError(failure);
|
||||
sender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_CANNOT_ADD_ITEMS_VIRTUAL_INVENTORY_BECAUSE,
|
||||
MessageArgument.untrusted("value", String.valueOf(failure.getMessage()))));
|
||||
}
|
||||
}, 0, () -> J.csr(ta.get()))) {
|
||||
J.csr(ta.get());
|
||||
}
|
||||
}, fast ? 5 : 35));
|
||||
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_INVENTORY_NOW));
|
||||
@@ -411,11 +432,61 @@ public class CommandStudio implements DirectorExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
UUID openerId = sender().isPlayer() ? player().getUniqueId() : null;
|
||||
VisionGUI.launch(IrisToolbelt.access(world).getEngine(), openerId);
|
||||
requestMapStart(world, MapWindow::openMap);
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_MAP));
|
||||
}
|
||||
|
||||
// The map opens where the player stands when they are in that world, otherwise at the world origin.
|
||||
private PaletteExplorerStart mapStartIn(World world, Player opener, UUID openerId) {
|
||||
Location center = opener != null && opener.getWorld().equals(world) ? opener.getLocation() : null;
|
||||
return new PaletteExplorerStart(
|
||||
IrisToolbelt.access(world).getEngine(),
|
||||
world.getUID(),
|
||||
openerId,
|
||||
null,
|
||||
center == null ? 0D : center.getX(),
|
||||
center == null ? 0D : center.getZ()
|
||||
);
|
||||
}
|
||||
|
||||
private void requestMapStart(World world, Consumer<PaletteExplorerStart> launch) {
|
||||
UUID openerId = sender().isPlayer() ? player().getUniqueId() : null;
|
||||
withPlayerContext(opener -> launch.accept(mapStartIn(world, opener, openerId)));
|
||||
}
|
||||
|
||||
private void openNoiseAtPlayer(VolmitSender commandSender, UUID openerId, Player opener, MapWindow.NoiseSelection selection) {
|
||||
World world = opener == null ? null : opener.getWorld();
|
||||
PlatformChunkGenerator worldGenerator = world == null ? null : IrisToolbelt.access(world);
|
||||
Engine engine = worldGenerator == null ? GuiHost.get().findActiveEngine() : worldGenerator.getEngine();
|
||||
if (engine == null) {
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_NEED_BE_SPECIFY_IRIS_GENERATED_WORLD));
|
||||
return;
|
||||
}
|
||||
PaletteExplorerStart start = worldGenerator == null
|
||||
? new PaletteExplorerStart(engine, null, openerId, null, 0D, 0D)
|
||||
: mapStartIn(world, opener, openerId);
|
||||
MapWindow.openNoise(start, selection);
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER));
|
||||
}
|
||||
|
||||
private void withPlayerContext(Consumer<Player> action) {
|
||||
Player opener = sender().isPlayer() ? player() : null;
|
||||
if (opener == null) {
|
||||
action.accept(null);
|
||||
return;
|
||||
}
|
||||
AtomicBoolean completed = new AtomicBoolean();
|
||||
Consumer<Player> complete = player -> {
|
||||
if (completed.compareAndSet(false, true)) {
|
||||
action.accept(player);
|
||||
}
|
||||
};
|
||||
Runnable retired = () -> complete.accept(null);
|
||||
if (!J.runEntity(opener, () -> complete.accept(opener.isOnline() ? opener : null), 0, retired)) {
|
||||
retired.run();
|
||||
}
|
||||
}
|
||||
|
||||
@Director(description = "Profiles the performance of a dimension", descriptionKey = "iris.director.commandstudio.director.profiles_performance_dimension", origin = DirectorOrigin.PLAYER)
|
||||
public void profile(
|
||||
@Param(description = "The dimension to profile", descriptionKey = "iris.director.commandstudio.param.dimension_profile", contextual = true, contextualOverride = true, defaultValue = "default", customHandler = DimensionHandler.class)
|
||||
@@ -588,6 +659,16 @@ public class CommandStudio implements DirectorExecutor {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_DONE, MessageArgument.untrusted("value", report.getPath())));
|
||||
}
|
||||
|
||||
private void sendHotloadResult(VolmitSender commandSender, Throwable failure) {
|
||||
if (failure != null) {
|
||||
Iris.reportError(failure);
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_HOTLOAD_FAILED,
|
||||
MessageArgument.untrusted("value", String.valueOf(failure.getMessage()))));
|
||||
return;
|
||||
}
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_HOTLOADED));
|
||||
}
|
||||
|
||||
private PlatformChunkGenerator resolveProfileGenerator(IrisDimension dimension) {
|
||||
StudioSVC studioService = Iris.service(StudioSVC.class);
|
||||
if (studioService != null && studioService.isProjectOpen()) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package art.arcane.iris.platform.agent;
|
||||
|
||||
import java.lang.foreign.Arena;
|
||||
import java.lang.foreign.FunctionDescriptor;
|
||||
import java.lang.foreign.Linker;
|
||||
import java.lang.foreign.MemorySegment;
|
||||
import java.lang.foreign.ValueLayout;
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class AttachSignal {
|
||||
private static final int SIGQUIT = 3;
|
||||
private static final int SIGTERM = 15;
|
||||
private static final long ACTION_BYTES = 256;
|
||||
private static final long SIG_IGN = 1L;
|
||||
|
||||
private AttachSignal() {
|
||||
}
|
||||
|
||||
public static boolean restoreJvmHandler() {
|
||||
if (System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win")) {
|
||||
return false;
|
||||
}
|
||||
Linker linker = Linker.nativeLinker();
|
||||
MethodHandle sigaction = linker.downcallHandle(
|
||||
linker.defaultLookup().find("sigaction")
|
||||
.orElseThrow(() -> new IllegalStateException("sigaction is unavailable")),
|
||||
FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.ADDRESS));
|
||||
try (Arena arena = Arena.ofConfined()) {
|
||||
MemorySegment jvm = arena.allocate(ACTION_BYTES);
|
||||
MemorySegment quit = arena.allocate(ACTION_BYTES);
|
||||
if ((int) sigaction.invokeExact(SIGTERM, MemorySegment.NULL, jvm) != 0
|
||||
|| (int) sigaction.invokeExact(SIGQUIT, MemorySegment.NULL, quit) != 0) {
|
||||
throw new IllegalStateException("sigaction could not read the SIGTERM and SIGQUIT handlers");
|
||||
}
|
||||
long handler = jvm.get(ValueLayout.JAVA_LONG, 0);
|
||||
if (handler == 0L || handler == SIG_IGN || quit.get(ValueLayout.JAVA_LONG, 0) == handler) {
|
||||
return false;
|
||||
}
|
||||
if ((int) sigaction.invokeExact(SIGQUIT, jvm, MemorySegment.NULL) != 0) {
|
||||
throw new IllegalStateException("sigaction could not restore the JVM SIGQUIT handler");
|
||||
}
|
||||
return true;
|
||||
} catch (RuntimeException | Error failure) {
|
||||
throw failure;
|
||||
} catch (Throwable failure) {
|
||||
throw new IllegalStateException("Unable to restore the JVM SIGQUIT handler", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-34
@@ -17,8 +17,7 @@ import art.arcane.iris.platform.bukkit.terrain.IrisSampleLimits;
|
||||
import art.arcane.iris.platform.bukkit.terrain.IrisSurfaceClassifier;
|
||||
import art.arcane.iris.platform.bukkit.terrain.IrisWorldInfoFactory;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
|
||||
import art.arcane.iris.generation.hydrology.raster.WaterRole;
|
||||
import art.arcane.iris.generation.terrain.InferredType;
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.terrain.IrisRegion;
|
||||
@@ -108,15 +107,15 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
|
||||
|
||||
try {
|
||||
int surface = engine.getHeight(blockX, blockZ);
|
||||
HydrologyColumnSample hydrology = hydrologySample(engine, blockX, blockZ);
|
||||
WaterRole role = engine.getComplex().getWaterRoleStream().get(blockX, blockZ);
|
||||
int fluid = (int) Math.round(
|
||||
engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ));
|
||||
engine.getComplex().getWaterHeadStream().get(blockX, blockZ));
|
||||
InferredType inferredType = null;
|
||||
if (IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)) {
|
||||
IrisBiome biome = engine.getSurfaceBiome(blockX, blockZ);
|
||||
inferredType = biome == null ? null : biome.getInferredType();
|
||||
}
|
||||
return IrisSurfaceClassifier.classify(surface, fluid, inferredType, hydrology);
|
||||
return IrisSurfaceClassifier.classify(surface, fluid, inferredType, role);
|
||||
} catch (Throwable error) {
|
||||
reportQueryFault("surfaceKind", world, error);
|
||||
return IrisSurfaceKind.UNKNOWN;
|
||||
@@ -266,12 +265,12 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
|
||||
return false;
|
||||
}
|
||||
|
||||
HydrologyColumnSample hydrology = wantRiver
|
||||
? hydrologySample(engine, blockX, blockZ)
|
||||
: null;
|
||||
WaterRole role = wantRiver
|
||||
? engine.getComplex().getWaterRoleStream().get(blockX, blockZ)
|
||||
: WaterRole.NONE;
|
||||
int surface = wantHeight || wantKind ? engine.getHeight(blockX, blockZ) : 0;
|
||||
int fluid = wantRiver
|
||||
? (int) Math.round(engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ))
|
||||
? (int) Math.round(engine.getComplex().getWaterHeadStream().get(blockX, blockZ))
|
||||
: engine.getDimension().getFluidHeight();
|
||||
boolean needsBiome = wantBiome
|
||||
|| (wantKind && IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid));
|
||||
@@ -281,29 +280,23 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
|
||||
surface,
|
||||
fluid,
|
||||
biome == null ? null : biome.getInferredType(),
|
||||
hydrology
|
||||
role
|
||||
)
|
||||
: IrisSurfaceKind.UNKNOWN;
|
||||
String biomeKey = wantBiome && biome != null ? biome.getLoadKey() : null;
|
||||
int natural = wantNaturalHeight
|
||||
? (int) Math.round(engine.getComplex().getNaturalHeightStream().get(blockX, blockZ)) + minHeight
|
||||
: IrisColumnSample.UNAVAILABLE_HEIGHT;
|
||||
HydrologyColumnLayer surfaceLayer = hydrology == null
|
||||
? null
|
||||
: hydrology.primarySurfaceLayer().orElse(null);
|
||||
boolean riverPresent = surfaceLayer != null;
|
||||
IrisRiverState riverState = wantRiverState
|
||||
? riverState(hydrology)
|
||||
? riverState(role)
|
||||
: IrisRiverState.NONE;
|
||||
double riverDistance = wantRiverDistance && riverPresent
|
||||
? engine.getComplex().getRiverDistanceStream().get(blockX, blockZ)
|
||||
double riverDistance = wantRiverDistance
|
||||
? riverDistance(role)
|
||||
: IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE;
|
||||
int riverFlow = wantRiverFlow && riverPresent
|
||||
? (int) Math.round(engine.getComplex().getRiverFlowStream().get(blockX, blockZ))
|
||||
int riverFlow = wantRiverFlow
|
||||
? riverFlow(role)
|
||||
: IrisColumnSample.UNAVAILABLE_RIVER_FLOW;
|
||||
int riverWaterSurfaceY = wantRiverWaterSurface
|
||||
&& surfaceLayer != null
|
||||
&& surfaceLayer.connectedFluid()
|
||||
int riverWaterSurfaceY = wantRiverWaterSurface && role.wet()
|
||||
? fluid + minHeight
|
||||
: IrisColumnSample.UNAVAILABLE_HEIGHT;
|
||||
sink.accept(new IrisColumnSample(
|
||||
@@ -327,21 +320,25 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
|
||||
}
|
||||
}
|
||||
|
||||
static IrisRiverState riverState(HydrologyColumnSample hydrology) {
|
||||
HydrologyColumnLayer layer = hydrology == null
|
||||
? null
|
||||
: hydrology.primarySurfaceLayer().orElse(null);
|
||||
if (layer == null || !layer.channel()) {
|
||||
return IrisRiverState.NONE;
|
||||
}
|
||||
return layer.connectedFluid() ? IrisRiverState.WET : IrisRiverState.DRY;
|
||||
static IrisRiverState riverState(WaterRole role) {
|
||||
return role.wet() ? IrisRiverState.WET : IrisRiverState.NONE;
|
||||
}
|
||||
|
||||
private static HydrologyColumnSample hydrologySample(Engine engine, int blockX, int blockZ) {
|
||||
if (engine.getComplex().getHydrologyRuntime() == null) {
|
||||
return null;
|
||||
static double riverDistance(WaterRole role) {
|
||||
return switch (role) {
|
||||
case NONE -> IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE;
|
||||
case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> 0D;
|
||||
case SHORE, POND_SHORE -> 1D;
|
||||
case BANK, DAM -> 2D;
|
||||
};
|
||||
}
|
||||
return engine.getComplex().getHydrologyRuntime().sample(blockX, blockZ).orElse(null);
|
||||
|
||||
static int riverFlow(WaterRole role) {
|
||||
return switch (role) {
|
||||
case NONE, SHORE, POND_SHORE, BANK, DAM -> IrisColumnSample.UNAVAILABLE_RIVER_FLOW;
|
||||
case CHANNEL, MOUTH, CASCADE, WATERFALL -> 1;
|
||||
case POND, INLET -> 0;
|
||||
};
|
||||
}
|
||||
|
||||
private static Optional<String> key(IrisBiome biome) {
|
||||
|
||||
+8
-18
@@ -1,8 +1,7 @@
|
||||
package art.arcane.iris.platform.bukkit.terrain;
|
||||
|
||||
import art.arcane.iris.api.terrain.IrisSurfaceKind;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
|
||||
import art.arcane.iris.generation.hydrology.raster.WaterRole;
|
||||
import art.arcane.iris.generation.terrain.InferredType;
|
||||
|
||||
public final class IrisSurfaceClassifier {
|
||||
@@ -29,25 +28,16 @@ public final class IrisSurfaceClassifier {
|
||||
int engineSurfaceHeight,
|
||||
int engineFluidHeight,
|
||||
InferredType inferredType,
|
||||
HydrologyColumnSample hydrology
|
||||
WaterRole role
|
||||
) {
|
||||
if (engineSurfaceHeight <= 0) {
|
||||
return IrisSurfaceKind.VOID;
|
||||
}
|
||||
HydrologyColumnLayer layer = hydrology == null
|
||||
? null
|
||||
: hydrology.primarySurfaceLayer().orElse(null);
|
||||
if (layer != null) {
|
||||
if (layer.shore()) {
|
||||
return IrisSurfaceKind.RIVER_SHORE;
|
||||
}
|
||||
if (layer.channel()) {
|
||||
return layer.connectedFluid() ? IrisSurfaceKind.RIVER : IrisSurfaceKind.DRY_CHANNEL;
|
||||
}
|
||||
if (layer.grading()) {
|
||||
return IrisSurfaceKind.LAND;
|
||||
}
|
||||
}
|
||||
return classify(engineSurfaceHeight, engineFluidHeight, inferredType);
|
||||
return switch (role) {
|
||||
case NONE -> classify(engineSurfaceHeight, engineFluidHeight, inferredType);
|
||||
case SHORE, POND_SHORE -> IrisSurfaceKind.RIVER_SHORE;
|
||||
case BANK, DAM -> IrisSurfaceKind.LAND;
|
||||
case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> IrisSurfaceKind.RIVER;
|
||||
};
|
||||
}
|
||||
}
|
||||
+39
-3
@@ -23,6 +23,8 @@ import art.arcane.iris.world.event.IrisEngineHotloadEvent;
|
||||
import art.arcane.iris.world.IrisToolbelt;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
|
||||
import art.arcane.iris.studio.StudioSVC;
|
||||
import art.arcane.iris.studio.workspace.IrisProject;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.event.EventHandler;
|
||||
@@ -40,10 +42,47 @@ public final class BukkitGuiHost implements GuiHost.Provider {
|
||||
GuiHost.set(new BukkitGuiHost());
|
||||
}
|
||||
|
||||
/**
|
||||
* The engine of the open studio project, which reads the pack folder being edited, or else the first
|
||||
* Iris world. A normal Iris world runs on its own copy of the pack, made when the world was created.
|
||||
*/
|
||||
@Override
|
||||
public Engine findActiveEngine() {
|
||||
Engine studioEngine = findStudioEngine();
|
||||
if (studioEngine != null) {
|
||||
return studioEngine;
|
||||
}
|
||||
try {
|
||||
for (World world : new ArrayList<>(Bukkit.getWorlds())) {
|
||||
Engine engine = engineOf(world);
|
||||
if (engine != null) {
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Engine findEngine(UUID worldId) {
|
||||
World world = worldId == null ? null : Bukkit.getWorld(worldId);
|
||||
return world == null ? null : engineOf(world);
|
||||
}
|
||||
|
||||
private static Engine findStudioEngine() {
|
||||
try {
|
||||
StudioSVC studio = Iris.service(StudioSVC.class);
|
||||
IrisProject project = studio == null || !studio.isProjectOpen() ? null : studio.getActiveProject();
|
||||
PlatformChunkGenerator provider = project == null ? null : project.getActiveProvider();
|
||||
Engine engine = provider == null ? null : provider.getEngine();
|
||||
return engine == null || engine.isClosed() ? null : engine;
|
||||
} catch (Throwable ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Engine engineOf(World world) {
|
||||
try {
|
||||
PlatformChunkGenerator access = IrisToolbelt.access(world);
|
||||
if (access != null && access.getEngine() != null && !access.getEngine().isClosed()) {
|
||||
@@ -51,9 +90,6 @@ public final class BukkitGuiHost implements GuiHost.Provider {
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+83
-40
@@ -18,23 +18,19 @@
|
||||
|
||||
package art.arcane.iris.studio.view;
|
||||
|
||||
import art.arcane.volmlib.nativelib.view.WorldMarker;
|
||||
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.studio.render.RenderType;
|
||||
import art.arcane.iris.world.IrisWorld;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.world.task.J;
|
||||
import art.arcane.volmlib.util.format.Form;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -56,7 +52,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
||||
private final AtomicBoolean playerRefreshQueued = new AtomicBoolean();
|
||||
private final AtomicLong teleportSequence = new AtomicLong();
|
||||
private final AtomicReference<VisionTeleportRequest> latestTeleport = new AtomicReference<>();
|
||||
private volatile List<WorldMarker> playerMarkers = List.of();
|
||||
private volatile List<GuiMarker> playerMarkers = List.of();
|
||||
|
||||
public BukkitVisionOverlay(Engine engine, UUID openerId) {
|
||||
this.engine = engine;
|
||||
@@ -68,7 +64,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
||||
* built by a server thread.
|
||||
*/
|
||||
@Override
|
||||
public List<WorldMarker> players() {
|
||||
public List<GuiMarker> players() {
|
||||
queuePlayerRefresh();
|
||||
return playerMarkers;
|
||||
}
|
||||
@@ -78,26 +74,48 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean scheduled = J.runGlobal(() -> {
|
||||
try {
|
||||
List<WorldMarker> markers = new ArrayList<>();
|
||||
for (Player player : BukkitWorldBinding.players(engine.getWorld())) {
|
||||
Location at = player.getLocation();
|
||||
markers.add(WorldMarker.player(player.getName(), at.getX(), at.getZ()));
|
||||
}
|
||||
playerMarkers = List.copyOf(markers);
|
||||
} finally {
|
||||
playerRefreshQueued.set(false);
|
||||
}
|
||||
});
|
||||
boolean scheduled = J.runGlobal(this::refreshPlayers);
|
||||
|
||||
if (!scheduled) {
|
||||
playerRefreshQueued.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshPlayers() {
|
||||
World world = BukkitWorldBinding.world(engine.getWorld());
|
||||
List<Player> players = BukkitWorldBinding.players(engine.getWorld());
|
||||
if (players.isEmpty() || world == null) {
|
||||
playerMarkers = List.of();
|
||||
playerRefreshQueued.set(false);
|
||||
return;
|
||||
}
|
||||
List<GuiMarker> markers = Collections.synchronizedList(new ArrayList<>(players.size()));
|
||||
AtomicInteger pending = new AtomicInteger(players.size());
|
||||
for (Player player : players) {
|
||||
AtomicBoolean completed = new AtomicBoolean();
|
||||
Runnable complete = () -> {
|
||||
if (completed.compareAndSet(false, true) && pending.decrementAndGet() == 0) {
|
||||
playerMarkers = List.copyOf(markers);
|
||||
playerRefreshQueued.set(false);
|
||||
}
|
||||
};
|
||||
if (!J.runEntity(player, () -> {
|
||||
try {
|
||||
if (player.isOnline() && player.getWorld() == world) {
|
||||
Location at = player.getLocation();
|
||||
markers.add(GuiMarker.player(player.getName(), at.getX(), at.getZ(), at.getYaw()));
|
||||
}
|
||||
} finally {
|
||||
complete.run();
|
||||
}
|
||||
}, 0, complete)) {
|
||||
complete.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requestEntities(Consumer<List<WorldMarker>> sink) {
|
||||
public void requestEntities(Consumer<List<GuiMarker>> sink) {
|
||||
J.runGlobal(() -> {
|
||||
IrisWorld target = engine.getWorld();
|
||||
World world = BukkitWorldBinding.world(target);
|
||||
@@ -118,7 +136,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
||||
return;
|
||||
}
|
||||
|
||||
List<WorldMarker> collected = Collections.synchronizedList(new ArrayList<>(living.size()));
|
||||
List<GuiMarker> collected = Collections.synchronizedList(new ArrayList<>(living.size()));
|
||||
AtomicInteger pending = new AtomicInteger(living.size());
|
||||
Runnable complete = () -> {
|
||||
if (pending.decrementAndGet() == 0) {
|
||||
@@ -144,22 +162,22 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
||||
});
|
||||
}
|
||||
|
||||
private WorldMarker marker(LivingEntity entity, Location at) {
|
||||
private GuiMarker marker(LivingEntity entity, Location at) {
|
||||
String label = Form.capitalizeWords(entity.getType().name().toLowerCase(Locale.ROOT).replaceAll("\\Q_\\E", " "));
|
||||
double maxHealth = 0;
|
||||
try {
|
||||
maxHealth = entity.getAttribute(MAX_HEALTH).getValue();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return WorldMarker.entity(label, at.getX(), at.getY(), at.getZ(), entity.getHealth(), maxHealth);
|
||||
return GuiMarker.entity(label, at.getX(), at.getY(), at.getZ(), entity.getHealth(), maxHealth);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void teleport(double worldX, double worldZ) {
|
||||
VisionTeleportRequest request = new VisionTeleportRequest(
|
||||
teleportSequence.incrementAndGet(),
|
||||
VisionGUI.floorWorldCoordinate(worldX),
|
||||
VisionGUI.floorWorldCoordinate(worldZ));
|
||||
(int) StrictMath.floor(worldX),
|
||||
(int) StrictMath.floor(worldZ));
|
||||
latestTeleport.set(request);
|
||||
startTeleport(request);
|
||||
}
|
||||
@@ -212,6 +230,46 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requestOpenerPosition(Consumer<OpenerPosition> sink) {
|
||||
AtomicBoolean completed = new AtomicBoolean();
|
||||
Consumer<OpenerPosition> complete = position -> {
|
||||
if (completed.compareAndSet(false, true)) {
|
||||
sink.accept(position);
|
||||
}
|
||||
};
|
||||
if (openerId == null) {
|
||||
complete.accept(OpenerPosition.OFFLINE);
|
||||
return;
|
||||
}
|
||||
boolean scheduled = J.runGlobal(() -> {
|
||||
Player player = Bukkit.getPlayer(openerId);
|
||||
if (player == null) {
|
||||
complete.accept(OpenerPosition.OFFLINE);
|
||||
return;
|
||||
}
|
||||
Runnable retired = () -> complete.accept(OpenerPosition.OFFLINE);
|
||||
if (!J.runEntity(player, () -> complete.accept(positionOf(player,
|
||||
BukkitWorldBinding.world(engine.getWorld()))), 0, retired)) {
|
||||
retired.run();
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
complete.accept(OpenerPosition.OFFLINE);
|
||||
}
|
||||
}
|
||||
|
||||
private static OpenerPosition positionOf(Player player, World world) {
|
||||
if (!player.isOnline()) {
|
||||
return OpenerPosition.OFFLINE;
|
||||
}
|
||||
if (world == null || player.getWorld() != world) {
|
||||
return OpenerPosition.IN_OTHER_WORLD;
|
||||
}
|
||||
Location at = player.getLocation();
|
||||
return OpenerPosition.inWorld(at.getX(), at.getY(), at.getZ());
|
||||
}
|
||||
|
||||
private Player selectPlayer(List<Player> players) {
|
||||
if (openerId == null) {
|
||||
return players.isEmpty() ? null : players.get(0);
|
||||
@@ -316,21 +374,6 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
||||
+ blockX + "," + blockZ + ".", failure);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String openInEditor(double worldX, double worldZ, RenderType type) {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
File file = switch (type) {
|
||||
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT, RIVER ->
|
||||
complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
case REGION -> complex.getRegionStream().get(worldX, worldZ).openInVSCode();
|
||||
case CAVE_LAND -> complex.getCaveBiomeStream().get(worldX, worldZ).openInVSCode();
|
||||
default -> null;
|
||||
};
|
||||
return file == null ? null : file.getName();
|
||||
}
|
||||
|
||||
private static final class VisionTeleportRequest {
|
||||
private final long sequence;
|
||||
private final int blockX;
|
||||
|
||||
+3
-7
@@ -896,13 +896,9 @@ public final class IrisWorldGeneratorResolver {
|
||||
}
|
||||
|
||||
private static String dimensionTypeKey(IrisData data, IrisDimension dimension) {
|
||||
return IrisPlatforms.get()
|
||||
.registries()
|
||||
.generationRegistry()
|
||||
.dimensionTypeResourceKey(
|
||||
data.getDataFolder().getName(),
|
||||
dimension.getLoadKey(),
|
||||
dimension.getDimensionTypeKey()
|
||||
return dimension.dimensionTypeResourceKey(
|
||||
IrisPlatforms.get().registries().generationRegistry(),
|
||||
data.getDataFolder().getName()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+59
-1
@@ -58,6 +58,7 @@ import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
@@ -78,6 +79,9 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
private volatile UUID overworldEntryAwaitingVerification;
|
||||
private volatile World overworldEntryWorld;
|
||||
private volatile boolean paperEntryListenerRegistered;
|
||||
private final Object stagingMonitor = new Object();
|
||||
private boolean stagingAdmissionOpen = true;
|
||||
private StagingAdmission activeStaging;
|
||||
|
||||
public PendingWorldReplacementManager(Iris plugin) {
|
||||
this.plugin = Objects.requireNonNull(plugin, "plugin");
|
||||
@@ -135,7 +139,8 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_REPLACE,
|
||||
requiredWorldKey.toString()
|
||||
); WorldReplacementProgress progress = WorldReplacementProgress.start(
|
||||
); StagingAdmission ignoredAdmission = admitStaging(requiredWorldKey);
|
||||
WorldReplacementProgress progress = WorldReplacementProgress.start(
|
||||
requiredSender, requiredWorldKey.toString())) {
|
||||
synchronized (this) {
|
||||
IrisStartupValidation.requireWorldReplacementStagingReady();
|
||||
@@ -271,6 +276,29 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
}
|
||||
}
|
||||
|
||||
public void awaitStagingForShutdown() {
|
||||
StagingAdmission staging;
|
||||
synchronized (stagingMonitor) {
|
||||
stagingAdmissionOpen = false;
|
||||
staging = activeStaging;
|
||||
}
|
||||
if (staging == null) {
|
||||
return;
|
||||
}
|
||||
Iris.warn("Waiting for the replacement of " + staging.worldKey + " to finish staging before Iris shuts down.");
|
||||
boolean interrupted = false;
|
||||
while (staging.finished.getCount() > 0L) {
|
||||
try {
|
||||
staging.finished.await();
|
||||
} catch (InterruptedException e) {
|
||||
interrupted = true;
|
||||
}
|
||||
}
|
||||
if (interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void processPendingStartupReplacements() {
|
||||
ArrayList<String> failures = new ArrayList<>();
|
||||
ArrayList<String> restartBoundaries = new ArrayList<>();
|
||||
@@ -1048,6 +1076,17 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
return plugin.getDataFolder().toPath().toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
private StagingAdmission admitStaging(NamespacedKey worldKey) throws IOException {
|
||||
synchronized (stagingMonitor) {
|
||||
if (!stagingAdmissionOpen) {
|
||||
throw new IOException("Iris is shutting down. Run the replacement again after the server starts.");
|
||||
}
|
||||
StagingAdmission staging = new StagingAdmission(worldKey);
|
||||
activeStaging = staging;
|
||||
return staging;
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void loadOverworldEntryGuard() throws IOException {
|
||||
ExactWorldSlotPathPolicy.Target target = resolveTarget(OVERWORLD_KEY);
|
||||
Optional<WorldReplacementEntryGuard.Entry> loaded = WorldReplacementEntryGuard.load(target.worldDirectory());
|
||||
@@ -1218,6 +1257,25 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
record SavedLocationInspection(boolean safe, Throwable failure) {
|
||||
}
|
||||
|
||||
private final class StagingAdmission implements AutoCloseable {
|
||||
private final NamespacedKey worldKey;
|
||||
private final CountDownLatch finished = new CountDownLatch(1);
|
||||
|
||||
private StagingAdmission(NamespacedKey worldKey) {
|
||||
this.worldKey = worldKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
synchronized (stagingMonitor) {
|
||||
if (activeStaging == this) {
|
||||
activeStaging = null;
|
||||
}
|
||||
}
|
||||
finished.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RestartBoundaryRequired extends IOException {
|
||||
private RestartBoundaryRequired(String message) {
|
||||
super(message);
|
||||
|
||||
+11
@@ -19,6 +19,10 @@
|
||||
package art.arcane.iris.world.runtime;
|
||||
|
||||
import art.arcane.iris.api.world.IrisWorldPhase;
|
||||
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
|
||||
import art.arcane.iris.integration.ExternalDataSVC;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint;
|
||||
import art.arcane.iris.pack.datapack.ServerConfigurator;
|
||||
import art.arcane.iris.pack.datapack.DatapackIngestService;
|
||||
import art.arcane.iris.world.event.IrisEngineHotloadEvent;
|
||||
@@ -51,6 +55,13 @@ import org.bukkit.World;
|
||||
import org.bukkit.WorldBorder;
|
||||
|
||||
public final class BukkitEnginePlatformHooks implements EnginePlatformHooks {
|
||||
@Override
|
||||
public boolean removeCustomBlock(Engine engine, NativeBlockPoint position, CustomBlockOwnership.Entry entry) {
|
||||
ExternalDataSVC service = IrisServices.getOrNull(ExternalDataSVC.class);
|
||||
return service != null && service.removeGenerated(
|
||||
BukkitWorldBinding.world(engine.getWorld()).getBlockAt(position.x(), position.y(), position.z()), entry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> flushSavedTerrainCapture(Engine engine) {
|
||||
return INMS.get().flushSavedTerrainCapture(BukkitWorldBinding.world(engine.getWorld()));
|
||||
|
||||
@@ -27,3 +27,4 @@ permissions:
|
||||
description: Allows survival players to fell Iris-managed trees with an axe.
|
||||
default: op
|
||||
api-version: '${apiVersion}'
|
||||
libraries: [ ${libraries} ]
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "Flat",
|
||||
"derivative": "minecraft:snowy_plains",
|
||||
"vanillaDerivative": "minecraft:snowy_plains",
|
||||
"layers": [
|
||||
{
|
||||
"minHeight": 1,
|
||||
"maxHeight": 1,
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:lime_wool"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"minHeight": 384,
|
||||
"maxHeight": 384,
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:green_concrete"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"generators": [
|
||||
{
|
||||
"generator": "flat",
|
||||
"min": 96,
|
||||
"max": 96
|
||||
}
|
||||
],
|
||||
"decorators": [
|
||||
{
|
||||
"palette": [
|
||||
{
|
||||
"block": "craftengine:default/flower_basket",
|
||||
"data": {
|
||||
"variant": "ground"
|
||||
}
|
||||
}
|
||||
],
|
||||
"chance": 1,
|
||||
"forcePlace": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "CraftEngine Imported Feature Fixture",
|
||||
"version": 1,
|
||||
"mode": {
|
||||
"type": "OVERWORLD"
|
||||
},
|
||||
"regions": [
|
||||
"flat"
|
||||
],
|
||||
"focusRegion": "flat",
|
||||
"focus": "flat",
|
||||
"landChance": 1,
|
||||
"environment": "NORMAL",
|
||||
"dimensionHeight": {
|
||||
"min": -64,
|
||||
"max": 320
|
||||
},
|
||||
"logicalHeight": 384,
|
||||
"fluidHeight": 0,
|
||||
"bedrock": false,
|
||||
"rockPalette": {
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:red_concrete"
|
||||
}
|
||||
]
|
||||
},
|
||||
"importedStructures": {
|
||||
"disabled": [
|
||||
"minecraft:"
|
||||
]
|
||||
},
|
||||
"importedFeatures": {
|
||||
"enabled": true,
|
||||
"steps": [
|
||||
"TOP_LAYER_MODIFICATION"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"interpolator": {
|
||||
"function": "NONE",
|
||||
"horizontalScale": 1
|
||||
},
|
||||
"seed": 310,
|
||||
"composite": [
|
||||
{
|
||||
"seed": 310,
|
||||
"style": {
|
||||
"style": "FLAT"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "Flat",
|
||||
"landBiomes": [
|
||||
"flat"
|
||||
],
|
||||
"seaBiomes": [
|
||||
"flat"
|
||||
],
|
||||
"shoreBiomes": [
|
||||
"flat"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
}
|
||||
|
||||
version = '1.0.0'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven { url = 'https://repo.papermc.io/repository/maven-public/' }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly files(providers.gradleProperty('irisJar').get())
|
||||
compileOnly files(providers.gradleProperty('craftEngineJar').get())
|
||||
compileOnly 'io.papermc.paper:paper-api:26.1.2.build.74-stable'
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain.languageVersion = JavaLanguageVersion.of(25)
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
java.srcDirs = ['src']
|
||||
resources.srcDirs = ['resources']
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.compilerArgs += '-parameters'
|
||||
}
|
||||
|
||||
tasks.named('jar') {
|
||||
archiveBaseName = 'IrisCraftEngineNativeFixture'
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
name: IrisCraftEngineNativeFixture
|
||||
version: 1.0.0
|
||||
main: art.arcane.iris.fixture.CraftEngineNativeFixture
|
||||
api-version: '26.1.2'
|
||||
folia-supported: true
|
||||
depend: [Iris, CraftEngine]
|
||||
commands:
|
||||
iriscenative:
|
||||
permission: iriscenative.run
|
||||
permissions:
|
||||
iriscenative.run:
|
||||
default: op
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = 'iris-craftengine-native-fixture'
|
||||
+385
@@ -0,0 +1,385 @@
|
||||
package art.arcane.iris.fixture;
|
||||
|
||||
import art.arcane.iris.generation.block.IrisCustomData;
|
||||
import art.arcane.iris.generation.block.TileData;
|
||||
import art.arcane.iris.generation.mantle.DeferredPlacementRecords;
|
||||
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.integration.ExternalDataSVC;
|
||||
import art.arcane.iris.integration.Identifier;
|
||||
import art.arcane.iris.platform.generation.EngineBukkitOps;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.world.IrisCreator;
|
||||
import art.arcane.iris.world.IrisToolbelt;
|
||||
import art.arcane.iris.world.storage.matter.TileWrapper;
|
||||
import art.arcane.iris.world.task.J;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
|
||||
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import net.momirealms.craftengine.bukkit.api.CraftEngineBlocks;
|
||||
import net.momirealms.craftengine.bukkit.api.CraftEngineFurniture;
|
||||
import net.momirealms.craftengine.core.util.Key;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.logging.Level;
|
||||
|
||||
public final class CraftEngineNativeFixture extends JavaPlugin {
|
||||
private static final NamespacedKey WORLD_KEY = new NamespacedKey("iris", "ce_native_probe");
|
||||
private static final NamespacedKey FEATURE_WORLD_KEY = new NamespacedKey("iris", "ce_native_feature_probe");
|
||||
private static final int TEST_Y = 180;
|
||||
private static final int TEST_Z = 8;
|
||||
private static final String BLOCK_ID = "craftengine:default/topaz_ore";
|
||||
private static final String FURNITURE_ID = "craftengine:default/flower_basket[variant=ground]";
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||
if (!(sender instanceof Player player) || arguments.length != 1) {
|
||||
sender.sendMessage("CE_FAILED Use /iriscenative prepare|pending|neighbor|loaded|features|restart from a player");
|
||||
return true;
|
||||
}
|
||||
if (arguments[0].equals("prepare")) {
|
||||
player.setGameMode(GameMode.CREATIVE);
|
||||
prepare(player);
|
||||
return true;
|
||||
}
|
||||
if (arguments[0].equals("features")) {
|
||||
importedFeatures(player);
|
||||
return true;
|
||||
}
|
||||
World world = Bukkit.getWorld(WORLD_KEY);
|
||||
if (world == null) {
|
||||
fail(player, new IllegalStateException("Fixture world has not been created"));
|
||||
return true;
|
||||
}
|
||||
if (arguments[0].equals("restart")) {
|
||||
loadChunks().thenCompose(ignored -> J.runRegionFuture(world, 0, 0,
|
||||
() -> later(player, world, () -> restarted(player, world))))
|
||||
.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) {
|
||||
fail(player, failure);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
J.runRegionFuture(world, 0, 0, () -> {
|
||||
switch (arguments[0]) {
|
||||
case "pending" -> pending(player, world);
|
||||
case "neighbor" -> neighbor(player, world);
|
||||
case "loaded" -> invalidateLoaded(player, world, IrisToolbelt.access(world).getEngine());
|
||||
default -> throw new IllegalArgumentException("Unknown fixture operation");
|
||||
}
|
||||
}).whenComplete((ignored, failure) -> {
|
||||
if (failure != null) {
|
||||
fail(player, failure);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private void prepare(Player player) {
|
||||
CompletableFuture.runAsync(() -> {
|
||||
if (Bukkit.getWorld(WORLD_KEY) == null) {
|
||||
try {
|
||||
new IrisCreator().name(WORLD_KEY.toString()).dimension("ce-native-fixture")
|
||||
.seed(78264193L).create();
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("Could not create CraftEngine fixture world", failure);
|
||||
}
|
||||
}
|
||||
}).thenCompose(ignored -> loadChunks()).thenCompose(ignored -> {
|
||||
World world = Bukkit.getWorld(WORLD_KEY);
|
||||
return J.runRegionFuture(world, 0, 0, () -> preparePlatform(world));
|
||||
}).thenAccept(ignored -> {
|
||||
World world = Bukkit.getWorld(WORLD_KEY);
|
||||
if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 8.5, TEST_Y + 3, 8.5))
|
||||
.whenComplete((teleported, failure) -> {
|
||||
if (failure != null || !Boolean.TRUE.equals(teleported)) {
|
||||
fail(player, failure == null ? new IllegalStateException("Fixture teleport failed") : failure);
|
||||
return;
|
||||
}
|
||||
player.sendMessage("CE_READY world=" + WORLD_KEY + " folia=" + J.isFolia());
|
||||
}))) {
|
||||
throw new IllegalStateException("Could not schedule fixture teleport");
|
||||
}
|
||||
}).exceptionally(failure -> {
|
||||
fail(player, failure);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private void importedFeatures(Player player) {
|
||||
CompletableFuture.runAsync(() -> {
|
||||
if (Bukkit.getWorld(FEATURE_WORLD_KEY) == null) {
|
||||
try {
|
||||
new IrisCreator().name(FEATURE_WORLD_KEY.toString()).dimension("ce-native-feature-fixture")
|
||||
.seed(78264193L).create();
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("Could not create imported feature fixture world", failure);
|
||||
}
|
||||
}
|
||||
}).thenCompose(ignored -> {
|
||||
World world = Bukkit.getWorld(FEATURE_WORLD_KEY);
|
||||
return world.getChunkAtAsync(0, 0, true);
|
||||
}).thenCompose(chunk -> J.runRegionFuture(chunk.getWorld(), 0, 0, () -> {
|
||||
World world = chunk.getWorld();
|
||||
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||
int snow = 0;
|
||||
int pending = 0;
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
if (world.getBlockAt(x, 97, z).getType() == Material.SNOW) {
|
||||
snow++;
|
||||
}
|
||||
if (engine.getMantle().getMantle().get(x, 97 - engine.getMinHeight(), z, Identifier.class) != null) {
|
||||
pending++;
|
||||
}
|
||||
}
|
||||
}
|
||||
require(snow == 256, "Imported snow feature did not cover all 256 test columns: " + snow);
|
||||
require(pending == 0, "Imported feature left pending furniture records: " + pending);
|
||||
for (Entity entity : chunk.getEntities()) {
|
||||
require(!CraftEngineFurniture.isFurniture(entity), "Imported snow feature left generated furniture");
|
||||
}
|
||||
if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 8.5, 101, 8.5))
|
||||
.whenComplete((teleported, failure) -> {
|
||||
if (failure != null || !Boolean.TRUE.equals(teleported)) {
|
||||
fail(player, failure == null ? new IllegalStateException("Feature world teleport failed") : failure);
|
||||
return;
|
||||
}
|
||||
player.sendMessage("CE_FEATURES snow=256 pending=0 furniture=0");
|
||||
}))) {
|
||||
throw new IllegalStateException("Could not schedule feature world teleport");
|
||||
}
|
||||
})).exceptionally(failure -> {
|
||||
fail(player, failure);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> loadChunks() {
|
||||
World world = Bukkit.getWorld(WORLD_KEY);
|
||||
require(world != null && IrisToolbelt.isIrisWorld(world), "Fixture must use a real Iris world");
|
||||
List<CompletableFuture<Void>> chunks = new ArrayList<>(15);
|
||||
for (int x = -1; x <= 3; x++) {
|
||||
for (int z = -1; z <= 1; z++) {
|
||||
chunks.add(world.getChunkAtAsync(x, z, true).thenCompose(chunk ->
|
||||
J.runRegionFuture(world, chunk.getX(), chunk.getZ(), () -> chunk.addPluginChunkTicket(this))));
|
||||
}
|
||||
}
|
||||
return CompletableFuture.allOf(chunks.toArray(CompletableFuture[]::new));
|
||||
}
|
||||
|
||||
private void preparePlatform(World world) {
|
||||
require(CraftEngineBlocks.byId(Key.of("default:topaz_ore")) != null, "CraftEngine default block is missing");
|
||||
require(CraftEngineFurniture.byId(Key.of("default:flower_basket")) != null, "CraftEngine furniture is missing");
|
||||
for (int chunkX = 0; chunkX <= 2; chunkX++) {
|
||||
for (Entity entity : world.getChunkAt(chunkX, 0).getEntities()) {
|
||||
if (CraftEngineFurniture.isFurniture(entity)) {
|
||||
CraftEngineFurniture.remove(entity, false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int x = 0; x <= 47; x++) {
|
||||
for (int z = 2; z <= 14; z++) {
|
||||
world.getBlockAt(x, TEST_Y - 1, z).setType(Material.STONE, false);
|
||||
for (int y = TEST_Y; y <= TEST_Y + 5; y++) {
|
||||
world.getBlockAt(x, y, z).setType(Material.AIR, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void pending(Player player, World world) {
|
||||
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||
Mantle<Matter> mantle = engine.getMantle().getMantle();
|
||||
queue(engine, world, 2, BLOCK_ID);
|
||||
queue(engine, world, 4, BLOCK_ID);
|
||||
queue(engine, world, 6, BLOCK_ID);
|
||||
queue(engine, world, 8, BLOCK_ID);
|
||||
queue(engine, world, 10, FURNITURE_ID);
|
||||
queue(engine, world, 12, FURNITURE_ID);
|
||||
Block chest = world.getBlockAt(14, TEST_Y, TEST_Z);
|
||||
chest.setType(Material.CHEST, false);
|
||||
mantle.set(14, TEST_Y - engine.getMinHeight(), TEST_Z, new TileWrapper(TileData.getTileState(chest, false)));
|
||||
world.getBlockAt(4, TEST_Y, TEST_Z).setType(Material.AIR, false);
|
||||
world.getBlockAt(6, TEST_Y, TEST_Z).setType(Material.COBBLESTONE, false);
|
||||
|
||||
NativeBlockChangeSet changes = DeferredPlacementRecords.track(engine, bounds(world));
|
||||
changes.add(8, TEST_Y, TEST_Z);
|
||||
changes.add(12, TEST_Y - 1, TEST_Z);
|
||||
changes.add(12, TEST_Y, TEST_Z);
|
||||
changes.add(14, TEST_Y, TEST_Z);
|
||||
world.getBlockAt(8, TEST_Y, TEST_Z).setType(Material.AIR, false);
|
||||
world.getBlockAt(12, TEST_Y - 1, TEST_Z).setType(Material.AIR, false);
|
||||
chest.setType(Material.COBBLESTONE, false);
|
||||
DeferredPlacementRecords.invalidate(engine, changes);
|
||||
require(record(engine, 8, Identifier.class) == null, "Native cut retained a pending custom record");
|
||||
require(record(engine, 12, Identifier.class) == null, "Flatten column retained pending furniture");
|
||||
require(record(engine, 14, TileWrapper.class) == null, "Native wall retained pending chest data");
|
||||
require(record(engine, 2, Identifier.class) != null, "Native writes invalidated an untouched custom record");
|
||||
replay(engine, world, 0);
|
||||
CustomBlockOwnership ownership = record(engine, 10, CustomBlockOwnership.class);
|
||||
require(record(engine, 2, Identifier.class) == null, "Custom materialization did not consume pending records");
|
||||
require(CraftEngineBlocks.isCustomBlock(world.getBlockAt(2, TEST_Y, TEST_Z)), "Intact custom block was not placed");
|
||||
require(world.getBlockAt(4, TEST_Y, TEST_Z).getType().isAir(), "Cut custom block was resurrected");
|
||||
require(world.getBlockAt(6, TEST_Y, TEST_Z).getType() == Material.COBBLESTONE, "Custom block replaced native wall");
|
||||
require(world.getBlockAt(8, TEST_Y, TEST_Z).getType().isAir(), "Invalidated custom block was resurrected");
|
||||
require(chest.getType() == Material.COBBLESTONE, "Chest data replaced native wall");
|
||||
later(player, world, () -> {
|
||||
int intactFurniture = furnitureAt(world, 10);
|
||||
require(intactFurniture == 1, "Intact furniture count is " + intactFurniture
|
||||
+ "; pending=" + record(engine, 10, Identifier.class)
|
||||
+ "; ownership=" + ownership
|
||||
+ "; chunkEntities=" + world.getChunkAt(0, 0).getEntities().length);
|
||||
require(furnitureAt(world, 12) == 0, "Flattened furniture was resurrected");
|
||||
player.sendMessage("CE_PENDING intact=1 air=1 wall=1 invalidated=1 flatten=1 chest=1 furniture=1");
|
||||
});
|
||||
}
|
||||
|
||||
private void neighbor(Player player, World world) {
|
||||
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||
queue(engine, world, 18, FURNITURE_ID);
|
||||
queue(engine, world, 20, FURNITURE_ID);
|
||||
queue(engine, world, 22, FURNITURE_ID);
|
||||
queue(engine, world, 42, FURNITURE_ID);
|
||||
replay(engine, world, 1);
|
||||
replay(engine, world, 2);
|
||||
world.getBlockAt(40, TEST_Y, TEST_Z).setType(Material.DIAMOND_BLOCK, false);
|
||||
later(player, world, () -> {
|
||||
require(furnitureAt(world, 18) == 1 && furnitureAt(world, 20) == 1
|
||||
&& furnitureAt(world, 22) == 1, "Neighbor furniture did not materialize");
|
||||
require(furnitureAt(world, 42) == 1 && record(engine, 42, CustomBlockOwnership.class) != null,
|
||||
"Regeneration target furniture did not materialize with saved ownership");
|
||||
if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 40.5, TEST_Y + 3, TEST_Z + 0.5))
|
||||
.whenComplete((teleported, failure) -> {
|
||||
if (failure != null || !Boolean.TRUE.equals(teleported)) {
|
||||
fail(player, failure == null ? new IllegalStateException("Regeneration target teleport failed") : failure);
|
||||
return;
|
||||
}
|
||||
player.sendMessage("CE_NEIGHBOR_READY chunk=2,0 furniture=3 target=1");
|
||||
}))) {
|
||||
throw new IllegalStateException("Could not schedule regeneration target teleport");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void invalidateLoaded(Player player, World world, Engine engine) {
|
||||
require(world.getBlockAt(40, TEST_Y, TEST_Z).getType().isAir(), "Adjacent target chunk did not regenerate");
|
||||
require(furnitureAt(world, 18) == 1 && furnitureAt(world, 20) == 1
|
||||
&& furnitureAt(world, 22) == 1, "Neighbor furniture did not materialize");
|
||||
NativeBlockChangeSet changes = DeferredPlacementRecords.track(engine, bounds(world));
|
||||
changes.add(18, TEST_Y - 1, TEST_Z);
|
||||
changes.add(18, TEST_Y, TEST_Z);
|
||||
changes.add(22, TEST_Y, TEST_Z);
|
||||
world.getBlockAt(18, TEST_Y - 1, TEST_Z).setType(Material.AIR, false);
|
||||
world.getBlockAt(18, TEST_Y, TEST_Z).setType(Material.COBBLESTONE, false);
|
||||
DeferredPlacementRecords.invalidate(engine, changes);
|
||||
require(CraftEngineFurniture.place(new Location(world, 22.5, TEST_Y, TEST_Z + 0.5),
|
||||
Key.of("default:flower_basket"), "ground", false) != null,
|
||||
"Could not place replacement furniture before scheduled cleanup");
|
||||
later(player, world, () -> {
|
||||
require(furnitureAt(world, 42) == 0, "Regeneration retained target chunk furniture");
|
||||
require(furnitureAt(world, 18) == 0, "Native write left materialized neighbor furniture");
|
||||
require(furnitureAt(world, 20) == 1, "Native write removed untouched neighbor furniture");
|
||||
require(furnitureAt(world, 22) == 1, "Native cleanup removed replacement furniture or retained the old one");
|
||||
require(world.getBlockAt(18, TEST_Y, TEST_Z).getType() == Material.COBBLESTONE,
|
||||
"Furniture cleanup replaced the native wall");
|
||||
player.sendMessage("CE_LOADED removed=1 untouched=1 replacement=1 wall=1 target=1");
|
||||
});
|
||||
}
|
||||
|
||||
private void restarted(Player player, World world) {
|
||||
require(furnitureAt(world, 20) == 1, "Generated neighbor furniture did not survive restart");
|
||||
require(furnitureAt(world, 10) == 1, "Untouched furniture did not survive restart");
|
||||
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||
NativeBlockChangeSet changes = DeferredPlacementRecords.track(engine, bounds(world));
|
||||
changes.add(20, TEST_Y, TEST_Z);
|
||||
DeferredPlacementRecords.invalidate(engine, changes);
|
||||
later(player, world, () -> {
|
||||
require(furnitureAt(world, 20) == 0, "Persisted furniture ownership was not invalidated after restart");
|
||||
require(furnitureAt(world, 10) == 1, "Restart cleanup removed untouched furniture");
|
||||
player.sendMessage("CE_RESTART removed=1 untouched=1");
|
||||
});
|
||||
}
|
||||
|
||||
private void queue(Engine engine, World world, int x, String identifier) {
|
||||
ExternalDataSVC service = IrisServices.get(ExternalDataSVC.class);
|
||||
BlockData data = service.getBlockData(Identifier.fromString(identifier)).orElseThrow();
|
||||
require(data instanceof IrisCustomData, "Iris did not resolve real CraftEngine content: " + identifier);
|
||||
IrisCustomData custom = (IrisCustomData) data;
|
||||
world.getBlockAt(x, TEST_Y, TEST_Z).setBlockData(custom.getBase(), false);
|
||||
engine.getMantle().getMantle().set(x, TEST_Y - engine.getMinHeight(), TEST_Z, custom.getCustom());
|
||||
}
|
||||
|
||||
private <T> T record(Engine engine, int x, Class<T> type) {
|
||||
return engine.getMantle().getMantle().get(x, TEST_Y - engine.getMinHeight(), TEST_Z, type);
|
||||
}
|
||||
|
||||
private void replay(Engine engine, World world, int chunkX) {
|
||||
Mantle<Matter> mantle = engine.getMantle().getMantle();
|
||||
for (int x = chunkX - 1; x <= chunkX + 1; x++) {
|
||||
for (int z = -1; z <= 1; z++) {
|
||||
require(world.isChunkLoaded(x, z), "Replay neighbor is not loaded: " + x + "," + z);
|
||||
}
|
||||
}
|
||||
require(mantle.isLoaded(world.getChunkAt(chunkX, 0)), "Replay mantle chunk is not loaded: " + chunkX);
|
||||
mantle.flag(chunkX, 0, MantleFlag.ETCHED, false);
|
||||
mantle.flag(chunkX, 0, MantleFlag.TILE, false);
|
||||
mantle.flag(chunkX, 0, MantleFlag.CUSTOM, false);
|
||||
EngineBukkitOps.updateChunk(engine, world.getChunkAt(chunkX, 0), mantle);
|
||||
}
|
||||
|
||||
private int furnitureAt(World world, int x) {
|
||||
int count = 0;
|
||||
for (Entity entity : world.getChunkAt(x >> 4, TEST_Z >> 4).getEntities()) {
|
||||
Location location = entity.getLocation();
|
||||
if (location.getBlockX() == x && location.getBlockY() == TEST_Y && location.getBlockZ() == TEST_Z
|
||||
&& CraftEngineFurniture.getLoadedFurnitureByMetaEntity(entity) != null) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private void later(Player player, World world, Runnable assertion) {
|
||||
Bukkit.getRegionScheduler().runDelayed(this, new Location(world, 8, TEST_Y, TEST_Z), task -> {
|
||||
try {
|
||||
assertion.run();
|
||||
} catch (Throwable failure) {
|
||||
fail(player, failure);
|
||||
}
|
||||
}, 20);
|
||||
}
|
||||
|
||||
private NativeBlockChangeSet.Bounds bounds(World world) {
|
||||
return new NativeBlockChangeSet.Bounds(-1, -1, 3, 1, world.getMinHeight(), world.getMaxHeight() - world.getMinHeight());
|
||||
}
|
||||
|
||||
private void require(boolean condition, String message) {
|
||||
if (!condition) {
|
||||
throw new IllegalStateException(message);
|
||||
}
|
||||
}
|
||||
|
||||
private void fail(CommandSender sender, Throwable failure) {
|
||||
getLogger().log(Level.SEVERE, "CraftEngine native placement assertion failed", failure);
|
||||
sender.sendMessage("CE_FAILED " + failure.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "Flat",
|
||||
"derivative": "minecraft:plains",
|
||||
"vanillaDerivative": "minecraft:plains",
|
||||
"layers": [
|
||||
{
|
||||
"minHeight": 1,
|
||||
"maxHeight": 1,
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:lime_wool"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"minHeight": 384,
|
||||
"maxHeight": 384,
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:green_concrete"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"generators": [
|
||||
{
|
||||
"generator": "flat",
|
||||
"min": 96,
|
||||
"max": 96
|
||||
}
|
||||
]
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "CraftEngine Native Fixture",
|
||||
"version": 1,
|
||||
"mode": {
|
||||
"type": "OVERWORLD"
|
||||
},
|
||||
"regions": [
|
||||
"flat"
|
||||
],
|
||||
"focusRegion": "flat",
|
||||
"focus": "flat",
|
||||
"landChance": 1,
|
||||
"environment": "NORMAL",
|
||||
"dimensionHeight": {
|
||||
"min": -64,
|
||||
"max": 320
|
||||
},
|
||||
"logicalHeight": 384,
|
||||
"fluidHeight": 0,
|
||||
"bedrock": false,
|
||||
"rockPalette": {
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:red_concrete"
|
||||
}
|
||||
]
|
||||
},
|
||||
"importedStructures": {
|
||||
"disabled": [
|
||||
"minecraft:"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"interpolator": {
|
||||
"function": "NONE",
|
||||
"horizontalScale": 1
|
||||
},
|
||||
"seed": 310,
|
||||
"composite": [
|
||||
{
|
||||
"seed": 310,
|
||||
"style": {
|
||||
"style": "FLAT"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "Flat",
|
||||
"landBiomes": [
|
||||
"flat"
|
||||
],
|
||||
"seaBiomes": [
|
||||
"flat"
|
||||
],
|
||||
"shoreBiomes": [
|
||||
"flat"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
export default {
|
||||
name: 'iris-craftengine-native-restart',
|
||||
description: 'Verify persisted generated furniture ownership after a full server restart.',
|
||||
async run(context) {
|
||||
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
|
||||
context.expect(/^isolated=true\s*$/m.test(source), 'CraftEngine regression requires an isolated instance')
|
||||
await context.step('native writes remove persisted ownership after restart', async () => {
|
||||
const response = await context.command('/iriscenative restart', /CE_RESTART|CE_FAILED/, 60000)
|
||||
context.expect(response.includes('CE_RESTART removed=1 untouched=1'), response)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
export default {
|
||||
name: 'iris-craftengine-native',
|
||||
description: 'Verify deferred native invalidation and loaded neighbor cleanup with real Iris and CraftEngine.',
|
||||
async run(context) {
|
||||
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
|
||||
context.expect(/^isolated=true\s*$/m.test(source), 'CraftEngine regression requires an isolated instance')
|
||||
await context.step('create and enter a real Iris world with CraftEngine content', async () => {
|
||||
const response = await context.command('/iriscenative prepare', /CE_READY|CE_FAILED/, 180000)
|
||||
context.expect(response.includes('CE_READY'), response)
|
||||
})
|
||||
await context.step('native writes remove pending block, chest, and flatten furniture records', async () => {
|
||||
const response = await context.command('/iriscenative pending', /CE_PENDING|CE_FAILED/, 60000)
|
||||
context.expect(response.includes('CE_PENDING intact=1 air=1 wall=1 invalidated=1 flatten=1 chest=1 furniture=1'), response)
|
||||
await context.waitUntil(() => {
|
||||
const origin = context.bot.entity.position.floored()
|
||||
const cut = context.bot.blockAt(origin.set(4, 180, 8))
|
||||
const wall = context.bot.blockAt(origin.set(6, 180, 8))
|
||||
const invalidated = context.bot.blockAt(origin.set(8, 180, 8))
|
||||
const chest = context.bot.blockAt(origin.set(14, 180, 8))
|
||||
return cut?.name === 'air' && wall?.name === 'cobblestone'
|
||||
&& invalidated?.name === 'air' && chest?.name === 'cobblestone'
|
||||
}, { label: 'Client observes retained native air and walls', timeoutMs: 30000, intervalMs: 100 })
|
||||
})
|
||||
await context.step('regeneration removes target furniture and preserves neighbor ownership', async () => {
|
||||
const ready = await context.command('/iriscenative neighbor', /CE_NEIGHBOR_READY|CE_FAILED/, 30000)
|
||||
context.expect(ready.includes('CE_NEIGHBOR_READY chunk=2,0 furniture=3 target=1'), ready)
|
||||
const regenerated = await context.command('/iris developer regen radius=0', /Regen complete:|Regen failed:/, 90000)
|
||||
context.expect(regenerated.includes('Regen complete:'), regenerated)
|
||||
const response = await context.command('/iriscenative loaded', /CE_LOADED|CE_FAILED/, 30000)
|
||||
context.expect(response.includes('CE_LOADED removed=1 untouched=1 replacement=1 wall=1 target=1'), response)
|
||||
})
|
||||
await context.step('imported snow features replace generated furniture records', async () => {
|
||||
const response = await context.command('/iriscenative features', /CE_FEATURES|CE_FAILED/, 180000)
|
||||
context.expect(response.includes('CE_FEATURES snow=256 pending=0 furniture=0'), response)
|
||||
await context.waitUntil(() => {
|
||||
const position = context.bot.entity.position.floored()
|
||||
for (let x = 0; x < 16; x++) {
|
||||
for (let z = 0; z < 16; z++) {
|
||||
if (context.bot.blockAt(position.set(x, 97, z))?.name !== 'snow') return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}, { label: 'Client observes 256 imported snow layers', timeoutMs: 30000, intervalMs: 100 })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ name: IrisNativeLifecycleFixture
|
||||
version: 1.0.0
|
||||
main: art.arcane.iris.fixture.NativeLifecycleFixture
|
||||
api-version: '26.1.2'
|
||||
folia-supported: true
|
||||
depend: [Iris]
|
||||
commands:
|
||||
irislifecycle:
|
||||
|
||||
+213
-6
@@ -1,8 +1,12 @@
|
||||
package art.arcane.iris.fixture;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.volmlib.nativelib.NativeAdapters;
|
||||
import art.arcane.iris.world.runtime.RuntimeInjection;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.structure.placement.IrisStructureLocator;
|
||||
import art.arcane.iris.world.IrisCreator;
|
||||
import art.arcane.iris.world.task.J;
|
||||
import art.arcane.iris.world.IrisToolbelt;
|
||||
import art.arcane.iris.world.IrisWorldStorage;
|
||||
import art.arcane.iris.world.WorldCreatorCompat;
|
||||
@@ -13,6 +17,15 @@ import org.bukkit.entity.Player;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.Biome;
|
||||
import org.bukkit.block.Container;
|
||||
import org.bukkit.loot.Lootable;
|
||||
import org.bukkit.loot.LootTable;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.MapMeta;
|
||||
import org.bukkit.map.MapView;
|
||||
import org.bukkit.generator.structure.Structure;
|
||||
import org.bukkit.util.StructureSearchResult;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeStructureReader;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorldRuntime;
|
||||
@@ -38,12 +51,15 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
||||
private static final NamespacedKey WORLD_KEY = NamespacedKey.minecraft("native_lifecycle_probe");
|
||||
private static final NamespacedKey TERRAIN_KEY = new NamespacedKey("iris", "native_terrain_probe");
|
||||
private static final NamespacedKey COMMAND_KEY = new NamespacedKey("iris", "lifecycle_probe");
|
||||
private static final NamespacedKey TREASURE_KEY = new NamespacedKey("iris", "treasure_map_probe");
|
||||
private static final long WORLD_SEED = 78264193L;
|
||||
private NativeWorldRuntime runtime;
|
||||
private TreasureLootChecks treasureLootChecks;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
runtime = NativeAdapters.require(NativeWorldRuntime.class);
|
||||
treasureLootChecks = new TreasureLootChecks(this);
|
||||
NativeTerrainAccess terrain = NativeAdapters.require(NativeTerrainAccess.class);
|
||||
getLogger().info("Native lifecycle ready: " + runtime.description()
|
||||
+ "; terrain provider=" + terrain.getClass().getName());
|
||||
@@ -56,12 +72,32 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||
if (arguments.length != 1) {
|
||||
if (arguments.length == 0) {
|
||||
sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload|lifecycle-status|lifecycle-entry");
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
switch (arguments[0]) {
|
||||
case "treasure-map-create" -> createTreasureMapWorld(sender);
|
||||
case "treasure-map-limit" -> runTreasureRegion(sender, () -> checkTreasureMapLimit(sender));
|
||||
case "treasure-map-chest" -> {
|
||||
if (arguments.length != 3) {
|
||||
throw new IllegalArgumentException("Use treasure-map-chest <destination> <CHEST|COPPER_CHEST>");
|
||||
}
|
||||
runTreasureRegion(sender, () -> prepareTreasureMapChest(sender, arguments[1], arguments[2]));
|
||||
}
|
||||
case "treasure-map-inspect" -> {
|
||||
if (arguments.length != 2) {
|
||||
throw new IllegalArgumentException("Use treasure-map-inspect <destination>");
|
||||
}
|
||||
runTreasureRegion(sender, () -> inspectTreasureMapChest(sender, arguments[1]));
|
||||
}
|
||||
case "treasure-map-check" -> runTreasureRegion(sender, () -> verifyTreasureMapLoot(sender));
|
||||
case "treasure-extra-check" -> runTreasureRegion(sender, () -> treasureLootChecks.verify(sender));
|
||||
case "treasure-region-check" -> runTreasureRegion(sender, () -> treasureLootChecks.verifyRegion(sender));
|
||||
case "treasure-reload-before" -> runTreasureRegion(sender, () -> treasureLootChecks.verifyReload(sender, false));
|
||||
case "treasure-reload-after" -> runTreasureRegion(sender, () -> treasureLootChecks.verifyReload(sender, true));
|
||||
case "treasure-resources" -> treasureLootChecks.reportResourceReloads(sender);
|
||||
case "create" -> create(sender);
|
||||
case "unload" -> unload(sender);
|
||||
case "structures" -> structures(sender);
|
||||
@@ -80,6 +116,177 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
||||
return true;
|
||||
}
|
||||
|
||||
private void createTreasureMapWorld(CommandSender sender) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
throw new IllegalStateException("Treasure map fixture requires a player");
|
||||
}
|
||||
runTreasureRegion(sender, () -> prepareTreasureMapWorld(player));
|
||||
}
|
||||
|
||||
private void runTreasureRegion(CommandSender sender, Runnable task) {
|
||||
ensureTreasureMapWorld()
|
||||
.thenCompose(ignored -> J.runRegionFuture(Bukkit.getWorld(TREASURE_KEY), 0, 0, task))
|
||||
.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) {
|
||||
fail(sender, failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private CompletableFuture<?> ensureTreasureMapWorld() {
|
||||
if (Bukkit.getWorld(TREASURE_KEY) != null) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
if (WorldCreatorCompat.persistentLevelRoot(TREASURE_KEY).exists()) {
|
||||
return Iris.instance.worldReconciler().loadWorld(ServerProperties.BUKKIT_YML, TREASURE_KEY.toString());
|
||||
}
|
||||
return runAsync(() -> {
|
||||
try {
|
||||
new IrisCreator().name(TREASURE_KEY.toString()).dimension("treasure-map-fixture")
|
||||
.seed(WORLD_SEED).create();
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("Treasure map fixture creation failed", failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void prepareTreasureMapWorld(Player player) {
|
||||
World world = Bukkit.getWorld(TREASURE_KEY);
|
||||
if (world == null || !IrisToolbelt.isIrisWorld(world) || world.getSeed() != WORLD_SEED
|
||||
|| !"treasure-map-fixture".equals(IrisToolbelt.access(world).getEngine().getDimension().getLoadKey())) {
|
||||
throw new IllegalStateException("Treasure map fixture world identity is invalid");
|
||||
}
|
||||
for (int x = 0; x <= 8; x++) {
|
||||
for (int z = 0; z <= 8; z++) {
|
||||
world.getBlockAt(x, 160, z).setType(Material.STONE, false);
|
||||
for (int y = 161; y <= 164; y++) {
|
||||
world.getBlockAt(x, y, z).setType(Material.AIR, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 0.5, 161, 0.5)).whenComplete((teleported, failure) -> {
|
||||
if (failure != null) {
|
||||
fail(player, failure);
|
||||
} else if (!Boolean.TRUE.equals(teleported)) {
|
||||
fail(player, new IllegalStateException("Treasure map fixture teleport failed"));
|
||||
} else {
|
||||
player.sendMessage("TREASURE_MAP_READY world=" + TREASURE_KEY
|
||||
+ " version=" + Bukkit.getMinecraftVersion() + " folia=" + J.isFolia());
|
||||
}
|
||||
}))) {
|
||||
throw new IllegalStateException("Could not schedule treasure map fixture teleport");
|
||||
}
|
||||
}
|
||||
|
||||
private void checkTreasureMapLimit(CommandSender sender) {
|
||||
World world = Bukkit.getWorld(TREASURE_KEY);
|
||||
if (world == null) {
|
||||
throw new IllegalStateException("Treasure map fixture world is absent");
|
||||
}
|
||||
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||
IrisStructureLocator.LocateResult located = IrisStructureLocator.locateInPlacementRings(
|
||||
engine, "minecraft:buried_treasure", 2, 2, 50, (x, z) -> true);
|
||||
if (located.status() != IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) {
|
||||
throw new IllegalStateException("Blocked treasure placement returned " + located.status());
|
||||
}
|
||||
sender.sendMessage("TREASURE_MAP_LIMIT status=" + located.status());
|
||||
}
|
||||
|
||||
private void prepareTreasureMapChest(CommandSender sender, String destination, String material) {
|
||||
World world = Bukkit.getWorld(TREASURE_KEY);
|
||||
treasureMapChest(world, destination, material);
|
||||
sender.sendMessage("TREASURE_MAP_CHEST destination=" + destination + " material=" + material);
|
||||
}
|
||||
|
||||
private Container treasureMapChest(World world, String destination, String material) {
|
||||
requireTreasureDestination(destination);
|
||||
if (!List.of("CHEST", "COPPER_CHEST").contains(material)) {
|
||||
throw new IllegalArgumentException("Unknown treasure map container: " + material);
|
||||
}
|
||||
LootTable loot = Bukkit.getLootTable(new NamespacedKey("iris_qa", destination));
|
||||
if (loot == null) {
|
||||
throw new IllegalStateException("Treasure map loot table is absent: " + destination);
|
||||
}
|
||||
world.getBlockAt(2, 161, 2).setType(Material.AIR, false);
|
||||
world.getBlockAt(2, 161, 2).setType(Material.valueOf(material), false);
|
||||
Container chest = (Container) world.getBlockAt(2, 161, 2).getState();
|
||||
((Lootable) chest).setLootTable(loot);
|
||||
((Lootable) chest).setSeed(WORLD_SEED);
|
||||
if (!chest.update(true, false)) {
|
||||
throw new IllegalStateException("Treasure map loot chest was not installed");
|
||||
}
|
||||
return (Container) world.getBlockAt(2, 161, 2).getState();
|
||||
}
|
||||
|
||||
private void verifyTreasureMapLoot(CommandSender sender) {
|
||||
checkTreasureMapLimit(sender);
|
||||
World world = Bukkit.getWorld(TREASURE_KEY);
|
||||
for (String destination : List.of("blocked", "disabled", "found", "mixed")) {
|
||||
Inventory inventory = treasureMapChest(world, destination, "CHEST").getInventory();
|
||||
inspectTreasureMapChest(sender, destination);
|
||||
inventory.remove(Material.DIAMOND);
|
||||
Inventory reopened = ((Container) world.getBlockAt(2, 161, 2).getState()).getInventory();
|
||||
if (reopened.contains(Material.DIAMOND)
|
||||
|| !(reopened.contains(Material.FILLED_MAP) || reopened.contains(Material.MAP))) {
|
||||
throw new IllegalStateException("Reopening changed " + destination + " chest loot");
|
||||
}
|
||||
}
|
||||
sender.sendMessage("TREASURE_MAP_CHECK destinations=4 retained=true");
|
||||
}
|
||||
|
||||
private void inspectTreasureMapChest(CommandSender sender, String destination) {
|
||||
requireTreasureDestination(destination);
|
||||
World world = Bukkit.getWorld(TREASURE_KEY);
|
||||
Inventory inventory = ((Container) world.getBlockAt(2, 161, 2).getState()).getInventory();
|
||||
ItemStack map = null;
|
||||
int diamonds = 0;
|
||||
for (ItemStack item : inventory.getContents()) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if ((item.getType() == Material.MAP || item.getType() == Material.FILLED_MAP)
|
||||
&& item.getAmount() == 1 && map == null) {
|
||||
map = item;
|
||||
} else if (item.getType() == Material.DIAMOND) {
|
||||
diamonds += item.getAmount();
|
||||
} else {
|
||||
throw new IllegalStateException("Unexpected chest loot: " + item);
|
||||
}
|
||||
}
|
||||
if (map == null || diamonds != 1) {
|
||||
throw new IllegalStateException("Invalid " + destination + " chest: map=" + map + " diamonds=" + diamonds);
|
||||
}
|
||||
boolean expectedLocated = destination.equals("found") || destination.equals("mixed");
|
||||
MapView view = map.getItemMeta() instanceof MapMeta meta && meta.hasMapView() ? meta.getMapView() : null;
|
||||
if ((view != null) != expectedLocated) {
|
||||
throw new IllegalStateException("Unexpected map data for " + destination + ": " + map);
|
||||
}
|
||||
String result = "TREASURE_MAP_INSPECT destination=" + destination + " diamonds=1 located=" + expectedLocated;
|
||||
if (view != null) {
|
||||
StructureSearchResult target = world.locateNearestStructure(
|
||||
new Location(world, 2, 161, 2), Structure.DESERT_PYRAMID, 50, false);
|
||||
if (target == null || target.getStructure() != Structure.DESERT_PYRAMID) {
|
||||
throw new IllegalStateException("Map has no verified pyramid target");
|
||||
}
|
||||
int targetX = target.getLocation().getBlockX();
|
||||
int targetZ = target.getLocation().getBlockZ();
|
||||
if (view.getWorld() != world || view.getScale() != MapView.Scale.CLOSEST
|
||||
|| view.getCenterX() != Math.floorDiv(targetX + 64, 128) * 128
|
||||
|| view.getCenterZ() != Math.floorDiv(targetZ + 64, 128) * 128) {
|
||||
throw new IllegalStateException("Map data does not cover the verified pyramid at " + target.getLocation());
|
||||
}
|
||||
result += " mapId=" + view.getId() + " targetX=" + targetX + " targetZ=" + targetZ
|
||||
+ " centerX=" + view.getCenterX() + " centerZ=" + view.getCenterZ();
|
||||
}
|
||||
sender.sendMessage(result);
|
||||
}
|
||||
|
||||
private void requireTreasureDestination(String destination) {
|
||||
if (!List.of("blocked", "disabled", "found", "mixed").contains(destination)) {
|
||||
throw new IllegalArgumentException("Unknown treasure map destination: " + destination);
|
||||
}
|
||||
}
|
||||
|
||||
private void lifecycleStatus(CommandSender sender) throws Exception {
|
||||
World world = Bukkit.getWorld(COMMAND_KEY);
|
||||
Path storage = WorldCreatorCompat.persistentLevelRoot(COMMAND_KEY).toPath();
|
||||
@@ -282,11 +489,11 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
||||
}
|
||||
|
||||
public boolean regionized() {
|
||||
return false;
|
||||
return J.isFolia();
|
||||
}
|
||||
|
||||
public boolean primaryThread() {
|
||||
return Bukkit.isPrimaryThread();
|
||||
return J.isFolia() ? Bukkit.isGlobalTickThread() : Bukkit.isPrimaryThread();
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> runGlobal(Runnable task) {
|
||||
@@ -299,10 +506,10 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
||||
result.completeExceptionally(failure);
|
||||
}
|
||||
};
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
if (primaryThread()) {
|
||||
wrapped.run();
|
||||
} else {
|
||||
Bukkit.getScheduler().runTask(this, wrapped);
|
||||
} else if (!J.runGlobal(wrapped)) {
|
||||
result.completeExceptionally(new IllegalStateException("Could not schedule fixture task"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
package art.arcane.iris.fixture;
|
||||
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.platform.bukkit.BukkitBlockResolution;
|
||||
import art.arcane.iris.platform.generation.EngineBukkitOps;
|
||||
import art.arcane.iris.world.IrisToolbelt;
|
||||
import art.arcane.iris.world.loot.InventorySlotType;
|
||||
import art.arcane.iris.world.loot.IrisVanillaLootTable;
|
||||
import art.arcane.iris.world.loot.IrisLootTable;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.iris.world.task.J;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import io.papermc.paper.event.world.StructuresLocateEvent;
|
||||
import io.papermc.paper.event.server.ServerResourcesReloadedEvent;
|
||||
import io.papermc.paper.math.Position;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.Container;
|
||||
import org.bukkit.block.Chest;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.world.LootGenerateEvent;
|
||||
import org.bukkit.generator.structure.Structure;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.util.StructureSearchResult;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public final class TreasureLootChecks implements Listener {
|
||||
private static final NamespacedKey WORLD_KEY = new NamespacedKey("iris", "treasure_map_probe");
|
||||
private static final List<String> CHESTS = List.of(
|
||||
"CHEST", "COPPER_CHEST", "EXPOSED_COPPER_CHEST", "WEATHERED_COPPER_CHEST",
|
||||
"OXIDIZED_COPPER_CHEST", "WAXED_COPPER_CHEST", "WAXED_EXPOSED_COPPER_CHEST",
|
||||
"WAXED_WEATHERED_COPPER_CHEST", "WAXED_OXIDIZED_COPPER_CHEST");
|
||||
private final IrisVanillaLootTable reloadTable = new IrisVanillaLootTable("iris_qa:reload");
|
||||
private final AtomicInteger resourceReloads = new AtomicInteger();
|
||||
private LocateAction locateAction = LocateAction.PASS;
|
||||
private int locateEvents;
|
||||
private int lootEvents;
|
||||
private boolean lootEventsOwned = true;
|
||||
|
||||
public TreasureLootChecks(JavaPlugin plugin) {
|
||||
Bukkit.getPluginManager().registerEvents(this, plugin);
|
||||
}
|
||||
|
||||
public void verify(CommandSender sender) {
|
||||
World world = requireWorld();
|
||||
verifyCopperLoot(world);
|
||||
sender.sendMessage("TREASURE_COPPER_LOOT variants=8 normal=true diamonds=1");
|
||||
verifyInventoryRollback(world);
|
||||
sender.sendMessage("TREASURE_LOOT_ROLLBACK contents=true nativeTable=true seed=true retry=true");
|
||||
verifyLocateEvents(world);
|
||||
sender.sendMessage("TREASURE_LOCATE_EVENTS cancelled=true replaced=true calls=2");
|
||||
sender.sendMessage("TREASURE_EXTRA_CHECK variants=9 rollback=true locateEvents=true");
|
||||
}
|
||||
|
||||
public void verifyRegion(CommandSender sender) {
|
||||
World world = requireWorld();
|
||||
if (!J.isOwnedByCurrentRegion(world, 0, 0)) {
|
||||
throw new IllegalStateException("Fixture does not own its loot region");
|
||||
}
|
||||
lootEvents = 0;
|
||||
lootEventsOwned = true;
|
||||
verifyCopperLoot(world);
|
||||
if (lootEvents != CHESTS.size() || !lootEventsOwned) {
|
||||
throw new IllegalStateException("Loot event ownership failed: count=" + lootEvents
|
||||
+ " owned=" + lootEventsOwned);
|
||||
}
|
||||
sender.sendMessage("TREASURE_REGION_LOOT copper=8 normal=1 events=" + lootEvents
|
||||
+ " owned=true folia=" + J.isFolia());
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onLoot(LootGenerateEvent event) {
|
||||
if (event.getWorld().getKey().equals(WORLD_KEY) && event.isPlugin()) {
|
||||
Location origin = event.getLootContext().getLocation();
|
||||
lootEvents++;
|
||||
lootEventsOwned &= J.isOwnedByCurrentRegion(event.getWorld(),
|
||||
origin.getBlockX() >> 4, origin.getBlockZ() >> 4);
|
||||
}
|
||||
}
|
||||
|
||||
public void verifyReload(CommandSender sender, boolean reloaded) {
|
||||
World world = requireWorld();
|
||||
Material expected = reloaded ? Material.EMERALD : Material.DIAMOND;
|
||||
Collection<ItemStack> items = reloadTable.getLoot(
|
||||
false, 783L, InventorySlotType.STORAGE, world, 2, 161, 2);
|
||||
if (items.size() != 1 || items.iterator().next().getType() != expected) {
|
||||
throw new IllegalStateException("Reloaded loot did not contain " + expected + ": " + items);
|
||||
}
|
||||
sender.sendMessage("TREASURE_RELOAD material=" + expected);
|
||||
}
|
||||
|
||||
public void reportResourceReloads(CommandSender sender) {
|
||||
sender.sendMessage("TREASURE_RESOURCES revision=" + resourceReloads.get());
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onResourcesReloaded(ServerResourcesReloadedEvent event) {
|
||||
resourceReloads.incrementAndGet();
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onLocate(StructuresLocateEvent event) {
|
||||
if (!event.getWorld().getKey().equals(WORLD_KEY) || locateAction == LocateAction.PASS) {
|
||||
return;
|
||||
}
|
||||
locateEvents++;
|
||||
if (locateAction == LocateAction.CANCEL) {
|
||||
event.setCancelled(true);
|
||||
} else {
|
||||
event.setResult(new StructuresLocateEvent.Result(Position.block(123, 100, 456), Structure.DESERT_PYRAMID));
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyCopperLoot(World world) {
|
||||
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||
Block block = world.getBlockAt(4, 161, 4);
|
||||
MantleChunk<Matter> mantle = engine.getMantle().getMantle().useChunk(block.getChunk());
|
||||
try {
|
||||
for (String name : CHESTS) {
|
||||
block.setType(Material.AIR, false);
|
||||
block.setType(Material.valueOf(name), false);
|
||||
if (!BukkitBlockResolution.isStorageChest(block.getBlockData())) {
|
||||
throw new IllegalStateException("Chest is excluded from Iris loot: " + name);
|
||||
}
|
||||
EngineBukkitOps.update(engine, 4, 161, 4, block.getChunk(), mantle);
|
||||
Container chest = (Container) block.getState();
|
||||
int diamonds = 0;
|
||||
for (ItemStack item : chest.getInventory().getContents()) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if (item.getType() != Material.DIAMOND) {
|
||||
throw new IllegalStateException("Unexpected " + name + " loot: " + item);
|
||||
}
|
||||
diamonds += item.getAmount();
|
||||
}
|
||||
if (diamonds != 1) {
|
||||
throw new IllegalStateException(name + " contains " + diamonds + " diamonds");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
mantle.release();
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyInventoryRollback(World world) {
|
||||
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||
Block block = world.getBlockAt(6, 161, 4);
|
||||
block.setType(Material.AIR, false);
|
||||
block.setType(Material.CHEST, false);
|
||||
Chest chest = (Chest) block.getState();
|
||||
chest.getBlockInventory().setItem(0, new ItemStack(Material.EMERALD, 3));
|
||||
chest = (Chest) block.getState();
|
||||
NamespacedKey key = new NamespacedKey("iris_qa", "reload");
|
||||
chest.setLootTable(Bukkit.getLootTable(key));
|
||||
chest.setSeed(913L);
|
||||
if (!chest.update(true, false)) {
|
||||
throw new IllegalStateException("Could not set native loot for rollback check");
|
||||
}
|
||||
Inventory inventory = chest.getBlockInventory();
|
||||
IllegalStateException injected = new IllegalStateException("Fixture insertion interruption");
|
||||
Inventory interrupted = (Inventory) Proxy.newProxyInstance(Inventory.class.getClassLoader(),
|
||||
new Class<?>[]{Inventory.class}, (proxy, method, arguments) -> {
|
||||
Object result;
|
||||
try {
|
||||
result = method.invoke(inventory, arguments);
|
||||
} catch (InvocationTargetException failure) {
|
||||
throw failure.getCause();
|
||||
}
|
||||
if (method.getName().equals("addItem")) {
|
||||
throw injected;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
KList<IrisLootTable> tables = new KList<>();
|
||||
tables.add(new IrisVanillaLootTable("iris_qa:reload"));
|
||||
try {
|
||||
EngineBukkitOps.addItems(engine, false, interrupted, tables, InventorySlotType.STORAGE,
|
||||
world, 6, 161, 4);
|
||||
throw new IllegalStateException("Interrupted insertion did not fail");
|
||||
} catch (IllegalStateException failure) {
|
||||
if (failure != injected) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
Chest restored = (Chest) block.getState();
|
||||
if (restored.getLootTable() == null || !restored.getLootTable().getKey().equals(key)
|
||||
|| restored.getSeed() != 913L) {
|
||||
throw new IllegalStateException("Rollback lost the native loot table or seed");
|
||||
}
|
||||
ItemStack existing = restored.getSnapshotInventory().getItem(0);
|
||||
if (existing == null || existing.getType() != Material.EMERALD || existing.getAmount() != 3) {
|
||||
throw new IllegalStateException("Rollback lost the original contents: " + existing);
|
||||
}
|
||||
EngineBukkitOps.addItems(engine, false, restored.getBlockInventory(), tables, InventorySlotType.STORAGE,
|
||||
world, 6, 161, 4);
|
||||
int diamonds = 0;
|
||||
int emeralds = 0;
|
||||
for (ItemStack item : restored.getBlockInventory().getContents()) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if (item.getType() == Material.DIAMOND) {
|
||||
diamonds += item.getAmount();
|
||||
} else if (item.getType() == Material.EMERALD) {
|
||||
emeralds += item.getAmount();
|
||||
}
|
||||
}
|
||||
if (diamonds != 2 || emeralds != 3 || ((Chest) block.getState()).getLootTable() != null) {
|
||||
throw new IllegalStateException("Loot retry failed: diamonds=" + diamonds + " emeralds=" + emeralds);
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyLocateEvents(World world) {
|
||||
Location origin = new Location(world, 2, 161, 2);
|
||||
locateEvents = 0;
|
||||
try {
|
||||
locateAction = LocateAction.CANCEL;
|
||||
if (world.locateNearestStructure(origin, Structure.DESERT_PYRAMID, 50, false) != null) {
|
||||
throw new IllegalStateException("Cancelled structure search returned a target");
|
||||
}
|
||||
locateAction = LocateAction.REPLACE;
|
||||
StructureSearchResult result = world.locateNearestStructure(origin, Structure.DESERT_PYRAMID, 50, false);
|
||||
if (result == null || result.getStructure() != Structure.DESERT_PYRAMID
|
||||
|| result.getLocation().getBlockX() != 123 || result.getLocation().getBlockY() != 100
|
||||
|| result.getLocation().getBlockZ() != 456 || locateEvents != 2) {
|
||||
throw new IllegalStateException("Structure search ignored event result: " + result);
|
||||
}
|
||||
} finally {
|
||||
locateAction = LocateAction.PASS;
|
||||
}
|
||||
}
|
||||
|
||||
private World requireWorld() {
|
||||
World world = Bukkit.getWorld(WORLD_KEY);
|
||||
if (world == null || !IrisToolbelt.isIrisWorld(world)) {
|
||||
throw new IllegalStateException("Treasure map fixture world is absent");
|
||||
}
|
||||
return world;
|
||||
}
|
||||
|
||||
private enum LocateAction {
|
||||
PASS, CANCEL, REPLACE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const world = 'native-terrain-support'
|
||||
const air = new Set(['air', 'cave_air', 'void_air'])
|
||||
|
||||
export default {
|
||||
name: 'iris-native-terrain-support',
|
||||
description: 'Verify native terrain fitting preserves buried caves and excludes trees from lowered ground.',
|
||||
async run(context) {
|
||||
const bot = context.bot
|
||||
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
|
||||
context.expect(/^isolated=true\s*$/m.test(source), 'Requires an isolated instance')
|
||||
const position = bot.entity.position.clone()
|
||||
const at = (x, y, z) => bot.blockAt(position.clone().set(x, y, z))
|
||||
const teleport = async (x, y, z) => {
|
||||
await context.command(`/minecraft:tp @s ${x + 0.5} ${y} ${z + 0.5}`, /Teleported/i, 60000)
|
||||
await context.waitUntil(() => Math.abs(bot.entity.position.x - x - 0.5) < 1
|
||||
&& Math.abs(bot.entity.position.z - z - 0.5) < 1 && at(x, y, z),
|
||||
{ label: 'Coordinate chunk received', timeoutMs: 90000, intervalMs: 100 })
|
||||
}
|
||||
await context.step('enter the seeded overworld', async () => {
|
||||
if (context.options.command !== 'reuse') {
|
||||
await context.command(`/iris create name=${world} type=overworld seed=1337`,
|
||||
/Successfully created your world/i, 240000)
|
||||
}
|
||||
await context.command(`/iris tp ${world}`)
|
||||
await context.waitUntil(async () => {
|
||||
const snapshot = await context.observe()
|
||||
const player = snapshot.players.find(entry => entry.username === bot.username)
|
||||
return snapshot.worlds.some(entry => entry.id === player?.world && entry.name.endsWith(world))
|
||||
}, { label: 'Entered seeded overworld', timeoutMs: 90000, intervalMs: 100 })
|
||||
await context.command('/minecraft:gamemode spectator @s', /Spectator Mode/i)
|
||||
await context.command('/minecraft:seed', /Seed:.*1337/)
|
||||
})
|
||||
await context.step('preserve caves beneath the ruined portal terrain skirt', async () => {
|
||||
await teleport(995, 44, -3611)
|
||||
await context.waitUntil(() => at(990, 43, -3626) && at(992, 43, -3627),
|
||||
{ label: 'Cave columns received', timeoutMs: 90000, intervalMs: 100 })
|
||||
for (const [x, z, bottom, top] of [[990, -3626, 33, 54], [992, -3627, 34, 55]]) {
|
||||
for (let y = bottom; y <= top; y++) {
|
||||
const block = at(x, y, z)
|
||||
context.expect(block && air.has(block.name), 'Native terrain fitting filled a buried cave',
|
||||
{ x, y, z, material: block?.name })
|
||||
}
|
||||
await context.command(`/execute if block ${x} 43 ${z} minecraft:cave_air run say CAVE_PRESERVED_${x}`,
|
||||
new RegExp(`CAVE_PRESERVED_${x}`))
|
||||
}
|
||||
})
|
||||
await context.step('exclude the pine whose support is removed by village grading', async () => {
|
||||
await teleport(-2601, 174, -7063)
|
||||
await context.waitUntil(() => at(-2601, 167, -7063),
|
||||
{ label: 'Pine column received', timeoutMs: 90000, intervalMs: 100 })
|
||||
const block = at(-2601, 167, -7063)
|
||||
context.expect(air.has(block.name), 'Tree remains above terrain removed by native grading',
|
||||
{ x: -2601, y: 167, z: -7063, material: block.name })
|
||||
await context.command('/execute if block -2601 167 -7063 minecraft:air run say PINE_EXCLUDED',
|
||||
/PINE_EXCLUDED/)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "Flat",
|
||||
"derivative": "minecraft:plains",
|
||||
"vanillaDerivative": "minecraft:plains",
|
||||
"layers": [
|
||||
{"minHeight": 1, "maxHeight": 1, "palette": [{"block": "minecraft:lime_wool"}]},
|
||||
{"minHeight": 128, "maxHeight": 128, "palette": [{"block": "minecraft:stone"}]}
|
||||
],
|
||||
"generators": [{"generator": "flat", "min": 32, "max": 32}],
|
||||
"objects": [{
|
||||
"place": ["smolshroom1"],
|
||||
"chance": 1,
|
||||
"density": 8,
|
||||
"mode": "ORGANIC_STILT",
|
||||
"rotation": {"enabled": false},
|
||||
"scale": {"size": 0.75, "interpolation": "NONE"},
|
||||
"translate": {"y": 6},
|
||||
"stiltSettings": {"organicMaxScan": 8, "organicJitter": 0, "organicScratch": 0}
|
||||
}]
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "Object Scaling Fixture",
|
||||
"version": 1,
|
||||
"mode": {"type": "OVERWORLD"},
|
||||
"regions": ["flat"],
|
||||
"focusRegion": "flat",
|
||||
"focus": "flat",
|
||||
"landChance": 1,
|
||||
"environment": "NORMAL",
|
||||
"dimensionHeight": {"min": 0, "max": 128},
|
||||
"logicalHeight": 128,
|
||||
"fluidHeight": 0,
|
||||
"bedrock": false,
|
||||
"caveProfile": {"enabled": false}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"seed": 310,
|
||||
"interpolator": {"function": "NONE", "horizontalScale": 1},
|
||||
"composite": [{"seed": 310, "style": {"style": "FLAT"}}]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Flat",
|
||||
"landBiomes": ["flat"],
|
||||
"seaBiomes": ["flat"],
|
||||
"shoreBiomes": ["flat"]
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile, realpath } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
export default {
|
||||
name: 'iris-object-scaling',
|
||||
description: 'Verify fractional mushroom scaling retains its cap and uses its stem for organic supports.',
|
||||
async run(context) {
|
||||
const directory = await realpath(context.server.directory)
|
||||
const source = await readFile(path.join(directory, '.server-source'), 'utf8')
|
||||
context.expect(/^isolated=true\s*$/m.test(source), 'Object scaling requires an isolated instance')
|
||||
const pack = await realpath(path.join(directory, 'plugins', 'Iris', 'packs', 'object-scaling-fixture'))
|
||||
context.expect(pack.startsWith(`${directory}${path.sep}`), 'Object scaling requires a local fixture pack')
|
||||
const object = await readFile(path.join(pack, 'objects', 'smolshroom1.iob'))
|
||||
const objectHash = createHash('sha256').update(object).digest('hex')
|
||||
const biome = JSON.parse(await readFile(path.join(pack, 'biomes', 'flat.json'), 'utf8'))
|
||||
const surfaceY = biome.generators[0].min
|
||||
const originalStemY = surfaceY + biome.objects[0].translate.y
|
||||
const expectedCapY = originalStemY + 1
|
||||
const maximumExtension = biome.objects[0].stiltSettings.organicMaxScan
|
||||
context.expect(surfaceY === 32 && expectedCapY === 39 && maximumExtension === 8,
|
||||
'Expected the bounded organic support fixture', { surfaceY, expectedCapY, maximumExtension })
|
||||
const world = context.options.command ?? 'object_scaling_probe'
|
||||
context.expect(/^[a-z0-9_]+$/.test(world), 'Fixture world name must be a simple identifier')
|
||||
await context.step('create the fractional mushroom fixture world', async () => {
|
||||
const response = await context.command(`/iris create name=${world} type=object-scaling-fixture seed=1337`,
|
||||
/Successfully created your world|World ready|Failed to create|Exception raised/i, 180000)
|
||||
context.expect(/Successfully created your world|World ready/i.test(response), response)
|
||||
await context.command(`/iris tp ${world}`)
|
||||
await context.waitUntil(async () => {
|
||||
const observation = await context.observe()
|
||||
const player = observation.players.find(entry => entry.username === context.bot.username)
|
||||
const current = observation.worlds.find(entry => entry.id === player?.world)
|
||||
return current?.key === `iris:${world}` || current?.name.endsWith(world)
|
||||
}, { label: 'Enter the scaling fixture world', timeoutMs: 60000, intervalMs: 100 })
|
||||
await context.command('/minecraft:gamemode spectator @s')
|
||||
await context.waitUntil(() => context.bot.game.gameMode === 'spectator',
|
||||
{ label: 'Spectator mode', timeoutMs: 30000, intervalMs: 100 })
|
||||
await context.command('/minecraft:tp @s 0.5 48 0.5', /Teleported/i, 30000)
|
||||
})
|
||||
const columns = []
|
||||
await context.step('client sees one cap and continuous stem supports', async () => {
|
||||
const position = context.bot.entity.position.clone()
|
||||
await context.waitUntil(() => {
|
||||
if (Math.abs(context.bot.entity.position.x - 0.5) > 1
|
||||
|| Math.abs(context.bot.entity.position.z - 0.5) > 1) return false
|
||||
for (const x of [-16, 0, 16, 31]) {
|
||||
for (const z of [-16, 0, 16, 31]) {
|
||||
for (const y of [0, 32, 63]) {
|
||||
if (!context.bot.blockAt(position.set(x, y, z))) return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}, { label: 'Complete mushroom fixture chunks', timeoutMs: 60000, intervalMs: 100 })
|
||||
for (let x = -16; x < 32; x++) {
|
||||
for (let z = -16; z < 32; z++) {
|
||||
const caps = []
|
||||
const stems = []
|
||||
for (let y = 1; y < 63; y++) {
|
||||
const block = context.bot.blockAt(position.set(x, y, z))
|
||||
context.expect(block !== null, 'Every sampled mushroom block must be loaded', { x, y, z })
|
||||
if (block.name === 'red_mushroom_block') caps.push(y)
|
||||
if (block.name === 'mushroom_stem') stems.push(y)
|
||||
}
|
||||
if (caps.length === 0) continue
|
||||
context.expect(caps.length === 1, 'Mushroom cap was copied into its stem or support', { x, z, caps })
|
||||
const capY = caps[0]
|
||||
context.expect(capY === expectedCapY, 'Fractional scaling changed the expected mushroom height',
|
||||
{ x, z, capY, expectedCapY })
|
||||
const bottomY = stems[0]
|
||||
const stemBlocks = stems.length
|
||||
context.expect(bottomY >= originalStemY - maximumExtension && bottomY <= surfaceY + 1,
|
||||
'Mushroom support must reach the surface within its configured extension limit',
|
||||
{ x, z, capY, bottomY, surfaceY, maximumExtension, stems })
|
||||
context.expect(stemBlocks === capY - bottomY
|
||||
&& stems.every((y, index) => y === bottomY + index),
|
||||
'Scaled mushroom must have a continuous stem from its cap to the ground', { x, z, capY, stems })
|
||||
const supportY = bottomY - 1
|
||||
const support = context.bot.blockAt(position.set(x, supportY, z))?.name
|
||||
const above = context.bot.blockAt(position.set(x, capY + 1, z))?.name
|
||||
context.expect(support === (supportY === surfaceY ? 'lime_wool' : 'stone'),
|
||||
'Mushroom stem must terminate on the fixture terrain', { x, z, capY, supportY, support })
|
||||
context.expect(above === 'air', 'Scaled mushroom has unexpected blocks above its cap', { x, z, capY, above })
|
||||
columns.push({ x, z, surfaceY, capY, bottomY, stemBlocks,
|
||||
buriedStemBlocks: Math.max(0, surfaceY - bottomY + 1) })
|
||||
}
|
||||
}
|
||||
context.expect(columns.length >= 8, 'Expected at least eight scaled mushroom columns', { columns })
|
||||
})
|
||||
await context.step('server confirms the sampled stem and cap materials', async () => {
|
||||
for (const [index, column] of columns.slice(0, 8).entries()) {
|
||||
const { x, z, surfaceY, capY, bottomY } = column
|
||||
const marker = `IRIS_SCALING_OK_${index}`
|
||||
await context.command(`/minecraft:execute if block ${x} ${surfaceY + 1} ${z} minecraft:mushroom_stem`
|
||||
+ ` if block ${x} ${bottomY} ${z} minecraft:mushroom_stem`
|
||||
+ ` if block ${x} ${capY} ${z} minecraft:red_mushroom_block run minecraft:say ${marker}`,
|
||||
new RegExp(marker), 10000)
|
||||
}
|
||||
})
|
||||
context.report.objectScaling = { world, scale: 0.75, object: 'smolshroom1', objectHash,
|
||||
surfaceY, maximumExtension, checkedColumns: columns.length,
|
||||
serverConfirmedColumns: Math.min(8, columns.length), columns }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const worlds = [
|
||||
{ name: 'seed-selection-default', seed: '1337', argument: '' },
|
||||
{ name: 'seed-selection-positive', seed: '78264193', argument: ' seed=78264193' },
|
||||
{ name: 'seed-selection-negative', seed: '-734829104958217364', argument: ' seed=-734829104958217364' },
|
||||
{ name: 'seed-selection-zero', seed: '0', argument: ' seed=0' }
|
||||
]
|
||||
|
||||
export default {
|
||||
name: 'iris-seed-selection',
|
||||
description: 'Verify omitted, positive, negative, and zero creation seeds against visible terrain and a cold restart.',
|
||||
async run(context) {
|
||||
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
|
||||
context.expect(/^isolated=true\s*$/m.test(source), 'Seed selection requires an isolated instance')
|
||||
const wait = (predicate, label) => context.waitUntil(predicate, { label, timeoutMs: 60000, intervalMs: 100 })
|
||||
const restart = context.options.command === 'restart-check'
|
||||
const stateFile = path.join(context.server.directory, '.seed-selection.json')
|
||||
const saved = restart ? JSON.parse(await readFile(stateFile, 'utf8')) : undefined
|
||||
const jarHash = createHash('sha256').update(await readFile(path.join(context.server.directory, 'plugins', 'Iris.jar'))).digest('hex')
|
||||
const processId = (await context.observe()).processId
|
||||
if (restart) {
|
||||
context.expect(saved.processId !== processId, 'Restart-check requires a cold server restart')
|
||||
context.expect(saved.jarHash === jarHash, 'Restart-check requires the same Iris jar')
|
||||
}
|
||||
const outputs = {}
|
||||
for (const world of worlds) {
|
||||
await context.step(`${restart ? 'Reload' : 'Create'} ${world.name} with seed ${world.seed}`, async () => {
|
||||
if (!restart) {
|
||||
await context.command(`/iris create name=${world.name} type=cohesion${world.argument}`,
|
||||
/Successfully created your world/i, 180000)
|
||||
}
|
||||
await context.command(`/iris tp ${world.name}`)
|
||||
await wait(async () => {
|
||||
const observation = await context.observe()
|
||||
const player = observation.players.find(entry => entry.username === context.bot.username)
|
||||
const current = observation.worlds.find(entry => entry.id === player?.world)
|
||||
return current?.key === `iris:${world.name}` || current?.name.endsWith(world.name)
|
||||
}, `Enter ${world.name}`)
|
||||
await context.command('/minecraft:gamemode spectator @s')
|
||||
await wait(() => context.bot.game.gameMode === 'spectator', 'Spectator mode')
|
||||
await context.command('/minecraft:seed', new RegExp(`Seed:\\s*\\[?${world.seed}\\]?(?:\\s|$)`))
|
||||
const x = 1024
|
||||
const z = -1024
|
||||
await context.command(`/minecraft:tp @s ${x + 8.5} 48 ${z + 8.5}`, /Teleported/i, 30000)
|
||||
await wait(() => Math.abs(context.bot.entity.position.x - x - 8.5) < 1
|
||||
&& Math.abs(context.bot.entity.position.z - z - 8.5) < 1, 'Sample teleport')
|
||||
const position = context.bot.entity.position.clone()
|
||||
await wait(() => context.bot.blockAt(position.clone().set(x, 0, z))
|
||||
&& context.bot.blockAt(position.clone().set(x + 15, 63, z + 15)), 'Complete sample chunk')
|
||||
const digest = createHash('sha256')
|
||||
let stone = 0
|
||||
for (let dx = 0; dx < 16; dx++) {
|
||||
for (let dz = 0; dz < 16; dz++) {
|
||||
for (let y = 0; y < 64; y++) {
|
||||
const block = context.bot.blockAt(position.set(x + dx, y, z + dz))
|
||||
context.expect(block !== null, 'Missing sampled block', { x: x + dx, y, z: z + dz })
|
||||
digest.update(`${block.stateId},`)
|
||||
if (block.name === 'stone') stone++
|
||||
}
|
||||
}
|
||||
}
|
||||
context.expect(stone > 1000, 'Fixture terrain was not generated', { world: world.name, stone })
|
||||
const blocks = digest.digest('hex')
|
||||
outputs[world.name] = { seed: world.seed, blocks }
|
||||
if (restart) {
|
||||
context.expect(saved.worlds[world.name].seed === world.seed, 'Seed expectation changed after restart')
|
||||
context.expect(saved.worlds[world.name].blocks === blocks, 'Persisted terrain changed after restart',
|
||||
{ world: world.name, before: saved.worlds[world.name].blocks, after: blocks })
|
||||
}
|
||||
})
|
||||
}
|
||||
const signatures = new Set(Object.values(outputs).map(output => output.blocks))
|
||||
context.expect(signatures.size === worlds.length, 'Different requested seeds generated identical sample chunks', outputs)
|
||||
context.report.seedSelection = { restart, jarHash, worlds: outputs }
|
||||
if (!restart) {
|
||||
await writeFile(stateFile, JSON.stringify({ processId, jarHash, worlds: outputs }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { setup, openStudio, closeStudio, sampleTerrain, readJson, writeJson,
|
||||
logPosition, logSince } from './studio-support.mjs'
|
||||
|
||||
async function hotload(context, fixture, active, command) {
|
||||
const before = await readJson(fixture.manifestPath(active.key))
|
||||
const response = await context.command(command, /^Studio hotloaded\.$|^Studio hotload failed:/, 90000)
|
||||
context.expect(response === 'Studio hotloaded.', 'The manual Studio command did not complete successfully', { command, response })
|
||||
const after = await readJson(fixture.manifestPath(active.key))
|
||||
context.expect(after.pendingActivationId === null, 'The manual hotload completed with a pending activation')
|
||||
context.report.iris.manualHotloads.push({ command, response,
|
||||
before: before.activeActivationId, after: after.activeActivationId })
|
||||
return after
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'iris-studio-manual-hotload',
|
||||
description: 'Refresh an open Studio through hotload and its refresh alias, verify terrain, and reject commands without a Studio.',
|
||||
async run(context) {
|
||||
const fixture = await setup(context)
|
||||
const biomeFile = path.join(fixture.pack, 'biomes', 'flat.json')
|
||||
const original = await readFile(biomeFile, 'utf8')
|
||||
const logOffset = await logPosition(fixture)
|
||||
const processId = (await context.observe()).processId
|
||||
context.report.iris.manualHotloads = []
|
||||
let active
|
||||
try {
|
||||
await context.step('Reject hotload without an open Studio', async () => {
|
||||
await context.command('/iris studio hotload', /^No open studio projects\.$/, 10000)
|
||||
})
|
||||
await context.step('Open Studio and hotload an unchanged pack', async () => {
|
||||
active = await openStudio(context, fixture)
|
||||
await sampleTerrain(context, fixture, 0, fixture.state.material)
|
||||
await hotload(context, fixture, active, '/iris studio hotload')
|
||||
await sampleTerrain(context, fixture, 4096, fixture.state.material)
|
||||
})
|
||||
await context.step('Refresh an edited pack and inspect newly generated terrain', async () => {
|
||||
const before = await readJson(fixture.manifestPath(active.key))
|
||||
const biome = await readJson(biomeFile)
|
||||
biome.name = 'Manual hotload terrain'
|
||||
biome.layers[0].palette[0].block = 'minecraft:gold_block'
|
||||
await writeJson(biomeFile, biome)
|
||||
await hotload(context, fixture, active, '/iris studio refresh')
|
||||
const after = await fixture.wait(async () => {
|
||||
const manifest = await readJson(fixture.manifestPath(active.key))
|
||||
return manifest.activeActivationId !== before.activeActivationId && manifest.pendingActivationId === null ? manifest : false
|
||||
}, 'The edited Studio pack did not become active', 90000)
|
||||
context.report.iris.activations.push({ before: before.activeActivationId,
|
||||
after: after.activeActivationId, material: 'gold_block' })
|
||||
await sampleTerrain(context, fixture, 16384, 'gold_block')
|
||||
await sampleTerrain(context, fixture, 0, fixture.state.material)
|
||||
})
|
||||
await context.step('Close Studio and reject another refresh', async () => {
|
||||
await closeStudio(context, fixture, active)
|
||||
active = undefined
|
||||
await context.command('/iris studio refresh', /^No open studio projects\.$/, 10000)
|
||||
await context.command('/minecraft:seed', /Seed:/i, 10000)
|
||||
})
|
||||
context.expect((await context.observe()).processId === processId, 'The server restarted during manual Studio hotload')
|
||||
const log = await logSince(fixture, logOffset)
|
||||
context.expect(!/Studio generation update failed|Studio hotload failed|Unable to (capture terrain|save the terrain boundary)|ClosedChannelException|Chunk system error|server has stopped responding|could not acquire its engine runtime|Failed to list files/i.test(log),
|
||||
'Manual Studio hotload emitted a generation, snapshot, storage, or watchdog failure')
|
||||
} finally {
|
||||
try {
|
||||
if (active) await closeStudio(context, fixture, active)
|
||||
} finally {
|
||||
await writeFile(biomeFile, original)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:map",
|
||||
"functions": [
|
||||
{
|
||||
"destination": "iris_qa:blocked",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"function": "minecraft:exploration_map"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:map",
|
||||
"functions": [
|
||||
{
|
||||
"destination": "iris_qa:disabled",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"function": "minecraft:exploration_map"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:map",
|
||||
"functions": [
|
||||
{
|
||||
"destination": "iris_qa:found",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"function": "minecraft:exploration_map"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:map",
|
||||
"functions": [
|
||||
{
|
||||
"destination": "iris_qa:mixed",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"function": "minecraft:exploration_map"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"values": [
|
||||
"minecraft:buried_treasure"
|
||||
]
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"values": [
|
||||
"minecraft:mansion"
|
||||
]
|
||||
}
|
||||
Loaded 100 of 1999 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user