VoxCaster: every on-demand Darktide voice line on the com wheel or a keybind

- 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
This commit is contained in:
Marco
2026-08-24 16:27:02 +00:00
commit 32257ece9c
10 changed files with 880 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
#!/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())