Files
shacraft-core/scripts/prepare_texture_pack.py
Emil c7e86663d8
MVP checks / mvp (push) Waiting to run
Expand voxel gameplay, lighting, full-height streaming and world imports
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.
2026-09-17 02:10:53 +03:00

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()