- 98 commands across com wheel, item, enemy, ability and blitz groups - shared command table drives wheel dropdowns, keybinds and localization - tools/check_upstream.py validates triggers against the game source dump - weekly GitHub Action opens an issue when triggers change upstream
202 lines
8.4 KiB
Python
Executable File
202 lines
8.4 KiB
Python
Executable File
#!/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())
|