first init

This commit is contained in:
BergaBruh
2026-07-18 19:58:41 +05:00
commit ce9d7a1371
11 changed files with 867 additions and 0 deletions
+255
View File
@@ -0,0 +1,255 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import hashlib
import gzip
import shutil
import tarfile
import tomllib
import zipfile
VALID_SIDES = frozenset({"client", "server", "both"})
@dataclass(frozen=True)
class PathRule:
source: str
side: str
client_destination: str | None = None
server_destination: str | None = None
def load_side_manifest(path: Path) -> dict[str, str]:
data = tomllib.loads(path.read_text(encoding="utf-8"))
mapping = data["classification"]
if not isinstance(mapping, dict):
raise ValueError(f"{path}: [classification] is required")
result = {str(name): str(side) for name, side in mapping.items()}
invalid = sorted(name for name, side in result.items() if side not in VALID_SIDES)
if invalid:
raise ValueError(f"{path}: invalid side for {', '.join(invalid)}")
return result
def load_path_rules(path: Path) -> list[PathRule]:
rules = []
for item in tomllib.loads(path.read_text(encoding="utf-8")).get("path", []):
rule = PathRule(
source=str(item["source"]),
side=str(item["side"]),
client_destination=item.get("client_destination"),
server_destination=item.get("server_destination"),
)
if rule.side not in VALID_SIDES:
raise ValueError(f"{path}: invalid side for {rule.source}")
if rule.side in {"client", "both"} and rule.client_destination is None:
raise ValueError(f"{path}: missing client destination for {rule.source}")
if rule.side in {"server", "both"} and rule.server_destination is None:
raise ValueError(f"{path}: missing server destination for {rule.source}")
rules.append(rule)
return rules
def validate_complete_mapping(expected: set[str], mapping: dict[str, str]) -> None:
missing = sorted(expected - mapping.keys())
extra = sorted(mapping.keys() - expected)
unknown = sorted(name for name, side in mapping.items() if side not in VALID_SIDES)
if missing or extra or unknown:
raise ValueError(
f"missing={','.join(missing)} extra={','.join(extra)} unknown={','.join(unknown)}"
)
def write_keybind_options(source: Path, destination: Path) -> None:
kept = [
line
for line in source.read_text(encoding="utf-8").splitlines()
if line.startswith("version:") or line.startswith("key_")
]
if sum(line.startswith("version:") for line in kept) != 1:
raise ValueError(f"{source}: expected exactly one version line")
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text("\n".join(kept) + "\n", encoding="utf-8")
def _matches_side(item_side: str, destination_side: str) -> bool:
return item_side == "both" or item_side == destination_side
def _safe_relative(value: str) -> Path:
candidate = Path(value)
if candidate.is_absolute() or ".." in candidate.parts:
raise ValueError(f"unsafe relative path: {value}")
return candidate
def copy_classified_tree(
source_root: Path,
destination_root: Path,
side: str,
mod_map: dict[str, str],
path_rules: list[PathRule],
) -> None:
if side not in {"client", "server"}:
raise ValueError(f"invalid destination side: {side}")
mods_root = Path("minecraft/mods") if side == "client" else Path("mods")
for jar_name, jar_side in mod_map.items():
if not _matches_side(jar_side, side):
continue
source = source_root / "minecraft/mods" / jar_name
if not source.is_file():
raise FileNotFoundError(source)
target = destination_root / mods_root / jar_name
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
for rule in path_rules:
if not _matches_side(rule.side, side):
continue
destination_name = (
rule.client_destination if side == "client" else rule.server_destination
)
if destination_name is None:
raise ValueError(f"missing {side} destination for {rule.source}")
source = source_root / _safe_relative(rule.source)
if not source.exists():
raise FileNotFoundError(source)
target = destination_root / _safe_relative(destination_name)
if source.is_dir():
shutil.copytree(source, target, dirs_exist_ok=False)
else:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
CLIENT_FORBIDDEN_PREFIXES = (
"minecraft/saves/",
"minecraft/logs/",
"minecraft/crash-reports/",
"minecraft/screenshots/",
"minecraft/mods-disabled/",
"minecraft/shaderpacks/",
)
CLIENT_FORBIDDEN_NAMES = {
"minecraft/servers.dat",
"minecraft/optionsof.txt",
"minecraft/optionsshaders.txt",
}
CLIENT_SANITIZED_PATHS = (
"minecraft/config/.backups",
"minecraft/config/Discord-Integration.toml",
"minecraft/config/EssentialsCommands-config.toml",
"minecraft/config/NoChatReports/NCR-ServerPreferences.json",
"minecraft/config/arctics_essentials",
"minecraft/config/bstats.json",
"minecraft/config/camerapture.server.json",
"minecraft/config/chunky",
"minecraft/config/discord_chat_mod-client.toml",
"minecraft/config/discord_chat_mod-common.toml",
"minecraft/config/dyairdrop.toml",
"minecraft/config/elite-holograms",
"minecraft/config/fancymenu/user_variables.db",
"minecraft/config/maplink/friends.json5",
"minecraft/config/oculus.properties",
"minecraft/config/refurbished_furniture.server.toml",
"minecraft/config/skinrestorer/config.json",
"minecraft/config/skinrestorer/mojang_profile_cache.json",
"minecraft/config/spark",
"minecraft/config/voicechat/player-volumes.properties",
"minecraft/config/voicechat/username-cache.json",
"minecraft/config/voicechat/voicechat-server.properties",
"minecraft/config/voicechat/voicechat-volumes.properties",
"minecraft/config/worldedit",
"minecraft/config/worldprotect-common.toml",
)
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as input_file:
while chunk := input_file.read(1024 * 1024):
digest.update(chunk)
return digest.hexdigest()
def sanitize_client_stage(stage: Path) -> None:
for relative in CLIENT_SANITIZED_PATHS:
target = stage / relative
if target.is_dir():
shutil.rmtree(target)
elif target.is_file() or target.is_symlink():
target.unlink()
def validate_client_stage(stage: Path, mod_map: dict[str, str]) -> None:
for required in ("instance.cfg", "mmc-pack.json", "minecraft/options.txt"):
if not (stage / required).is_file():
raise ValueError(f"missing client file: {required}")
for path in sorted(stage.rglob("*")):
relative = path.relative_to(stage).as_posix()
if relative in CLIENT_FORBIDDEN_NAMES or relative.startswith(CLIENT_FORBIDDEN_PREFIXES):
raise ValueError(f"forbidden client path: {relative}")
for relative in CLIENT_SANITIZED_PATHS:
if (stage / relative).exists():
raise ValueError(f"unsanitized client path: {relative}")
actual = {path.name for path in (stage / "minecraft/mods").glob("*.jar")}
expected = {name for name, side in mod_map.items() if side in {"client", "both"}}
if actual != expected:
raise ValueError(
f"client JAR mismatch missing={sorted(expected - actual)} extra={sorted(actual - expected)}"
)
def create_zip(source: Path, archive: Path) -> str:
archive.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as output:
for path in sorted(source.rglob("*")):
if path.is_file():
info = zipfile.ZipInfo(
path.relative_to(source).as_posix(),
date_time=(2026, 7, 18, 0, 0, 0),
)
info.compress_type = zipfile.ZIP_DEFLATED
output.writestr(info, path.read_bytes())
return sha256_file(archive)
def validate_server_stage(stage: Path, mod_map: dict[str, str], world_name: str) -> None:
for required in ("eula.txt", "server.properties", "mods"):
if not (stage / required).exists():
raise ValueError(f"missing server file: {required}")
if not (stage / world_name).is_dir():
raise ValueError(f"missing target world: {world_name}")
if (stage / "eula.txt").read_text(encoding="utf-8") != "eula=false\n":
raise ValueError("server eula.txt must remain eula=false")
properties = (stage / "server.properties").read_text(encoding="utf-8")
if f"level-name={world_name}\n" not in properties:
raise ValueError("server.properties does not target the expected world")
actual = {path.name for path in (stage / "mods").glob("*.jar")}
expected = {name for name, side in mod_map.items() if side in {"server", "both"}}
if actual != expected:
raise ValueError(
f"server JAR mismatch missing={sorted(expected - actual)} extra={sorted(actual - expected)}"
)
def create_tar_gz(source: Path, archive: Path) -> str:
archive.parent.mkdir(parents=True, exist_ok=True)
with archive.open("wb") as raw:
with gzip.GzipFile(fileobj=raw, mode="wb", mtime=0) as compressed:
with tarfile.open(fileobj=compressed, mode="w") as output:
for path in sorted(source.rglob("*")):
relative = path.relative_to(source).as_posix()
info = output.gettarinfo(str(path), arcname=relative)
info.uid = info.gid = 0
info.uname = info.gname = ""
info.mtime = 0
if path.is_file():
with path.open("rb") as input_file:
output.addfile(info, input_file)
else:
output.addfile(info)
return sha256_file(archive)
+163
View File
@@ -0,0 +1,163 @@
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import tomllib
import urllib.request
from pathlib import Path
try:
from .beta_release_lib import (
create_zip,
create_tar_gz,
copy_classified_tree,
load_path_rules,
load_side_manifest,
sanitize_client_stage,
validate_complete_mapping,
validate_client_stage,
validate_server_stage,
write_keybind_options,
)
except ImportError:
from beta_release_lib import (
create_zip,
create_tar_gz,
copy_classified_tree,
load_path_rules,
load_side_manifest,
sanitize_client_stage,
validate_complete_mapping,
validate_client_stage,
validate_server_stage,
write_keybind_options,
)
def version_directory(output_root: Path, version: str) -> Path:
resolved_root = output_root.resolve()
candidate = (resolved_root / version).resolve()
if candidate.parent != resolved_root:
raise ValueError(f"invalid release version: {version}")
return candidate
def source_mod_names(source: Path) -> set[str]:
return {path.name for path in (source / "minecraft/mods").glob("*.jar")}
def forge_request(url: str) -> urllib.request.Request:
return urllib.request.Request(
url,
headers={"User-Agent": "EscapeFromFuncraftBetaBuilder/1.0"},
)
def forge_install_env(compatibility_library_path: Path | None) -> dict[str, str] | None:
if compatibility_library_path is None:
return None
env = os.environ.copy()
existing = env.get("LD_LIBRARY_PATH")
env["LD_LIBRARY_PATH"] = (
f"{compatibility_library_path}:{existing}" if existing else str(compatibility_library_path)
)
return env
def download_forge_installer(
server_stage: Path, compatibility_library_path: Path | None = None
) -> None:
version = "1.20.1-47.4.10"
base = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{version}/forge-{version}-installer.jar"
installer = server_stage / f"forge-{version}-installer.jar"
expected_sha1 = (
urllib.request.urlopen(forge_request(base + ".sha1"), timeout=30)
.read()
.decode("utf-8")
.strip()
.split()[0]
)
with urllib.request.urlopen(forge_request(base), timeout=60) as response:
installer.write_bytes(response.read())
import hashlib
actual_sha1 = hashlib.sha1(installer.read_bytes()).hexdigest()
if actual_sha1 != expected_sha1:
raise ValueError(f"Forge installer checksum mismatch: {actual_sha1} != {expected_sha1}")
subprocess.run(
["java", "-jar", installer.name, "--installServer", "."],
cwd=server_stage,
check=True,
env=forge_install_env(compatibility_library_path),
)
installer.unlink()
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--source", required=True, type=Path)
parser.add_argument("--client-output", required=True, type=Path)
parser.add_argument("--server-output", required=True, type=Path)
parser.add_argument("--version", required=True)
parser.add_argument("--manifest", type=Path, default=Path("packaging/mod-classification.toml"))
parser.add_argument("--path-manifest", type=Path, default=Path("packaging/path-classification.toml"))
parser.add_argument("--release-config", type=Path, default=Path("packaging/beta-release.toml"))
parser.add_argument(
"--forge-installer-lib-path",
type=Path,
help="directory containing a zlib implementation compatible with Forge's processors",
)
args = parser.parse_args()
source = args.source.resolve()
client_version_root = version_directory(args.client_output, args.version)
server_version_root = version_directory(args.server_output, args.version)
for root in (client_version_root, server_version_root):
if root.exists():
shutil.rmtree(root)
client_stage = client_version_root / "client-stage"
server_stage = server_version_root / "server-stage"
mod_map = load_side_manifest(args.manifest)
validate_complete_mapping(source_mod_names(source), mod_map)
path_rules = load_path_rules(args.path_manifest)
release = tomllib.loads(args.release_config.read_text(encoding="utf-8"))
world_source = source / release["source"]["world"]
world_name = world_source.name
copy_classified_tree(source, client_stage, "client", mod_map, path_rules)
copy_classified_tree(source, server_stage, "server", mod_map, path_rules)
write_keybind_options(source / "minecraft/options.txt", client_stage / "minecraft/options.txt")
sanitize_client_stage(client_stage)
validate_client_stage(client_stage, mod_map)
client_archive = client_version_root / f"escape-from-funcraft-client-{args.version}.zip"
digest = create_zip(client_stage, client_archive)
(client_version_root / "SHA256SUMS").write_text(
f"{digest} {client_archive.name}\n", encoding="utf-8"
)
shutil.copytree(world_source, server_stage / world_name)
(server_stage / "eula.txt").write_text("eula=false\n", encoding="utf-8")
(server_stage / "server.properties").write_text(
"\n".join(
(
f"level-name={world_name}",
"online-mode=true",
"enable-command-block=true",
"spawn-protection=0",
"",
)
),
encoding="utf-8",
)
validate_server_stage(server_stage, mod_map, world_name)
download_forge_installer(server_stage, args.forge_installer_lib_path)
server_archive = server_version_root / f"escape-from-funcraft-server-{args.version}.tar.gz"
server_digest = create_tar_gz(server_stage, server_archive)
(server_version_root / "SHA256SUMS").write_text(
f"{server_digest} {server_archive.name}\n", encoding="utf-8"
)
if __name__ == "__main__":
main()
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import argparse
from pathlib import Path
from beta_release_lib import load_side_manifest, validate_client_stage, validate_server_stage
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--client-stage", type=Path)
parser.add_argument("--server-stage", type=Path)
parser.add_argument("--manifest", required=True, type=Path)
parser.add_argument("--world")
args = parser.parse_args()
if (args.client_stage is None) == (args.server_stage is None):
parser.error("provide exactly one of --client-stage or --server-stage")
manifest = load_side_manifest(args.manifest)
if args.client_stage is not None:
validate_client_stage(args.client_stage, manifest)
else:
if args.world is None:
parser.error("--world is required with --server-stage")
validate_server_stage(args.server_stage, manifest, args.world)
if __name__ == "__main__":
main()