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:
Executable
+201
@@ -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())
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rename the mod. Usage: tools/rename.sh NewName
|
||||
set -euo pipefail
|
||||
NEW="${1:?usage: tools/rename.sh NewName}"
|
||||
cd "$(dirname "$0")/.."
|
||||
OLD=$(ls -d */ | sed 's#/##' | while read d; do [ -f "$d/$d.mod" ] && echo "$d"; done | head -1)
|
||||
[ -n "$OLD" ] || { echo "could not find current mod folder"; exit 1; }
|
||||
echo "Renaming $OLD -> $NEW"
|
||||
git mv "$OLD/scripts/mods/$OLD" "$OLD/scripts/mods/$NEW" 2>/dev/null || mv "$OLD/scripts/mods/$OLD" "$OLD/scripts/mods/$NEW"
|
||||
for f in "$OLD/scripts/mods/$NEW/$OLD"*.lua "$OLD/$OLD.mod"; do
|
||||
n="${f//$OLD/$NEW}"; n="${n/#$NEW\/scripts/$OLD/scripts}"; n="${n/#$NEW\/$NEW.mod/$OLD/$NEW.mod}"
|
||||
git mv "$f" "$n" 2>/dev/null || mv "$f" "$n"
|
||||
done
|
||||
git mv "$OLD" "$NEW" 2>/dev/null || mv "$OLD" "$NEW"
|
||||
grep -rl "$OLD" "$NEW" README.md tools .github | xargs sed -i "s/$OLD/$NEW/g"
|
||||
echo "Done. Review with: git status"
|
||||
Reference in New Issue
Block a user