495 lines
26 KiB
Python
495 lines
26 KiB
Python
"""Package the repository's available Beta data and evidence without running it.
|
|
|
|
This is an offline handoff builder, not a Client dumper. It never reads saves,
|
|
account settings or live process memory. Source files are copied byte-for-byte;
|
|
only the separate derived indices normalize large text-hash integers to strings.
|
|
"""
|
|
|
|
import argparse
|
|
import csv
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import re
|
|
from collections import Counter, defaultdict
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from zipfile import ZIP_DEFLATED, ZipFile
|
|
|
|
|
|
POLICY_FILES = {
|
|
"ActivityPreviewPolicy.json", "GachaSandboxPolicy.json", "AhaHelpConfig.json",
|
|
"CurrencyWarsSpotlightPolicy.json", "TeamPresetConfig.json",
|
|
}
|
|
ROOT_DATA = (
|
|
"res.json", "teleports.json", "challenge_data.json", "mission_unlocks.json",
|
|
"cmd_ids_4_6.json",
|
|
)
|
|
DOCS = (
|
|
"GLM-BETA-DATA.md", "GLM-BETA-EVENTS.md", "GLM-GACHA-GUIDE.md",
|
|
"ZCODE-HANDOFF.md", "offline-gameplay-continuation.md",
|
|
"currency-wars-investigation.md", "currency-wars-6900-audit.md",
|
|
"currency-wars-coverage.md", "protocol-update.md", "endgame-audit.md",
|
|
)
|
|
SOURCE_FILES = (
|
|
"internal/common/activity_preview.go", "internal/common/gacha_catalog.go",
|
|
"internal/common/stella_jump.go", "internal/common/res.go",
|
|
"internal/gameserver/handlers_activity_preview.go",
|
|
"internal/gameserver/handlers_gacha.go", "internal/gameserver/handlers_stella_jump.go",
|
|
"internal/gameserver/handlers_challenge.go", "internal/gameserver/dispatch.go",
|
|
)
|
|
EVIDENCE_FILES = {
|
|
"gacha-main-page.json", "gacha-main-page.luau", "gacha-main-page.hex",
|
|
"luau-bytecode.h", "luau-lvmload.cpp", "glm-beta-targeted-metadata.log",
|
|
"glm-beta-textid-metadata.log", "glm-gacha-group-enum.log",
|
|
"glm-gacha-groups-current.json", "glm-gacha-group-assets.json",
|
|
}
|
|
# These are config/type metadata captures, not unrestricted Player.log copies.
|
|
PREVIEW_CONFIG_LOGS = {
|
|
"preview-gameboy-current.log", "preview-metadata-current.log",
|
|
"preview-minigame-current.log", "preview-module-fields-current.log",
|
|
"preview-modules-current.log",
|
|
}
|
|
TOOL_PREFIXES = (
|
|
"preview_", "gacha_", "gameplay_", "fivedim_", "glm_beta",
|
|
"cw_current_", "export_cw_",
|
|
)
|
|
TOOL_FILES = {
|
|
"build_glm_beta_handoff.py", "inspect_luau_v4.py", "check_preview_assets.py",
|
|
"build_cw_role_compatibility.py", "disassemble_pe_range.py",
|
|
"find_pe_direct_calls.py", "find_pe_rip_references.py",
|
|
"cw_config_cache_export.lua", "cw_excel_export.lua",
|
|
"sync_protocol_commands.py", "audit_protocol_handlers.py",
|
|
}
|
|
HANDLERS = {
|
|
"GetFiveDimMiniGameData", "GetActiveActivityData", "QuestState",
|
|
"GetMultipleDropInfo",
|
|
}
|
|
|
|
|
|
def read_json(path):
|
|
return json.loads(path.read_text(encoding="utf-8-sig"))
|
|
|
|
|
|
def exact_hashes(value):
|
|
"""Avoid JavaScript/CSV consumers rounding 64-bit text hashes in indices."""
|
|
if isinstance(value, dict):
|
|
return {
|
|
key: str(item) if key.lower() == "hash64" and isinstance(item, int)
|
|
else exact_hashes(item)
|
|
for key, item in value.items()
|
|
}
|
|
if isinstance(value, list):
|
|
return [exact_hashes(item) for item in value]
|
|
if isinstance(value, int) and not isinstance(value, bool) and abs(value) > 2**53 - 1:
|
|
return str(value)
|
|
return value
|
|
|
|
|
|
def declared_hashes(value, prefix=""):
|
|
result = {}
|
|
if isinstance(value, dict):
|
|
for key, item in value.items():
|
|
location = f"{prefix}.{key}" if prefix else key
|
|
if isinstance(item, str) and "sha256" in key.lower():
|
|
result[location] = item
|
|
elif isinstance(item, (dict, list)):
|
|
result.update(declared_hashes(item, location))
|
|
elif isinstance(value, list):
|
|
for index, item in enumerate(value):
|
|
if isinstance(item, (dict, list)):
|
|
result.update(declared_hashes(item, f"{prefix}[{index}]"))
|
|
return result
|
|
|
|
|
|
def collection_counts(value):
|
|
if isinstance(value, list):
|
|
return {"$": len(value)}
|
|
if not isinstance(value, dict):
|
|
return {}
|
|
result = {key: len(item) for key, item in value.items() if isinstance(item, (dict, list))}
|
|
for key in ("tables",):
|
|
if isinstance(value.get(key), dict):
|
|
result.update({f"{key}.{name}": len(rows)
|
|
for name, rows in value[key].items()
|
|
if isinstance(rows, (dict, list))})
|
|
return result
|
|
|
|
|
|
def classify(path, value, client_hash):
|
|
name = path.name
|
|
if "Compatibility" in name:
|
|
return "compatibility-overlays"
|
|
if name in POLICY_FILES or name.endswith("Policy.json"):
|
|
return "server-policies"
|
|
hashes = declared_hashes(value)
|
|
assemblies = {item.lower() for key, item in hashes.items() if "assembly" in key.lower()}
|
|
if client_hash in assemblies:
|
|
return "current-client-captures"
|
|
if assemblies:
|
|
return "legacy-client-or-reference"
|
|
if isinstance(value, dict):
|
|
source = value.get("source")
|
|
if isinstance(source, dict) and (source.get("repository") or source.get("url")):
|
|
return "external-reference-data"
|
|
return "unversioned-server-data"
|
|
|
|
|
|
def policy_eligibility(panel, policy, availability):
|
|
handler = policy.get(panel.get("UIPrefab", ""), "")
|
|
issues = []
|
|
if not handler:
|
|
issues.append("family_not_enabled_by_policy")
|
|
if availability.get(panel.get("UIPrefab", "")) is not True:
|
|
issues.append("prefab_missing_or_unobserved")
|
|
if availability.get(panel.get("TabIcon", "")) is not True:
|
|
issues.append("tab_icon_missing_or_unobserved")
|
|
if panel.get("DisplayItemList"):
|
|
issues.append("explicit_display_item_dependencies")
|
|
return handler, issues
|
|
|
|
|
|
def derive_events(catalog, policy_document, availability_document):
|
|
panels = catalog.get("tables", {}).get("panels", [])
|
|
activities = catalog.get("tables", {}).get("activities", [])
|
|
paths = availability_document.get("paths", {})
|
|
policy = {row.get("prefab", ""): row.get("data_handler", "")
|
|
for row in policy_document.get("panels", [])}
|
|
linked = defaultdict(list)
|
|
unresolved = []
|
|
known = {row["PanelID"] for row in panels}
|
|
for activity in activities:
|
|
panel_id = activity.get("ActivityPanelID", 0) or activity.get("ActivityID", 0)
|
|
if panel_id in known:
|
|
linked[panel_id].append(activity)
|
|
else:
|
|
unresolved.append({"reason": "effective_panel_not_in_captured_panel_table",
|
|
"effective_panel_id": panel_id, "activity": activity})
|
|
index, eligible, module_panels = [], {}, defaultdict(set)
|
|
for panel in sorted(panels, key=lambda row: row["PanelID"]):
|
|
panel_id = panel["PanelID"]
|
|
associations = linked[panel_id]
|
|
main = sorted({m for a in associations for m in a.get("ActivityModuleIDList", []) if m})
|
|
resident = sorted({m for a in associations for m in a.get("ResidentModuleList", []) if m})
|
|
prefab = panel.get("UIPrefab", "")
|
|
handler, issues = policy_eligibility(panel, policy, paths)
|
|
row = {
|
|
"panel_id": panel_id,
|
|
"technical_prefab_name": Path(prefab).stem,
|
|
"name_is_localized": False,
|
|
"prefab": prefab,
|
|
"tab_icon": panel.get("TabIcon", ""),
|
|
"activity_ids": sorted({a.get("ActivityID", 0) for a in associations}),
|
|
"main_modules": main,
|
|
"resident_modules": resident,
|
|
"activity_associations": associations,
|
|
"unlock_conditions": panel.get("UnlockConditions", ""),
|
|
"finish_conditions": panel.get("FinishConditions", ""),
|
|
"finish_type": panel.get("FinishType"),
|
|
"prefab_asset_exists": paths.get(prefab),
|
|
"tab_icon_asset_exists": paths.get(panel.get("TabIcon", "")),
|
|
"display_items": panel.get("DisplayItemList", []),
|
|
"text_hashes": {k: panel[k] for k in (
|
|
"TabName", "TitleName", "TagDesc", "IntroDesc", "PanelDesc", "PanelBrief") if k in panel},
|
|
"policy_family": prefab if handler else None,
|
|
"data_handler": handler or None,
|
|
"preview_panel_eligible": not issues,
|
|
"preview_exclusion_reasons": issues,
|
|
"has_activity_association": bool(associations),
|
|
"gameplay_support": "not_established_by_catalog_or_asset_presence",
|
|
}
|
|
index.append(row)
|
|
if not issues:
|
|
eligible[panel_id] = (handler, prefab)
|
|
for module_id in main + resident:
|
|
module_panels[module_id].add(panel_id)
|
|
errors = []
|
|
if not policy_document.get("source"):
|
|
errors.append("activity preview policy missing")
|
|
if not catalog.get("evidence_id"):
|
|
errors.append("activity preview catalog provenance missing")
|
|
if not availability_document.get("source") or not isinstance(paths, dict):
|
|
errors.append("event asset verification missing")
|
|
for handler in policy.values():
|
|
if handler not in HANDLERS:
|
|
errors.append(f"unsupported preview data handler {handler}")
|
|
candidates, conflicts = [], []
|
|
for module_id, panel_ids in sorted(module_panels.items()):
|
|
panel_ids = sorted(panel_ids)
|
|
signatures = {eligible[p] for p in panel_ids}
|
|
if len(signatures) != 1:
|
|
conflicts.append({"module_id": module_id, "panel_ids": panel_ids,
|
|
"signatures": [{"panel_id": p, "data_handler": eligible[p][0],
|
|
"prefab": eligible[p][1]} for p in panel_ids]})
|
|
else:
|
|
candidates.append({"module_id": module_id, "panel_id": panel_ids[0],
|
|
"data_handler": eligible[panel_ids[0]][0],
|
|
"equivalent_candidate_panel_ids": panel_ids})
|
|
if conflicts:
|
|
errors.extend(f"activity module {c['module_id']} maps to conflicting panels {c['panel_ids']}"
|
|
for c in conflicts)
|
|
if policy and not module_panels:
|
|
errors.append("no supported activity-preview modules in captured catalog")
|
|
schedules = {
|
|
"semantics": "Mirror internal/common/activity_preview.go: filter eligible panels; sort module IDs and panel IDs; require identical handler+prefab for every candidate of each module; reject the whole result on any conflict.",
|
|
"status": "error" if errors else "ready",
|
|
"errors": errors,
|
|
"conflicts": conflicts,
|
|
"schedules": [] if errors else candidates,
|
|
"compatible_candidates_for_diagnosis_only": candidates if errors else [],
|
|
"not_a_gameplay_confirmation": True,
|
|
}
|
|
return exact_hashes(index), exact_hashes(unresolved), schedules
|
|
|
|
|
|
def derive_gacha(catalog, policy, group_document, group_assets):
|
|
observations = policy.get("asset_verification", {})
|
|
verified_paths = set(policy.get("verified_banner_prefabs", []))
|
|
rows = []
|
|
for row in catalog.get("tables", {}).get("gacha", []):
|
|
prefab = row.get("PrefabPath", "")
|
|
observed = None
|
|
basename = Path(prefab).name
|
|
if basename.startswith("StandardGacha_"):
|
|
observed = observations.get("standard")
|
|
elif basename.startswith("NoviceGacha_"):
|
|
observed = observations.get("novice")
|
|
elif "/GachaPanelLimited/" in prefab:
|
|
observed = observations.get("captured_limited_character_and_lightcone_prefabs")
|
|
rows.append({"gacha_id": row.get("GachaID"), "gacha_type": row.get("GachaType"),
|
|
"prefab": prefab, "asset_verified": observed,
|
|
"browse_enabled_by_policy": prefab in verified_paths,
|
|
"original_row": row})
|
|
group_note = "No supplemental current group-table capture is present in this bundle."
|
|
if group_document is not None:
|
|
group_note = "Use the supplemental capture's declared completion/count. A completed zero-row table is observed empty, not a missing capture. Loaded generic Lua presentation classes do not establish GroupIDs."
|
|
return exact_hashes({
|
|
"captured_basic_rows": rows,
|
|
"asset_verification_source": observations,
|
|
"group_table_capture": group_document,
|
|
"generic_group_prefab_asset_observations": group_assets,
|
|
"group_capture_note": group_note,
|
|
"limitation": "Catalog rows, prefab existence and server browse policy do not establish usable drawing, presentation binding or one banner for every avatar.",
|
|
})
|
|
|
|
|
|
class Bundle:
|
|
def __init__(self, output):
|
|
self.output = output
|
|
self.entries = []
|
|
|
|
def add(self, relative, data, provenance, source_relative=None, metadata=None):
|
|
target = self.output / relative
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
with target.open("xb") as stream:
|
|
stream.write(data)
|
|
entry = {"source_relative": source_relative, "bundle_relative": relative,
|
|
"sha256": hashlib.sha256(data).hexdigest(), "size_bytes": len(data),
|
|
"provenance_class": provenance}
|
|
if metadata:
|
|
entry.update(metadata)
|
|
self.entries.append(entry)
|
|
|
|
def json(self, relative, value, provenance="derived-index"):
|
|
data = (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
|
self.add(relative, data, provenance)
|
|
|
|
def copy(self, root, source, relative, provenance):
|
|
source = source.resolve()
|
|
source_relative = source.relative_to(root).as_posix()
|
|
data = source.read_bytes()
|
|
metadata = None
|
|
if source.suffix == ".json":
|
|
try:
|
|
value = json.loads(data.decode("utf-8-sig"))
|
|
metadata = {"declared_hashes": declared_hashes(value),
|
|
"collection_counts": collection_counts(value)}
|
|
except (ValueError, UnicodeError):
|
|
metadata = {"json_parse_status": "invalid_or_non_utf8; original bytes retained"}
|
|
self.add(relative, data, provenance, source_relative, metadata)
|
|
|
|
|
|
def build(root, output, client_hash, client_label):
|
|
root = root.resolve()
|
|
output = output.resolve()
|
|
archive = output.with_name(output.name + ".zip")
|
|
if output.exists() or archive.exists():
|
|
raise ValueError("Output directory and adjacent zip must both be new; existing paths are never overwritten.")
|
|
if not (root / "AGENTS.md").is_file() or not (root / "resources").is_dir():
|
|
raise ValueError("Root does not contain the expected repository structure.")
|
|
required = [root / "resources" / name for name in (
|
|
"PreviewGameplayCatalog.json", "ActivityPreviewPolicy.json",
|
|
"PreviewAssetAvailability.json", "GachaSandboxPolicy.json")]
|
|
# The derived indices and their copied raw inputs share the exact same bytes,
|
|
# even if another editor changes one of these files during bundle creation.
|
|
input_bytes = {path: path.read_bytes() for path in required}
|
|
catalog, policy, availability, gacha_policy = [
|
|
json.loads(input_bytes[path].decode("utf-8-sig")) for path in required]
|
|
if catalog.get("client_assembly_sha256", "").lower() != client_hash:
|
|
raise ValueError("Requested Client SHA differs from the captured preview catalog.")
|
|
if availability.get("client_assembly_sha256", "").lower() != client_hash:
|
|
raise ValueError("Requested Client SHA differs from the asset availability observations.")
|
|
output.mkdir(parents=True, exist_ok=False)
|
|
bundle = Bundle(output)
|
|
for source in sorted((root / "resources").glob("*.json")):
|
|
raw = input_bytes.get(source)
|
|
if raw is None:
|
|
raw = source.read_bytes()
|
|
value = json.loads(raw.decode("utf-8-sig"))
|
|
category = classify(source, value, client_hash)
|
|
bundle.add(f"data/{category}/resources/{source.name}", raw, category,
|
|
source.relative_to(root).as_posix(),
|
|
{"declared_hashes": declared_hashes(value), "collection_counts": collection_counts(value)})
|
|
for name in ROOT_DATA:
|
|
source = root / name
|
|
if source.is_file():
|
|
category = "protocol-reference" if name == "cmd_ids_4_6.json" else "unversioned-server-data"
|
|
bundle.copy(root, source, f"data/{category}/root/{name}", category)
|
|
proto_root = root / "protocol" / "proto"
|
|
for source in sorted(proto_root.iterdir()):
|
|
if source.suffix == ".proto" or (source.suffix == ".go" and not source.name.endswith(".pb.go")):
|
|
bundle.copy(root, source, f"protocol/{source.name}", "protocol-source-revision-not-full-client-dump")
|
|
bundle.copy(root, root / "AGENTS.md", "AGENTS.md", "repository-instructions")
|
|
for name in DOCS:
|
|
source = root / "docs" / name
|
|
if source.is_file():
|
|
bundle.copy(root, source, f"docs/{name}", "chronological-project-documentation")
|
|
for name in SOURCE_FILES:
|
|
source = root / name
|
|
if source.is_file():
|
|
bundle.copy(root, source, f"source/{name}", "selected-current-source; not gameplay verification")
|
|
tools_root = root / "tools"
|
|
for source in sorted(tools_root.iterdir()):
|
|
if (source.is_file() and source.suffix in {".py", ".lua", ".js", ".go"}
|
|
and (source.name in TOOL_FILES or source.name.startswith(TOOL_PREFIXES))
|
|
and not source.name.startswith("test_") and not source.name.endswith("_test.go")):
|
|
bundle.copy(root, source, f"tools/{source.name}", "offline-or-runtime-research-tool; not executed by bundle")
|
|
evidence_root = root / "battle_diagnostics"
|
|
if evidence_root.is_dir():
|
|
for source in sorted(evidence_root.iterdir()):
|
|
selected = (source.name in EVIDENCE_FILES or source.name in PREVIEW_CONFIG_LOGS
|
|
or (source.suffix == ".asm" and source.name.startswith(("preview-", "glm-"))))
|
|
if source.is_file() and selected:
|
|
bundle.copy(root, source, f"evidence/{source.name}", "selected-config-research-evidence")
|
|
group_file = evidence_root / "glm-gacha-groups-current.json"
|
|
group_asset_file = evidence_root / "glm-gacha-group-assets.json"
|
|
group_document = read_json(group_file) if group_file.is_file() else None
|
|
group_assets = read_json(group_asset_file) if group_asset_file.is_file() else None
|
|
event_index, unresolved, schedules = derive_events(catalog, policy, availability)
|
|
bundle.json("derived/event-index.json", event_index)
|
|
bundle.json("derived/unresolved-activities.json", unresolved)
|
|
bundle.json("derived/preview-schedules.json", schedules)
|
|
bundle.json("derived/gacha-index.json", derive_gacha(catalog, gacha_policy, group_document, group_assets))
|
|
csv_stream = io.StringIO(newline="")
|
|
columns = list(event_index[0]) if event_index else ["panel_id"]
|
|
writer = csv.DictWriter(csv_stream, fieldnames=columns)
|
|
writer.writeheader()
|
|
for row in event_index:
|
|
writer.writerow({key: json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else value
|
|
for key, value in row.items()})
|
|
bundle.add("derived/event-index.csv", csv_stream.getvalue().encode("utf-8-sig"), "derived-index")
|
|
tables = catalog.get("tables", {})
|
|
classes = Counter(entry["provenance_class"] for entry in bundle.entries)
|
|
readme = f"""# Beta data handoff for GLM
|
|
|
|
Client label supplied by operator: {client_label}
|
|
|
|
Current assembly SHA-256: `{client_hash}`
|
|
|
|
## Scope
|
|
|
|
This bundle contains **all currently available resource JSON files in this checkout**, selected root data,
|
|
protocol metadata, research tools and bounded config evidence. It is **not a dump of all Beta game data**.
|
|
No game asset bundles, native executable, account settings, `persistent`, `freesr-data.json`,
|
|
`server.local.json` or unrestricted player/server logs are included. Tools are copied, never executed.
|
|
|
|
Captured current preview tables: {', '.join(f'{name}={len(rows)}' for name, rows in tables.items())}.
|
|
Event presence observations: {len(availability.get('paths', {}))} paths.
|
|
Event policy families: {len(policy.get('panels', []))}.
|
|
Event index: {len(event_index)} captured panels; unresolved activity associations: {len(unresolved)}.
|
|
Preview schedule derivation status: **{schedules['status']}**; publishable schedule count: {len(schedules['schedules'])}.
|
|
|
|
## Start here
|
|
|
|
- `derived/event-index.json` / CSV: technical prefab names, ActivityID-to-PanelID associations,
|
|
main/resident modules, raw unlock/finish conditions, item dependencies, observed assets and policy eligibility.
|
|
Technical names are **not localized event titles**. Text hashes are retained and 64-bit values are decimal strings.
|
|
- `derived/unresolved-activities.json`: ActivityConfig rows whose effective panel is absent from the captured panel table.
|
|
- `derived/preview-schedules.json`: deterministic server eligibility/conflict rules from `internal/common/activity_preview.go`.
|
|
A conflict rejects the entire Go schedule result; diagnostic candidates are not published partial success.
|
|
- `derived/gacha-index.json`: six captured basic rows when present in the source catalog, observed asset presence,
|
|
policy browsing status and supplemental current group-table capture/prefab observations when supplied.
|
|
- The current group table's explicit completed zero-row capture, if present, means **observed empty**;
|
|
generic Lua presentation classes do not supply missing GroupIDs or prove an installed generic prefab.
|
|
- `docs/ZCODE-HANDOFF.md` and other documents retain chronology. Some deployment/gameplay status may be superseded.
|
|
- `docs/GLM-BETA-DATA.md`, `docs/GLM-BETA-EVENTS.md` and `docs/GLM-GACHA-GUIDE.md` contain the current Thai handoff.
|
|
- `source/internal/` includes selected event/gacha/entry handler source for comparison; it is not the whole buildable repository.
|
|
|
|
## Provenance and limitations
|
|
|
|
- `data/current-client-captures`: an explicit matching assembly hash is present. Coverage is only the rows/tables declared.
|
|
- `data/compatibility-overlays`: cross-revision/exclusion mappings, not a recaptured full ruleset.
|
|
- `data/server-policies`: operator-defined sandbox choices, not captured official rates or gameplay guarantees.
|
|
- `data/legacy-client-or-reference`: explicit different assembly provenance; do not relabel as the current Client.
|
|
- `data/external-reference-data`: external repository/dataset sources.
|
|
- `data/unversioned-server-data`: no matching assembly proof was found. Presence or successful server use does not establish revision.
|
|
- Protocol files retain their own source revision; this bundle does not certify the full protocol against this Client.
|
|
- Raw copied files preserve original bytes and numeric JSON tokens. Derived indices stringify 64-bit hashes for readers
|
|
that would otherwise round JSON numbers. Never reserialize raw game tables through floating-point JavaScript numbers.
|
|
- Asset existence is not gameplay support. Do not expose every historical panel with empty success replies.
|
|
- Validate current process/module identity before any runtime investigation. Never reuse old session pointers.
|
|
- Keep runtime probes outside `scripts/` when inactive, bound work per frame and avoid broad scene/assembly scans.
|
|
|
|
## Manifest
|
|
|
|
`manifest.json` records source-relative path, bundle path, SHA-256, size, classification, declared hashes and collection counts.
|
|
It deliberately does not hash itself or the zip, avoiding recursive hashes. The zip contains the same bundle contents.
|
|
Files by category: {json.dumps(dict(sorted(classes.items())), ensure_ascii=False)}.
|
|
|
|
Build and Client gameplay success are separate. Follow AGENTS.md and the user's current authorization before running tests,
|
|
deploying or interacting with a live game. Creating this bundle does not run tests or change the server.
|
|
"""
|
|
bundle.add("README-GLM.md", readme.encode("utf-8"), "handoff-documentation")
|
|
manifest = {
|
|
"format_version": 1,
|
|
"created_at_utc": datetime.now(timezone.utc).isoformat(),
|
|
"client_label": client_label,
|
|
"client_assembly_sha256": client_hash,
|
|
"scope": "all currently available repository resources plus explicitly selected supporting data; not all Beta Client data",
|
|
"files": sorted(bundle.entries, key=lambda entry: entry["bundle_relative"]),
|
|
"manifest_excludes_own_hash_and_archive_hash": True,
|
|
}
|
|
with (output / "manifest.json").open("x", encoding="utf-8", newline="\n") as stream:
|
|
json.dump(manifest, stream, ensure_ascii=False, indent=2)
|
|
stream.write("\n")
|
|
with ZipFile(archive, "x", compression=ZIP_DEFLATED) as zipped:
|
|
for source in sorted(output.rglob("*")):
|
|
if source.is_file():
|
|
zipped.write(source, arcname=f"{output.name}/{source.relative_to(output).as_posix()}")
|
|
return output, archive, len(bundle.entries) + 1
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
|
|
parser.add_argument("--output", type=Path, required=True, help="New bundle directory; an adjacent .zip is also created.")
|
|
parser.add_argument("--client-sha256", required=True)
|
|
parser.add_argument("--client-label", required=True)
|
|
args = parser.parse_args()
|
|
client_hash = args.client_sha256.lower()
|
|
if not re.fullmatch(r"[0-9a-f]{64}", client_hash):
|
|
parser.error("--client-sha256 must be exactly 64 hexadecimal characters")
|
|
if not args.client_label.strip() or any(c in args.client_label for c in "\r\n"):
|
|
parser.error("--client-label must be a nonempty single line")
|
|
try:
|
|
output, archive, count = build(args.root, args.output, client_hash, args.client_label.strip())
|
|
except (OSError, ValueError, KeyError) as error:
|
|
parser.exit(1, f"Handoff build failed: {error}\nExisting paths were not deleted; inspect any newly created partial output before choosing another output.\n")
|
|
print(f"Created {count} files: {output}")
|
|
print(f"Created archive: {archive}")
|
|
print("No Client probes, tests, save writes or server changes were performed.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|