commit ce9d7a1371887eeebe46922beaded99e40e3b053 Author: BergaBruh Date: Sat Jul 18 19:58:41 2026 +0500 first init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ea3477 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +.pytest_cache/ +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5617b5 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# Escape from Funcraft — client beta + +This repository contains the reproducible client-packaging files for the beta. +The playable client is distributed as a Gitea Release asset, not as a Git commit. + +## Install in Prism Launcher + +1. In Prism Launcher, choose **Add Instance** → **Import**. +2. Paste the release-asset URL below. +3. Start the imported instance and use the beta server address supplied by the team. + +For `beta.1`, use: + +`https://gitea.projectshitpost.fun/funcraft/escape-from-funcraft-client/releases/download/beta.1/escape-from-funcraft-client-beta.1.zip` + +The ZIP is a Prism Launcher instance. It includes the required client mods, shared pack settings, and only key bindings from `options.txt`. It deliberately does not include the world, server files, player screenshots/logs, shader selection, graphics/audio/FOV settings, or server credentials. + +## Beta notes + +- Minecraft: 1.20.1 +- Forge: 47.4.10 +- The server distribution is private and is not part of this repository. diff --git a/docs/beta-release/mod-inventory.md b/docs/beta-release/mod-inventory.md new file mode 100644 index 0000000..3a932f8 --- /dev/null +++ b/docs/beta-release/mod-inventory.md @@ -0,0 +1,14 @@ ++# Beta mod inventory + +Active client set: **136** JARs. The manifest is the authoritative machine-readable mapping used by the builders. + +| Side | Count | Audit basis | +| --- | ---: | --- | +| client | 48 | client HUD/render/input/UI mods and their loader/config dependencies | +| server | 16 | permissions, protection, profiling, Discord integration, pre-generation and server tools | +| both | 72 | gameplay content, registries, entities, TACZ, shared libraries and network features | + +The old `server-staging` was not used as an input. It contains 119 JARs while the active client contains 136; its list served only as a historical comparison. + +Before a public release, the server build must be started once and any side error is corrected in `packaging/mod-classification.toml` before repacking. + diff --git a/packaging/beta-release.toml b/packaging/beta-release.toml new file mode 100644 index 0000000..5f1770c --- /dev/null +++ b/packaging/beta-release.toml @@ -0,0 +1,16 @@ +[release] +version = "beta.1" +client_archive = "escape-from-funcraft-client-beta.1.zip" +server_archive = "escape-from-funcraft-server-beta.1.tar.gz" + +[source] +world = "minecraft/saves/§2MC Zombie Apocalypse §f- §4Update #17.1" + +[gitea] +base_url = "https://gitea.projectshitpost.fun" +client_repository = "funcraft/escape-from-funcraft-client" +server_repository = "funcraft/escape-from-funcraft-server" + +[client] +excluded_roots = ["saves", "logs", "crash-reports", "screenshots", "mods-disabled", "shaderpacks"] +excluded_files = ["servers.dat", "optionsof.txt", "optionsshaders.txt"] diff --git a/packaging/mod-classification.toml b/packaging/mod-classification.toml new file mode 100644 index 0000000..8f06e26 --- /dev/null +++ b/packaging/mod-classification.toml @@ -0,0 +1,137 @@ +[classification] +"AmbientSounds_FORGE_v6.3.8_mc1.20.1.jar" = "client" +"Bookshelf-Forge-1.20.1-20.2.13.jar" = "client" +"Camerapture-1.10.8+mc1.20.1-forge.jar" = "client" +"Chunky-1.3.146.jar" = "server" +"Clumps-forge-1.20.1-12.0.0.4.jar" = "both" +"Connector-1.0.0-beta.48+1.20.1.jar" = "client" +"CraftTweaker-forge-1.20.1-14.0.57.jar" = "both" +"CreativeCore_FORGE_v2.12.39_mc1.20.1.jar" = "both" +"DeltaForce Melee Pack-0.2.0.jar" = "both" +"DistantHorizons-3.2.0-b-1.20.1-fabric-forge.jar" = "client" +"Elite X Quality Guns Forge v5.1 - 1.20.1.jar" = "both" +"EliteHolograms-1.20.1-1.0.7.jar" = "both" +"EmoteTweaks-4.0.2.27-forge.jar" = "client" +"EnhancedVisuals_FORGE_v1.8.2_mc1.20.1.jar" = "client" +"FarmersDelight-1.20.1-1.2.9.jar" = "both" +"Kiwi-1.20.1-Forge-11.9.2.jar" = "both" +"Loot Journal-forge-1.20.1-5.0.1.jar" = "client" +"LuckPerms-Forge-5.4.102.jar" = "server" +"MouseTweaks-forge-mc1.20.1-2.25.1.jar" = "client" +"Necronomicon-Forge-1.6.0+1.20.1.jar" = "both" +"NoChatReports-FORGE-1.20.1-v2.2.2.jar" = "client" +"SmartBrainLib-forge-1.20.1-1.15.jar" = "both" +"TES-forge-1.20.1-1.6.1.jar" = "both" +"Tips-Forge-1.20.1-12.1.8.jar" = "client" +"WeaponsOfMiracles-2.0.164-mc1.20.1-forge.jar" = "both" +"WorldEditCUI-1.20+01.jar" = "client" +"XeKr's Decoration-1.20.1-Forge-0.9.3.jar" = "both" +"YungsApi-1.20-Forge-4.0.6.jar" = "both" +"[CS] Library-4.4.6-1.20.1.jar" = "both" +"[CS] Roleplay Decor-2.4.2-1.20.1.jar" = "both" +"[TACZ] Supressing-1.0.jar" = "both" +"amendments-1.20-2.2.3.jar" = "both" +"apocalypsenow-3.0.5-forge-1.20.1.jar" = "both" +"apocalypticarsenal-1.0.1.jar" = "both" +"appleskin-forge-mc1.20.1-2.5.1.jar" = "client" +"architectury-9.2.14-forge.jar" = "both" +"arctics_essentials-1.0.9.jar" = "server" +"autohide-hotbar-forge-1.0.0+mc1.20.1.jar" = "client" +"auudio_forge_1.0.3_MC_1.19.3.jar" = "client" +"banksystem-forge-1.4.1.jar" = "both" +"cc-recrafted-forge-1.20.1.0.0.3.jar" = "both" +"cc-tweaked-1.20.1-forge-1.116.2.jar" = "both" +"chamber_clarity-4.0.0-1.20.1.jar" = "both" +"chat_heads-0.15.2-forge-1.20.jar" = "client" +"cloth-config-11.1.136-forge.jar" = "client" +"collective-1.20.1-8.13.jar" = "both" +"configured-fabric-1.20.1-2.2.3.jar" = "client" +"configured-forge-1.20.1-2.2.3.jar" = "client" +"constructionwand-1.20.1-2.11.jar" = "both" +"continuity-3.0.0+1.20.1.forge.jar" = "client" +"create-1.20.1-6.0.8.jar" = "both" +"createimmersivetacz-1.4-1.20.1.jar" = "both" +"createtaczauto-1.4.8-forge-1.20.1.jar" = "both" +"curios-forge-5.14.1+1.20.1.jar" = "both" +"daffas_arsenal-3.5.0.jar" = "both" +"dcintegration-forge-3.0.7.1-1.20.1.jar" = "server" +"decorative_blocks-forge-1.20.1-4.1.3.jar" = "both" +"discord_chat_mod-forge-1.20.1-2.7.0.jar" = "server" +"doomsday_decoration-1.1.3-forge-1.20.1.jar" = "both" +"dyairdrop-1.0.0-1.20.1-hotfix3.0.jar" = "server" +"dynamic-fps-3.11.4+minecraft-1.20.0-forge.jar" = "client" +"easy_npc_bundle-6.0.21-forge-1.20.1.jar" = "both" +"embeddium-0.3.31+mc1.20.1.jar" = "client" +"emotecraft-for-MC1.20.1-2.2.7-b.build.50-forge.jar" = "client" +"entity_model_features-3.2.4-1.20.1-forge.jar" = "client" +"entity_texture_features_1.20.1-forge-7.1.jar" = "client" +"entityculling-forge-1.9.3-mc1.20.1.jar" = "client" +"epic-fight-20.14.17-mc1.20.1-forge.jar" = "both" +"epictweaks-forge-1.20.1-1.1.1.jar" = "client" +"escape_from_minecov-1.4.0-forge-1.20.1.jar" = "both" +"fabric-api-0.92.6+1.11.14+1.20.1.jar" = "both" +"fancymenu_forge_3.8.0_MC_1.20.1.jar" = "client" +"ferritecore-6.0.1-forge.jar" = "both" +"firstaid-1.20.1-1.1.jar" = "both" +"foxapi-1.0.0-remaster.jar" = "client" +"framework-forge-1.20.1-0.7.15.jar" = "both" +"ftb-library-forge-2001.2.12.jar" = "server" +"ftb-ranks-forge-2001.1.7.jar" = "server" +"geckolib-forge-1.20.1-4.8.2.jar" = "both" +"grieflogger-1.2.4-1.20.1-forge.jar" = "server" +"guccivuitton-1.20.1-0.2.2.jar" = "both" +"gundb-2.1.0-all.jar" = "both" +"immersive_melodies-0.6.0+1.20.1-forge.jar" = "client" +"immersive_weathering-1.20.1-2.0.5-forge.jar" = "both" +"jei-1.20.1-forge-15.20.0.125.jar" = "client" +"konkrete_forge_1.8.0_MC_1.20-1.20.1.jar" = "client" +"kotlinforforge-4.11.0-all.jar" = "client" +"leawind_third_person-v2.2.0-mc1.20-1.20.1-forge.jar" = "client" +"lootr-forge-1.20-0.7.35.94.jar" = "both" +"lradd-1.20.1-0.3.0.jar" = "both" +"lrarmor-1.20.1-0.1.4.4.jar" = "both" +"lrtactical-1.20.1-0.3.0.jar" = "both" +"melody_forge_1.0.3_MC_1.20.1-1.20.4.jar" = "both" +"memoryleakfix-forge-1.17+-1.1.5.jar" = "client" +"modelfix-1.15.jar" = "client" +"modernfix-forge-5.27.51+mc1.20.1.jar" = "both" +"moonlight-1.20-2.16.16-forge.jar" = "both" +"mrfegs_coffee_box-1.0.0-forge-1.20.1.jar" = "both" +"nef-1.2.0-forge-1.20.1.jar" = "both" +"notenoughanimations-forge-1.12.4-mc1.20.1.jar" = "client" +"oculus-flywheel-compat-Forge-2.0.3.jar" = "client" +"oculus-mc1.20.1-1.8.0.jar" = "client" +"opacbonusclaims-1.2.0-1.20.1.jar" = "server" +"opacfixes-1.0.jar" = "server" +"opacpvp-forge-1.0.1-1.20.1.jar" = "both" +"open-parties-and-claims-forge-1.20.1-0.27.6.jar" = "both" +"panoramica_forge_1.2.1_MC_1.19-1.19.2.jar" = "client" +"player-animation-lib-forge-1.0.2-rc1+1.20.jar" = "both" +"prma-1.20.1-0.4.4-cr6.0-SIMPLE-beta-all.jar" = "client" +"props_mod-1.4.1-forge-1.20.1.jar" = "both" +"refurbished_furniture-forge-1.20.1-1.0.14.jar" = "both" +"rubidium-extra-0.5.4.4+mc1.20.1-build.131.jar" = "client" +"skinlayers3d-forge-1.11.2-mc1.20.1.jar" = "client" +"skinrestorer-2.8.1+1.20-forge.jar" = "both" +"sodiumoptionsapi-forge-1.0.10-1.20.1.jar" = "client" +"spark-1.10.53-forge.jar" = "server" +"stockmarket-forge-1.3.1.jar" = "both" +"supermartijn642configlib-1.1.8-forge-mc1.20.jar" = "both" +"supplementaries-1.20-3.1.41.jar" = "both" +"survival_instinct-1.0.1-forge-1.20.1.jar" = "both" +"tacz-1.20.1-1.1.6-hotfix.jar" = "both" +"tacz_turrets-1.0.1-all.jar" = "both" +"taczadditions-1.20.1-1.0.5-hotfix2.jar" = "both" +"taczaddon-1.20.1-1.1.6.jar" = "both" +"taczcustomdeath-1.0.0-Beta.jar" = "both" +"taczdd-0.1.1.jar" = "both" +"taczmagazines-1.20.1-0.1.6-Beta.jar" = "both" +"tacznpcs-2.1.0-1.20.1.jar" = "both" +"tarkovdayz-1.0.0-forge-1.20.1.jar" = "both" +"visualhealth-forge-1.20.1-2.0.2.jar" = "client" +"voicechat-forge-1.20.1-2.6.6.jar" = "both" +"worldedit-mod-7.2.15.jar" = "server" +"worldedit_items-2.0-forge-1.20.1.jar" = "server" +"worldflags-1.3.0-forge-1.20.1.jar" = "server" +"worldprotect-1.0.0.jar" = "server" +"yet_another_config_lib_v3-3.6.6+1.20.1-forge.jar" = "client" diff --git a/packaging/path-classification.toml b/packaging/path-classification.toml new file mode 100644 index 0000000..55da4ce --- /dev/null +++ b/packaging/path-classification.toml @@ -0,0 +1,54 @@ +[[path]] +source = "instance.cfg" +side = "client" +client_destination = "instance.cfg" + +[[path]] +source = "mmc-pack.json" +side = "client" +client_destination = "mmc-pack.json" + +[[path]] +source = "minecraft/config" +side = "both" +client_destination = "minecraft/config" +server_destination = "config" + +[[path]] +source = "minecraft/defaultconfigs" +side = "both" +client_destination = "minecraft/defaultconfigs" +server_destination = "defaultconfigs" + +[[path]] +source = "minecraft/tacz" +side = "both" +client_destination = "minecraft/tacz" +server_destination = "tacz" + +[[path]] +source = "minecraft/easy_npc" +side = "both" +client_destination = "minecraft/easy_npc" +server_destination = "easy_npc" + +[[path]] +source = "minecraft/data" +side = "both" +client_destination = "minecraft/data" +server_destination = "data" + +[[path]] +source = "minecraft/emotes" +side = "client" +client_destination = "minecraft/emotes" + +[[path]] +source = "minecraft/fancymenu_data" +side = "client" +client_destination = "minecraft/fancymenu_data" + +[[path]] +source = "minecraft/DiscordIntegration-Data" +side = "server" +server_destination = "DiscordIntegration-Data" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..acaf156 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/tests/test_beta_release.py b/tests/test_beta_release.py new file mode 100644 index 0000000..e925808 --- /dev/null +++ b/tests/test_beta_release.py @@ -0,0 +1,172 @@ +import hashlib +from pathlib import Path + +import pytest + +from tools.beta_release_lib import validate_complete_mapping + + +def test_manifest_rejects_an_unclassified_or_missing_mod() -> None: + with pytest.raises(ValueError, match="missing=.*alpha.jar.*unknown=.*beta.jar"): + validate_complete_mapping( + {"alpha.jar", "beta.jar"}, + {"beta.jar": "UNCLASSIFIED"}, + ) + + +def test_manifest_accepts_only_known_sides_and_exact_names() -> None: + validate_complete_mapping( + {"alpha.jar", "beta.jar"}, + {"alpha.jar": "client", "beta.jar": "server"}, + ) + with pytest.raises(ValueError, match="extra=.*gamma.jar"): + validate_complete_mapping( + {"alpha.jar"}, {"alpha.jar": "both", "gamma.jar": "client"}, + ) + + +def test_write_keybind_options_keeps_only_version_and_key_lines(tmp_path: Path) -> None: + from tools.beta_release_lib import write_keybind_options + + source = tmp_path / "source-options.txt" + destination = tmp_path / "options.txt" + source.write_text( + "version:3465\nrenderDistance:32\nkey_key.tacz.reload.desc:key.keyboard.r\n" + "fov:0.0\nkey_key.forward:key.keyboard.w\n", + encoding="utf-8", + ) + + write_keybind_options(source, destination) + + assert destination.read_text(encoding="utf-8") == ( + "version:3465\nkey_key.tacz.reload.desc:key.keyboard.r\nkey_key.forward:key.keyboard.w\n" + ) + + +def test_copy_classified_tree_never_copies_wrong_side_or_world(tmp_path: Path) -> None: + from tools.beta_release_lib import copy_classified_tree + + source = tmp_path / "source" + (source / "minecraft/mods").mkdir(parents=True) + (source / "minecraft/saves/world").mkdir(parents=True) + (source / "minecraft/mods/client.jar").write_bytes(b"client") + (source / "minecraft/mods/both.jar").write_bytes(b"both") + (source / "minecraft/mods/server.jar").write_bytes(b"server") + (source / "minecraft/saves/world/level.dat").write_bytes(b"world") + + destination = tmp_path / "client" + copy_classified_tree( + source, + destination, + "client", + {"client.jar": "client", "both.jar": "both", "server.jar": "server"}, + [], + ) + + assert (destination / "minecraft/mods/client.jar").is_file() + assert (destination / "minecraft/mods/both.jar").is_file() + assert not (destination / "minecraft/mods/server.jar").exists() + assert not (destination / "minecraft/saves").exists() + + +def test_validate_client_stage_requires_prism_root_and_rejects_world(tmp_path: Path) -> None: + from tools.beta_release_lib import validate_client_stage + + stage = tmp_path / "client-stage" + (stage / "minecraft/saves/world").mkdir(parents=True) + (stage / "minecraft/saves/world/level.dat").write_bytes(b"world") + (stage / "minecraft/mods").mkdir() + (stage / "minecraft/options.txt").write_text("version:3465\n", encoding="utf-8") + (stage / "instance.cfg").write_text("InstanceType=OneSix\n", encoding="utf-8") + (stage / "mmc-pack.json").write_text("{}\n", encoding="utf-8") + + with pytest.raises(ValueError, match="forbidden client path.*minecraft/saves"): + validate_client_stage(stage, {}) + + +def test_sanitize_client_stage_removes_server_credentials_and_personal_cache( + tmp_path: Path, +) -> None: + from tools.beta_release_lib import sanitize_client_stage + + stage = tmp_path / "client-stage" + credentials = stage / "minecraft/config/Discord-Integration.toml" + cache = stage / "minecraft/config/voicechat/username-cache.json" + shader_selection = stage / "minecraft/config/oculus.properties" + skin_provider = stage / "minecraft/config/skinrestorer/config.json" + credentials.parent.mkdir(parents=True) + cache.parent.mkdir(parents=True) + skin_provider.parent.mkdir(parents=True) + credentials.write_text("botToken=do-not-publish\n", encoding="utf-8") + cache.write_text('{"player":"do-not-publish"}\n', encoding="utf-8") + shader_selection.write_text("shaderPack=local-only\n", encoding="utf-8") + skin_provider.write_text('{"apiKey":"do-not-publish"}\n', encoding="utf-8") + + sanitize_client_stage(stage) + + assert not credentials.exists() + assert not cache.exists() + assert not shader_selection.exists() + assert not skin_provider.exists() + + +def test_validate_server_stage_rejects_client_jar_and_requires_target_world(tmp_path: Path) -> None: + from tools.beta_release_lib import validate_server_stage + + stage = tmp_path / "server-stage" + (stage / "mods").mkdir(parents=True) + (stage / "mods/client.jar").write_bytes(b"client") + (stage / "eula.txt").write_text("eula=false\n", encoding="utf-8") + (stage / "server.properties").write_text("level-name=world\n", encoding="utf-8") + + with pytest.raises(ValueError, match="server JAR mismatch|missing target world"): + validate_server_stage(stage, {"client.jar": "client"}, "world") + + +def test_forge_request_declares_a_user_agent() -> None: + from tools.build_beta_release import forge_request + + request = forge_request("https://example.invalid/forge-installer.jar") + + assert request.get_header("User-agent") == "EscapeFromFuncraftBetaBuilder/1.0" + + +def test_forge_install_env_prepends_compatibility_library_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from tools.build_beta_release import forge_install_env + + monkeypatch.setenv("LD_LIBRARY_PATH", "/system/lib") + + env = forge_install_env(tmp_path / "legacy-zlib") + + assert env is not None + assert env["LD_LIBRARY_PATH"] == f"{tmp_path / 'legacy-zlib'}:/system/lib" + + +def test_sha256_file_streams_without_reading_the_archive_at_once( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from tools.beta_release_lib import sha256_file + + archive = tmp_path / "large-archive.bin" + payload = b"release-data" * 100_000 + archive.write_bytes(payload) + + def fail_read_bytes(_: Path) -> bytes: + raise AssertionError("release archives must not be read into memory at once") + + monkeypatch.setattr(Path, "read_bytes", fail_read_bytes) + + assert sha256_file(archive) == hashlib.sha256(payload).hexdigest() + + +def test_source_mod_names_includes_only_mod_jars(tmp_path: Path) -> None: + from tools.build_beta_release import source_mod_names + + mods = tmp_path / "minecraft/mods" + mods.mkdir(parents=True) + (mods / "required.jar").write_bytes(b"jar") + (mods / "notes.txt").write_text("ignore", encoding="utf-8") + + assert source_mod_names(tmp_path) == {"required.jar"} diff --git a/tools/beta_release_lib.py b/tools/beta_release_lib.py new file mode 100644 index 0000000..1ba5f32 --- /dev/null +++ b/tools/beta_release_lib.py @@ -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) diff --git a/tools/build_beta_release.py b/tools/build_beta_release.py new file mode 100644 index 0000000..0de0715 --- /dev/null +++ b/tools/build_beta_release.py @@ -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() diff --git a/tools/validate_beta_release.py b/tools/validate_beta_release.py new file mode 100644 index 0000000..af19457 --- /dev/null +++ b/tools/validate_beta_release.py @@ -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()