Files
GaCy-GO/tools/build_glm_beta_handoff.py
T
2026-10-28 18:54:43 +07:00

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