MVP checks / mvp (push) Waiting to run
Add shared Rust/WASM physics, worker meshing and diagnostics, 64-chunk full-height streaming, atlas texture support, and baseline world import. Document the current implementation and include the supplied in-game lobby screenshot.
161 lines
7.1 KiB
Python
161 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Bundle selected pixel block textures with the local Shacraft packages.
|
|
|
|
Uses only the Python standard library. Source images are copied verbatim; this
|
|
does not generate art, resample pixels, or infer distribution rights.
|
|
"""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import struct
|
|
import tempfile
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_TEXTURES = (
|
|
"stone cobblestone oak_planks oak_log oak_log_top dirt grass_block_top "
|
|
"grass_block_side sand gravel bricks stone_bricks glass oak_leaves "
|
|
"diamond_ore iron_ore coal_ore gold_ore redstone_ore deepslate obsidian "
|
|
"snow netherrack oak_door_bottom"
|
|
).split()
|
|
MARKER = ".shacraft-texture-bundle.json"
|
|
|
|
|
|
def write_json(path, data):
|
|
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def resource(path, package, role):
|
|
data = path.read_bytes()
|
|
return {
|
|
"path": path.relative_to(package).as_posix(),
|
|
"scope": "client",
|
|
"role": role,
|
|
"size": len(data),
|
|
"sha256": hashlib.sha256(data).hexdigest(),
|
|
}
|
|
|
|
|
|
def texture_bytes(path, pixel_size=32):
|
|
data = path.read_bytes()
|
|
if (
|
|
len(data) < 33
|
|
or data[:16] != b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
|
|
or struct.unpack(">II", data[16:24]) != (pixel_size, pixel_size)
|
|
):
|
|
raise ValueError(f"Expected a real {pixel_size}x{pixel_size} PNG: {path}")
|
|
if len(data) > 16 * 1024 * 1024:
|
|
raise ValueError(f"Texture exceeds the server resource limit: {path}")
|
|
return data
|
|
|
|
|
|
def prepare(args):
|
|
source = args.source.resolve()
|
|
output = args.output.resolve()
|
|
if source == output or source.is_relative_to(output):
|
|
raise ValueError("The output must not contain the source pack.")
|
|
if output == ROOT / "packages" or (ROOT / "packages").is_relative_to(output):
|
|
raise ValueError("The output must not replace the bundled core packages.")
|
|
if output.exists():
|
|
if not args.overwrite:
|
|
raise ValueError("Output already exists; use --overwrite to rebuild it.")
|
|
marker = output / MARKER
|
|
if not marker.is_file() or json.loads(marker.read_text()).get("generator") != "prepare_texture_pack.py":
|
|
raise ValueError("Refusing to replace a directory not created by this script.")
|
|
if not re.fullmatch(r"[a-z0-9_.-]+", args.id):
|
|
raise ValueError("Package id must use lowercase letters, digits, '.', '_' or '-'.")
|
|
if args.id in {"shacraft.base", "shacraft.trampoline"}:
|
|
raise ValueError("The texture package needs its own id.")
|
|
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", args.version):
|
|
raise ValueError("Package version must contain three numeric components.")
|
|
if not args.name.strip() or len(args.name) > 120:
|
|
raise ValueError("Texture pack name must contain 1 to 120 characters.")
|
|
if not args.license or len(args.license) > 256 or any(ord(c) < 32 for c in args.license):
|
|
raise ValueError("Supply a nonempty license or local-use notice (at most 256 characters).")
|
|
names = args.textures or DEFAULT_TEXTURES
|
|
if not 1 <= len(names) <= 127 or len(set(names)) != len(names):
|
|
raise ValueError("Choose 1 to 127 unique texture names.")
|
|
if any(not re.fullmatch(r"[a-z0-9_]+", name) for name in names):
|
|
raise ValueError("Texture names must be plain Minecraft block texture names.")
|
|
source_textures = source / "assets/minecraft/textures/block"
|
|
# Validate every selected source before touching an existing output bundle.
|
|
textures = [(name, texture_bytes(source_textures / f"{name}.png", args.pixel_size)) for name in names]
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
stage = Path(tempfile.mkdtemp(prefix=f".{output.name}-", dir=output.parent))
|
|
backup = None
|
|
try:
|
|
for name in ("base", "trampoline"):
|
|
shutil.copytree(ROOT / "packages" / name, stage / name)
|
|
package = stage / f"pixel{args.pixel_size}"
|
|
images = package / "client/textures"
|
|
images.mkdir(parents=True)
|
|
entries, resources = [], []
|
|
for name, data in textures:
|
|
path = images / f"{name}.png"
|
|
path.write_bytes(data)
|
|
entries.append({"name": name, "path": path.relative_to(package).as_posix()})
|
|
resources.append(resource(path, package, "texture"))
|
|
style = package / "client/style.json"
|
|
write_json(style, {"schema": 1, "texture_pack": {
|
|
"name": args.name, "pixel_size": args.pixel_size, "textures": entries,
|
|
}})
|
|
resources.append(resource(style, package, "client-style"))
|
|
base_manifest = json.loads((stage / "base/manifest.json").read_text())
|
|
write_json(package / "manifest.json", {
|
|
"schema": 1, "id": args.id, "version": args.version,
|
|
"license": args.license,
|
|
"dependencies": [{"id": base_manifest["id"], "version": base_manifest["version"]}],
|
|
"capabilities": ["client.texture", "client.style"],
|
|
"resources": resources,
|
|
})
|
|
write_json(stage / MARKER, {
|
|
"generator": "prepare_texture_pack.py", "schema": 1,
|
|
"package": args.id, "name": args.name, "textures": len(entries), "pixel_size": args.pixel_size,
|
|
"texture_bytes": sum(len(data) for _, data in textures),
|
|
})
|
|
if output.exists():
|
|
backup = Path(tempfile.mkdtemp(prefix=f".{output.name}-old-", dir=output.parent))
|
|
backup.rmdir()
|
|
output.rename(backup)
|
|
try:
|
|
stage.rename(output)
|
|
except OSError:
|
|
if backup is not None:
|
|
backup.rename(output)
|
|
backup = None
|
|
raise
|
|
if backup is not None:
|
|
shutil.rmtree(backup)
|
|
print(f"Prepared {len(entries)} textures ({sum(len(data) for _, data in textures):,} PNG bytes)")
|
|
print(f"Package bundle: {output}")
|
|
finally:
|
|
if stage.exists():
|
|
shutil.rmtree(stage)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--source", type=Path, required=True, help="Unpacked Minecraft-format resource pack")
|
|
parser.add_argument("--output", type=Path, required=True, help="Output bundle for the server --packages option")
|
|
parser.add_argument("--name", default="Shacraft Pixel32 Study")
|
|
parser.add_argument("--id", default="shacraft.pixel32.study")
|
|
parser.add_argument("--version", default="1.0.0")
|
|
parser.add_argument("--pixel-size", type=int, choices=(16, 32, 64, 128), default=32,
|
|
help="Required native PNG dimensions (default: 32)")
|
|
parser.add_argument("--license", default="Local reference-derived study; redistribution rights not granted")
|
|
parser.add_argument("--textures", nargs="+", help="Selected texture names without .png (defaults to the 24-texture study)")
|
|
parser.add_argument("--overwrite", action="store_true", help="Replace a bundle previously created by this script")
|
|
args = parser.parse_args()
|
|
try:
|
|
prepare(args)
|
|
except (OSError, ValueError) as error:
|
|
parser.exit(1, f"Texture pack preparation failed: {error}\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|