#!/usr/bin/env python3 """ check_upstream.py - keep VoxCaster in sync with Darktide's VO system. Pulls the decompiled game source (Aussiemon/Darktide-Source-Code) and: 1. Verifies every trigger in VoxCaster_commands.lua still exists in the game. 2. Lists any on-demand / ability / blitz triggers the game has that the mod doesn't expose yet. Exit code 1 if anything is missing or new, so CI can flag it. Usage: python3 tools/check_upstream.py # report only python3 tools/check_upstream.py --json out.json """ import argparse import json import os import re import sys import urllib.request from pathlib import Path RAW = "https://raw.githubusercontent.com/Aussiemon/Darktide-Source-Code/master/" API_TREE = "https://api.github.com/repos/Aussiemon/Darktide-Source-Code/git/trees/master?recursive=1" ROOT = Path(__file__).resolve().parent.parent # Used when the GitHub tree API is rate-limited. Keep this in sync with new classes. FALLBACK_TEMPLATE_FILES = [ "scripts/settings/equipment/weapon_templates/missile_launchers/missile_launcher.lua", "scripts/settings/ability/ability_templates/broker_focus.lua", "scripts/settings/ability/ability_templates/broker_punk_rage.lua", "scripts/settings/equipment/weapon_templates/combat_abilities/broker_stimm_field.lua", "scripts/settings/ability/ability_templates/adamant_charge.lua", "scripts/settings/ability/ability_templates/adamant_shout.lua", "scripts/settings/ability/ability_templates/adamant_stance.lua", "scripts/settings/ability/ability_templates/ogryn_charge.lua", "scripts/settings/ability/ability_templates/ogryn_gunlugger_stance.lua", "scripts/settings/ability/ability_templates/ogryn_taunt_shout.lua", "scripts/settings/equipment/weapon_templates/grenades/psyker_chain_lightning.lua", "scripts/settings/ability/ability_templates/psyker_overcharge_stance.lua", "scripts/settings/equipment/weapon_templates/grenades/psyker_smite.lua", "scripts/settings/equipment/weapon_templates/grenades/psyker_throwing_knives.lua", "scripts/settings/ability/ability_templates/zealot_dash.lua", "scripts/settings/ability/ability_templates/zealot_invisibility.lua", "scripts/settings/equipment/weapon_templates/combat_abilities/zealot_relic.lua", ] MOD_NAME = next(p.name for p in ROOT.iterdir() if p.is_dir() and (p / f"{p.name}.mod").exists()) COMMANDS_FILE = ROOT / MOD_NAME / "scripts" / "mods" / MOD_NAME / f"{MOD_NAME}_commands.lua" def fetch(url, token=None): req = urllib.request.Request(url, headers={"User-Agent": "voxcaster-sync"}) if token and "api.github.com" in url: req.add_header("Authorization", f"Bearer {token}") with urllib.request.urlopen(req, timeout=60) as r: return r.read().decode("utf-8-sig") def parse_rules(lua): return re.findall(r"define_rule\(\{(.*?)\n\t\}\)", lua, re.S) def rule_values(rules, concept_wanted, field): """All values a query_context field takes for rules with the given concept.""" out = set() for r in rules: c = re.search(r'"concept",\s*OP\.EQ,\s*"([^"]+)"', r) if not c or c.group(1) != concept_wanted: continue for v in re.findall(rf'"query_context",\s*"{field}",\s*OP\.EQ,\s*"([^"]+)"', r): out.add(v) for block in re.findall(rf'"query_context",\s*"{field}",\s*OP\.SET_INCLUDES,\s*args = \{{(.*?)\}}', r, re.S): out.update(re.findall(r'"([^"]+)"', block)) return out def game_triggers(token=None): """Return {concept: set(trigger)} as the game currently knows them.""" result = {} # 1. On-demand rules (com wheel / item / enemy) od = parse_rules(fetch(RAW + "dialogues/generated/on_demand_vo.lua")) result["on_demand_com_wheel"] = rule_values(od, "on_demand_com_wheel", "trigger_id") result["on_demand_vo_tag_item"] = rule_values(od, "on_demand_vo_tag_item", "item_tag") result["on_demand_vo_tag_enemy"] = rule_values(od, "on_demand_vo_tag_enemy", "enemy_tag") # 2. Ability + throw rules in the shared gameplay databases ability, throw = set(), set() for db in ("gameplay_vo", "class_rework"): rules = parse_rules(fetch(RAW + f"dialogues/generated/{db}.lua")) ability |= rule_values(rules, "combat_ability", "ability_name") throw |= rule_values(rules, "throwing_item", "item") # 3. vo_tags actually wired into ability / weapon templates (the reliable source # for new class abilities - rules for them may live in per-class databases). vo_tags = set() paths = list(FALLBACK_TEMPLATE_FILES) try: tree = json.loads(fetch(API_TREE, token))["tree"] paths = [ p["path"] for p in tree if p["path"].endswith(".lua") and ( "ability_templates" in p["path"] or "weapon_templates/combat_abilities" in p["path"] or "weapon_templates/missile" in p["path"] or "weapon_templates/grenades" in p["path"] ) ] except Exception as e: # rate limit etc. - fall back to the known file list print(f"[warn] tree API unavailable ({e}); using fallback template list", file=sys.stderr) for path in paths: try: vo_tags |= set(re.findall(r'vo_tag\w* = "([^"]+)"', fetch(RAW + path))) except Exception as e: print(f"[warn] could not fetch {path}: {e}", file=sys.stderr) # 4. Player projectile templates (valid throwing_item names) ppt = fetch(RAW + "scripts/settings/projectile/player_projectile_templates.lua") projectiles = set(re.findall(r"^projectile_templates\.(\w+) = \{", ppt, re.M)) # "valid" = every name the game would accept; "new" is only reported for names # that have an actual dialogue rule or a wired-up vo_tag, to avoid noise from # projectiles (staff balls, airstrikes...) that have no voice line. result["combat_ability"] = ability | vo_tags result["throwing_item"] = throw | projectiles result["_new_candidates"] = {"combat_ability": ability | vo_tags, "throwing_item": throw} return result def mod_commands(): """Parse the mod's command list: returns list of (key, concept, trigger).""" src = COMMANDS_FILE.read_text() consts = {} vq = fetch(RAW + "scripts/settings/dialogue/vo_query_constants.lua") for k, v in re.findall(r'^\s*(\w+) = "([^"]+)"', vq, re.M): consts[k] = v kind_to_concept = { "com": "on_demand_com_wheel", "item": "on_demand_vo_tag_item", "enemy": "on_demand_vo_tag_enemy", "ability": "combat_ability", "blitz": "throwing_item", } cmds = [] for kind, _id, key, _text, trig in re.findall( r'^\s*(com|item|enemy|ability|blitz)\((\d+),\s*"([^"]+)",\s*"([^"]+)",\s*([^,)]+)', src, re.M ): trig = trig.strip() m = re.match(r'trigger_ids\.(\w+)(?:\s*or\s*"([^"]+)")?', trig) if m: value = consts.get(m.group(1), m.group(2)) else: value = trig.strip('"') cmds.append((key, kind_to_concept[kind], value)) return cmds def main(): ap = argparse.ArgumentParser() ap.add_argument("--json", help="write machine-readable report here") args = ap.parse_args() token = os.environ.get("GITHUB_TOKEN") game = game_triggers(token) cmds = mod_commands() missing = [(k, c, t) for k, c, t in cmds if t is None or t not in game.get(c, set())] game = {c: ts for c, ts in game.items()} # keep _new_candidates for the "new" pass have = {(c, t) for _, c, t in cmds} candidates = {c: ts for c, ts in game.items() if not c.startswith("_")} candidates.update(game["_new_candidates"]) new = {c: sorted(t for t in ts if (c, t) not in have) for c, ts in candidates.items()} new = {c: ts for c, ts in new.items() if ts} print(f"Checked {len(cmds)} commands against the game source.\n") if missing: print("MISSING - these triggers no longer exist in the game (lines will be silent):") for k, c, t in missing: print(f" - {k}: {c} / {t}") print() if new: print("NEW - triggers in the game that the mod does not expose yet:") for c, ts in new.items(): print(f" [{c}]") for t in ts: print(f" - {t}") print() if not missing and not new: print("Up to date.") if args.json: Path(args.json).write_text(json.dumps({"missing": missing, "new": new}, indent=2)) return 1 if (missing or new) else 0 if __name__ == "__main__": sys.exit(main())