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
+42
View File
@@ -0,0 +1,42 @@
name: Check Darktide source for VO changes
on:
schedule:
- cron: "0 9 * * 1" # every Monday 09:00 UTC
workflow_dispatch: # "Run workflow" button in the Actions tab
permissions:
contents: read
issues: write
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Compare mod commands against game source
id: check
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set +e
python3 tools/check_upstream.py --json report.json | tee report.txt
echo "changed=$?" >> "$GITHUB_OUTPUT"
- name: Open / update issue if something changed
if: steps.check.outputs.changed == '1'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TITLE="VO triggers changed in Darktide source"
BODY="$(printf '%s\n\n```\n%s\n```\n\nRun `python3 tools/check_upstream.py` locally, add the new triggers to `VoxCaster_commands.lua` + `_localization.lua`, and re-run until it says *Up to date*.' "Automated check on $(date -u +%F)." "$(cat report.txt)")"
EXISTING=$(gh issue list --state open --search "$TITLE in:title" --json number --jq '.[0].number')
if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --body "$BODY"
else
gh issue create --title "$TITLE" --body "$BODY" --label "upstream"
fi
+5
View File
@@ -0,0 +1,5 @@
report.json
report.txt
__pycache__/
*.zip
.DS_Store
+73
View File
@@ -0,0 +1,73 @@
# VoxCaster
A [Darktide](https://www.playdarktide.com/) mod (requires the
[Darktide Mod Framework](https://www.nexusmods.com/warhammer40kdarktide/mods/19))
that lets you put **any** of your character's on-demand voice lines on the
communication wheel or on a keybind.
98 lines, in five groups:
| Group | What | Examples |
|---|---|---|
| Com wheel | The vanilla wheel lines + the two the wheel never shows | Yes, No, Thanks, *My Pleasure*, *Take This* |
| Item | Every item-tag callout | Ammo, Medipack, Stimms, Grimoire, Vacuum Capsule |
| Enemy | Every enemy-tag callout | Scab Gunner, Trapper, Crusher, Daemonhost (sleeping/awake) |
| Ability | Class ability lines | **Hive Scum - Missile Launcher**, Voice of Command, Bull Rush, Chorus |
| Blitz | Grenade / throw lines | Krak, Big Rock, Grenade Box, Tox, Flash |
Com wheel, item and enemy lines work on every character. **Ability and blitz
lines only play on the class/personality that owns them** (the game's dialogue
rules check your voice profile), and some require enemies nearby.
## Install
Copy the `VoxCaster/` folder into `Warhammer 40,000 DARKTIDE/mods/` and add
`VoxCaster` to `mod_load_order.txt`.
## Configure
Mod Options → VoxCaster:
- Eight dropdowns, one per wheel slot. Anything you pick replaces the vanilla
entry in that slot.
- Five keybind groups. Bind any line to any key.
## Keeping it up to date
Fatshark adds classes and lines over time. This repo tracks the
[decompiled game source](https://github.com/Aussiemon/Darktide-Source-Code) so
new lines can be added the moment they show up there.
```
python3 tools/check_upstream.py
```
verifies every trigger in the mod still exists in the game and lists any new
ones the mod doesn't expose yet. A GitHub Action runs it every Monday and opens
an issue when something changes. To add a line:
1. Add one entry to `VoxCaster_commands.lua` (`com`, `item`, `enemy`,
`ability` or `blitz`) with the next free id.
2. Add the matching `option_<key>` string to `VoxCaster_localization.lua`.
3. Re-run the checker until it says `Up to date.`
## How it works
Each command is a `(concept, trigger)` pair fed to the same `Vo.*` call the
game itself uses:
| Group | Call |
|---|---|
| Com wheel / item / enemy | `Vo.on_demand_vo_event(unit, concept, trigger)` |
| Ability | `Vo.play_combat_ability_event(unit, vo_tag)` |
| Blitz | `Vo.throwing_item_event(unit, projectile_template)` |
Enemy callouts pass the breed name as the tag with no target unit, which the
game accepts. `Vo.on_demand_vo_event` is hooked so ability/blitz entries on the
wheel are routed to the right call.
Originally based on the "For the Vacuum Capsule" mod.
## Rename
`tools/rename.sh NewName` renames the folder, files and every reference.
+12
View File
@@ -0,0 +1,12 @@
return {
run = function()
fassert(rawget(_G, "new_mod"), "`VoxCaster` encountered an error loading the Darktide Mod Framework.")
new_mod("VoxCaster", {
mod_script = "VoxCaster/scripts/mods/VoxCaster/VoxCaster",
mod_data = "VoxCaster/scripts/mods/VoxCaster/VoxCaster_data",
mod_localization = "VoxCaster/scripts/mods/VoxCaster/VoxCaster_localization",
})
end,
packages = {},
}
@@ -0,0 +1,138 @@
local mod = get_mod("VoxCaster")
local Vo = require("scripts/utilities/vo")
local commands = mod.commands
or mod:io_dofile("VoxCaster/scripts/mods/VoxCaster/VoxCaster_commands")
local commands_by_id = mod.commands_by_id
local HudElementSmartTagging_instance
-- Wheel slot indices used by HudElementSmartTagging:_populate_wheel
local WHEEL_SLOTS = {
{ setting = "plugin_wheel_bottom", index = 1 },
{ setting = "plugin_wheel_bottom_right", index = 2 },
{ setting = "plugin_wheel_right", index = 3 },
{ setting = "plugin_wheel_top_right", index = 4 },
{ setting = "plugin_wheel_top", index = 5 },
{ setting = "plugin_wheel_top_left", index = 6 },
{ setting = "plugin_wheel_left", index = 7 },
{ setting = "plugin_wheel_bottom_left", index = 8 },
}
-- ---------------------------------------------------------------------------
-- Localization for wheel labels.
-- The wheel looks display names up through LocalizationManager, so we answer
-- our own keys directly instead of stuffing the cache on every call.
-- ---------------------------------------------------------------------------
local wheel_loc = {}
for _, cmd in ipairs(commands) do
wheel_loc["loc_ftvc_" .. cmd.key] = cmd.text
end
mod:hook(LocalizationManager, "localize", function(func, self, key, no_cache, context)
local custom = wheel_loc[key]
if custom then
return custom
end
return func(self, key, no_cache, context)
end)
-- ---------------------------------------------------------------------------
-- Build the option table the smart-tag wheel expects for a command.
-- ---------------------------------------------------------------------------
local function wheel_option(cmd)
return {
icon = cmd.icon,
display_name = "loc_ftvc_" .. cmd.key,
voice_event_data = {
voice_tag_concept = cmd.concept,
voice_tag_id = cmd.trigger,
},
}
end
local base_wheel_options
mod:hook("HudElementSmartTagging", "_populate_wheel", function(func, self, options)
-- First call comes from the game's init with the vanilla wheel. Keep a copy so we
-- can rebuild from a clean slate when the user changes a dropdown mid-session.
if not base_wheel_options then
base_wheel_options = table.clone(options)
end
for _, slot in ipairs(WHEEL_SLOTS) do
local id = mod:get(slot.setting) or 0
local cmd = id > 0 and commands_by_id[id]
if cmd then
options[slot.index] = wheel_option(cmd)
end
end
func(self, options)
end)
-- ---------------------------------------------------------------------------
-- VO dispatch. Ability and blitz lines don't go through the on-demand concepts,
-- so route them to the same Vo calls the game itself uses for them.
-- ---------------------------------------------------------------------------
local function fire_vo(player_unit, concept, trigger)
if concept == "combat_ability" then
Vo.play_combat_ability_event(player_unit, trigger)
elseif concept == "throwing_item" then
Vo.throwing_item_event(player_unit, trigger)
else
-- For on_demand_vo_tag_enemy, target_unit is nil, so the game falls back to
-- event_data.enemy_tag = trigger (the breed name). That's exactly what we want.
Vo.on_demand_vo_event(player_unit, concept, trigger)
end
end
-- The com wheel calls Vo.on_demand_vo_event with whatever we put in voice_event_data,
-- so intercept our two non-on-demand concepts there too.
mod:hook(Vo, "on_demand_vo_event", function(func, unit, concept, trigger_id, target_unit)
if concept == "combat_ability" or concept == "throwing_item" then
return fire_vo(unit, concept, trigger_id)
end
return func(unit, concept, trigger_id, target_unit)
end)
local function play_command(cmd)
if not HudElementSmartTagging_instance then
return
end
local parent = HudElementSmartTagging_instance._parent
local player_unit = parent and parent:player_unit()
if player_unit then
fire_vo(player_unit, cmd.concept, cmd.trigger)
end
end
-- Register keybind_<id> for every command. These exist from load so the
-- keybind widgets always resolve; they become live once the HUD element is up.
for _, cmd in ipairs(commands) do
mod["keybind_" .. cmd.id] = function()
play_command(cmd)
end
end
mod:hook_safe("HudElementSmartTagging", "update", function(self)
if not HudElementSmartTagging_instance then
HudElementSmartTagging_instance = self
end
if mod.setting_dirty then
if base_wheel_options then
self:_populate_wheel(table.clone(base_wheel_options))
end
mod.setting_dirty = false
end
end)
mod.on_setting_changed = function(setting_id)
if string.find(setting_id, "^plugin_wheel_") then
mod.setting_dirty = true
end
end
@@ -0,0 +1,172 @@
-- Shared command list. Loaded by both the main script and the _data file.
-- IDs 1-27 are the original mod's IDs and must not change (they are what
-- gets stored in the user's dropdown settings). New entries are appended.
local mod = get_mod("VoxCaster")
local VOQueryConstants = require("scripts/settings/dialogue/vo_query_constants")
local concepts = VOQueryConstants.concepts
local trigger_ids = VOQueryConstants.trigger_ids
local ICON_WHEEL = "content/ui/materials/hud/communication_wheel/icons/"
local ICON_ITEM = "content/ui/materials/hud/interactions/icons/"
local ICON_POCKET = "content/ui/materials/icons/pocketables/hud/"
-- Small helpers so each entry is one line.
-- group: "wheel" | "item" | "enemy" (used for dropdown label prefix)
local function com(id, key, text, trigger, icon)
return { id = id, key = key, text = text, group = "wheel", icon = icon or (ICON_WHEEL .. "attention"),
concept = concepts.on_demand_com_wheel, trigger = trigger }
end
local function item(id, key, text, trigger, icon)
return { id = id, key = key, text = text, group = "item", icon = icon or (ICON_ITEM .. "pocketable_default"),
concept = concepts.on_demand_vo_tag_item, trigger = trigger }
end
-- Enemy tag lines: the game's Vo.on_demand_vo_event() sets event_data.enemy_tag = trigger_id
-- when no target unit is given, so passing the breed name is enough to fire the callout.
local function enemy(id, key, text, breed_name)
return { id = id, key = key, text = text, group = "enemy", icon = ICON_WHEEL .. "enemy",
concept = concepts.on_demand_vo_tag_enemy, trigger = breed_name }
end
-- Class ability lines. Fired by the game via Vo.play_combat_ability_event(unit, vo_tag)
-- (concept "combat_ability", ability_name = vo_tag). The launcher line is the wield
-- vo_tag on the missile launcher weapon template.
local function ability(id, key, text, vo_tag, icon)
return { id = id, key = key, text = text, group = "ability", icon = icon or (ICON_WHEEL .. "attention"),
concept = "combat_ability", trigger = vo_tag }
end
-- Blitz/throw lines. Fired by Vo.throwing_item_event(unit, projectile_template_name)
-- (concept "throwing_item", item = template name).
local function blitz(id, key, text, projectile_name)
return { id = id, key = key, text = text, group = "blitz", icon = ICON_ITEM .. "pocketable_default",
concept = "throwing_item", trigger = projectile_name }
end
local commands = {
-- ===== Original IDs (unchanged) =====
com(1, "for_the_emperor", "For The Emperor!", trigger_ids.com_wheel_vo_for_the_emperor, "content/ui/materials/icons/system/escape/achievements"),
item(2, "vacuum_capsule", "Vacuum Capsule", trigger_ids.smart_tag_vo_pickup_container, ICON_ITEM .. "training_grounds"),
com(3, "yes", "Yes", trigger_ids.com_wheel_vo_yes, ICON_POCKET .. "scripture"),
com(4, "no", "No", trigger_ids.com_wheel_vo_no, "content/ui/materials/icons/system/settings/category_interface"),
item(5, "cryonic_rod", "Cryonic Rod", trigger_ids.smart_tag_vo_pickup_control_rod, ICON_ITEM .. "training_grounds"),
item(6, "power_cell", "Power Cell", trigger_ids.smart_tag_vo_pickup_battery, ICON_ITEM .. "training_grounds"),
item(7, "scripture", "Scripture", trigger_ids.smart_tag_vo_pickup_side_mission_tome, ICON_POCKET .. "scripture"),
item(8, "grimoire", "Grimoire", trigger_ids.smart_tag_vo_pickup_side_mission_grimoire, ICON_POCKET .. "grimoire"),
item(9, "relic", "Relic", trigger_ids.smart_tag_vo_pickup_side_mission_consumable, ICON_POCKET .. "grimoire"),
item(10, "health_booster", "Health Booster", trigger_ids.smart_tag_vo_pickup_health_booster or "pup_stimm_health", ICON_ITEM .. "pocketable_medkit"),
com(11, "mine", "Mine", trigger_ids.com_wheel_vo_need_that, ICON_ITEM .. "pocketable_default"),
item(12, "ammo", "Ammo", trigger_ids.smart_tag_vo_pickup_ammo, ICON_ITEM .. "pocketable_ammo"),
item(13, "grenade", "Grenade", trigger_ids.smart_tag_vo_small_grenade),
item(14, "plasteel", "Plasteel", trigger_ids.smart_tag_vo_pickup_forge_metal),
item(15, "diamantine", "Diamantine", trigger_ids.smart_tag_vo_pickup_platinum),
com(16, "following", "Following", trigger_ids.com_wheel_vo_follow_you, ICON_POCKET .. "scripture"),
item(17, "medicae_station", "Medicae Station", trigger_ids.smart_tag_vo_station_health, ICON_ITEM .. "pocketable_medkit"),
item(18, "uncharged_medicae", "Uncharged Medicae", trigger_ids.smart_tag_vo_station_health_without_battery, ICON_ITEM .. "pocketable_medkit"),
item(19, "medipack", "Medipack", trigger_ids.smart_tag_vo_pickup_medical_crate, ICON_ITEM .. "pocketable_medkit"),
-- NOTE: the constant for this one points at a generic_mission_vo tag; the on-demand rule wants this literal.
item(20, "medipack_down", "Medipack Down", "pup_deployed_medical_crate", ICON_ITEM .. "pocketable_medkit"),
item(21, "ammo_crate", "Ammo Crate", trigger_ids.smart_tag_vo_pickup_deployed_ammo_crate, ICON_ITEM .. "pocketable_ammo"),
com(22, "need_ammo", "Need Ammo", trigger_ids.com_wheel_vo_need_ammo, ICON_WHEEL .. "ammo"),
com(23, "need_healing", "Need Healing", trigger_ids.com_wheel_vo_need_health, ICON_WHEEL .. "health"),
com(24, "look_there", "Look There", trigger_ids.com_wheel_vo_over_here, ICON_WHEEL .. "attention"),
com(25, "go_there", "Go There", trigger_ids.com_wheel_vo_lets_go_this_way, ICON_WHEEL .. "location"),
com(26, "enemy", "Enemy", trigger_ids.com_wheel_vo_enemy_over_here, ICON_WHEEL .. "enemy"),
com(27, "thanks", "Thanks", trigger_ids.com_wheel_vo_thank_you, ICON_WHEEL .. "thanks"),
-- ===== New: remaining com wheel lines =====
com(28, "my_pleasure", "My Pleasure", trigger_ids.com_wheel_vo_my_pleasure, ICON_WHEEL .. "thanks"),
com(29, "take_this", "Take This", trigger_ids.com_wheel_vo_take_this, ICON_ITEM .. "pocketable_default"),
-- ===== New: stimms =====
item(30, "stimm_health", "Stimm (Health)", trigger_ids.smart_tag_vo_pickup_stimm_health, ICON_ITEM .. "pocketable_medkit"),
item(31, "stimm_concentration", "Stimm (Concentration)", trigger_ids.smart_tag_vo_pickup_stimm_concentration),
item(32, "stimm_power", "Stimm (Power)", trigger_ids.smart_tag_vo_pickup_stimm_power),
item(33, "stimm_speed", "Stimm (Speed)", trigger_ids.smart_tag_vo_pickup_stimm_speed),
-- ===== New: enemy tag callouts (breed names from dialogues/on_demand_vo) =====
-- Scabs (Moebian 6th)
enemy(34, "scab_gunner", "Scab Gunner", "renegade_gunner"),
enemy(35, "scab_plasma", "Scab Plasma Gunner", "renegade_plasma_gunner"),
enemy(36, "scab_rager", "Scab Rager", "renegade_berzerker"),
enemy(37, "scab_mauler", "Scab Mauler", "renegade_executor"),
enemy(38, "scab_bomber", "Scab Bomber", "renegade_grenadier"),
enemy(39, "scab_flamer", "Scab Flamer", "renegade_flamer"),
enemy(40, "scab_shotgunner", "Scab Shotgunner", "renegade_shocktrooper"),
enemy(41, "scab_trapper", "Scab Trapper", "renegade_netgunner"),
enemy(42, "scab_sniper", "Scab Sniper", "renegade_sniper"),
enemy(43, "scab_captain", "Captain", "renegade_captain"),
-- Dregs (Admonition)
enemy(44, "dreg_gunner", "Dreg Gunner", "cultist_gunner"),
enemy(45, "dreg_rager", "Dreg Rager", "cultist_berzerker"),
enemy(46, "dreg_tox_bomber", "Dreg Tox Bomber", "cultist_grenadier"),
enemy(47, "dreg_tox_flamer", "Dreg Tox Flamer", "cultist_flamer"),
enemy(48, "dreg_shotgunner", "Dreg Shotgunner", "cultist_shocktrooper"),
enemy(49, "mutant", "Mutant", "cultist_mutant"),
-- Chaos / ogryns / monsters
enemy(50, "pox_hound", "Pox Hound", "chaos_hound"),
enemy(51, "pox_burster", "Pox Burster", "chaos_poxwalker_bomber"),
enemy(52, "bulwark", "Bulwark", "chaos_ogryn_bulwark"),
enemy(53, "crusher", "Crusher", "chaos_ogryn_executor"),
enemy(54, "reaper", "Reaper", "chaos_ogryn_gunner"),
enemy(55, "plague_ogryn", "Plague Ogryn", "chaos_plague_ogryn"),
enemy(56, "chaos_spawn", "Chaos Spawn", "chaos_spawn"),
enemy(57, "daemonhost", "Daemonhost (sleeping)", "chaos_daemonhost"),
enemy(58, "daemonhost_awake", "Daemonhost (awake)", "aggroed"),
enemy(59, "trapper_fled", "Trapper Fled", "seen_netgunner_flee"),
enemy(60, "houndmaster", "Houndmaster", "chaos_ogryn_houndmaster"),
-- ===== New: class ability lines =====
ability(61, "hs_missile_launcher", "Hive Scum - Missile Launcher", "broker_missile_launcher", ICON_WHEEL .. "enemy"),
ability(62, "hs_focus", "Hive Scum - Focus", "ability_focus"),
ability(63, "hs_rage", "Hive Scum - Rage", "ability_rage"),
ability(64, "hs_stimm_field", "Hive Scum - Stimm Field", "ability_stimm"),
ability(65, "vet_voice_of_command", "Veteran - Voice of Command", "ability_ranger"),
ability(66, "zealot_dash", "Zealot - Chastise", "ability_maniac"),
ability(67, "zealot_stealth", "Zealot - Shroudfield", "ability_pious_stabber"),
ability(68, "zealot_relic", "Zealot - Chorus", "ability_litany"),
ability(69, "psyker_venting", "Psyker - Venting", "ability_venting"),
ability(70, "psyker_shards", "Psyker - Assail", "ability_gunslinger"),
ability(71, "psyker_stance", "Psyker - Scrier's Gaze", "ability_buff_stance"),
ability(72, "psyker_biomancer_low", "Psyker - Biomancer (low)", "ability_biomancer_low"),
ability(73, "psyker_biomancer_high", "Psyker - Biomancer (high)", "ability_biomancer_high"),
ability(74, "ogryn_charge", "Ogryn - Bull Rush", "ability_bonebreaker"),
ability(75, "ogryn_taunt", "Ogryn - Loyal Protector", "ability_bullgryn"),
ability(76, "ogryn_gunlugger", "Ogryn - Point-Blank Barrage", "ability_gun_lugger"),
ability(77, "arb_charge", "Arbitrator - Charge", "ability_charge_a"),
ability(78, "arb_shout", "Arbitrator - Shout", "ability_howl_a"),
ability(79, "arb_stance", "Arbitrator - Stance", "ability_stance_a"),
-- ===== New: blitz / throw lines =====
blitz(80, "blitz_frag", "Frag Grenade", "frag_grenade"),
blitz(81, "blitz_krak", "Krak Grenade", "krak_grenade"),
blitz(82, "blitz_smoke", "Smoke Grenade", "smoke_grenade"),
blitz(83, "blitz_shock", "Shock Grenade", "shock_grenade"),
blitz(84, "blitz_fire", "Fire Grenade", "fire_grenade"),
blitz(85, "blitz_tox", "Tox Grenade", "broker_tox_grenade"),
blitz(86, "blitz_flash", "Flash Grenade", "quick_flash_grenade"),
blitz(87, "blitz_rock", "Big Rock", "ogryn_grenade_friend_rock"),
blitz(88, "blitz_box", "Grenade Box", "ogryn_grenade_box"),
blitz(98, "blitz_box_cluster", "Grenade Box (cluster)", "ogryn_grenade_box_cluster"),
blitz(89, "blitz_ogryn_frag", "Ogryn Frag Bomb", "ogryn_grenade_frag"),
blitz(90, "blitz_arb", "Arbites Grenade", "adamant_grenade"),
blitz(91, "blitz_shock_mine", "Shock Mine", "shock_mine"),
-- class_rework database ability lines
ability(92, "vet_squad_leader", "Veteran - Squad Leader", "ability_squad_leader"),
ability(93, "vet_shock_trooper", "Veteran - Shock Trooper", "ability_shock_trooper"),
ability(94, "banisher", "Banisher", "ability_banisher"),
ability(95, "banisher_impact", "Banisher (impact)", "ability_banisher_impact"),
ability(96, "protectorate_start", "Protectorate (start)", "ability_protectorate_start"),
ability(97, "protectorate_stop", "Protectorate (stop)", "ability_protectorate_stop"),
}
-- Fast lookup by id (dropdown values) and a stable ordered list for the UI.
local by_id = {}
for _, cmd in ipairs(commands) do
by_id[cmd.id] = cmd
end
mod.commands = commands
mod.commands_by_id = by_id
return commands
@@ -0,0 +1,102 @@
local mod = get_mod("VoxCaster")
local commands = mod.commands
or mod:io_dofile("VoxCaster/scripts/mods/VoxCaster/VoxCaster_commands")
-- Dropdown order: com wheel lines, then item callouts, then enemy callouts.
-- Within a group, alphabetical by display text.
local GROUP_ORDER = { wheel = 1, item = 2, enemy = 3, ability = 4, blitz = 5 }
local function sorted_commands()
local list = {}
for _, cmd in ipairs(commands) do
list[#list + 1] = cmd
end
table.sort(list, function(a, b)
local ga, gb = GROUP_ORDER[a.group], GROUP_ORDER[b.group]
if ga ~= gb then
return ga < gb
end
return a.text < b.text
end)
return list
end
local function get_options()
local options = {
{ text = "option_nil", value = 0 },
}
for _, cmd in ipairs(sorted_commands()) do
options[#options + 1] = { text = "option_" .. cmd.key, value = cmd.id }
end
return options
end
local function get_keybind_widgets(group)
local widgets = {}
for _, cmd in ipairs(sorted_commands()) do
if cmd.group == group then
widgets[#widgets + 1] = {
setting_id = "option_" .. cmd.key, -- same IDs as the original mod, keeps existing binds
type = "keybind",
keybind_trigger = "pressed",
keybind_type = "function_call",
function_name = "keybind_" .. cmd.id,
default_value = {},
}
end
end
return widgets
end
local function wheel_dropdown(setting_id)
return {
setting_id = setting_id,
type = "dropdown",
default_value = 0,
options = get_options(),
}
end
return {
name = mod:localize("mod_title"),
description = mod:localize("mod_description"),
is_togglable = true,
options = {
widgets = {
wheel_dropdown("plugin_wheel_top_left"),
wheel_dropdown("plugin_wheel_top"),
wheel_dropdown("plugin_wheel_top_right"),
wheel_dropdown("plugin_wheel_left"),
wheel_dropdown("plugin_wheel_right"),
wheel_dropdown("plugin_wheel_bottom_left"),
wheel_dropdown("plugin_wheel_bottom"),
wheel_dropdown("plugin_wheel_bottom_right"),
{
setting_id = "plugin_keybinds_wheel",
type = "group",
sub_widgets = get_keybind_widgets("wheel"),
},
{
setting_id = "plugin_keybinds_item",
type = "group",
sub_widgets = get_keybind_widgets("item"),
},
{
setting_id = "plugin_keybinds_enemy",
type = "group",
sub_widgets = get_keybind_widgets("enemy"),
},
{
setting_id = "plugin_keybinds_ability",
type = "group",
sub_widgets = get_keybind_widgets("ability"),
},
{
setting_id = "plugin_keybinds_blitz",
type = "group",
sub_widgets = get_keybind_widgets("blitz"),
},
},
},
}
@@ -0,0 +1,119 @@
return {
mod_title = { en = "VoxCaster" },
mod_description = { en = "Put any voice line (com wheel, item/enemy callouts, class abilities, blitz throws) on the com wheel or a keybind." },
plugin_wheel_top = { en = "Top" },
plugin_wheel_top_right = { en = "Top Right" },
plugin_wheel_right = { en = "Right" },
plugin_wheel_bottom_right = { en = "Bottom Right" },
plugin_wheel_bottom = { en = "Bottom" },
plugin_wheel_bottom_left = { en = "Bottom Left" },
plugin_wheel_left = { en = "Left" },
plugin_wheel_top_left = { en = "Top Left" },
plugin_keybinds_wheel = { en = "Keybinds: Com Wheel" },
plugin_keybinds_item = { en = "Keybinds: Item Callouts" },
plugin_keybinds_enemy = { en = "Keybinds: Enemy Callouts" },
plugin_keybinds_ability = { en = "Keybinds: Class Abilities" },
plugin_keybinds_blitz = { en = "Keybinds: Blitz / Throws" },
option_nil = { en = "None" },
option_for_the_emperor = { en = "For The Emperor!" },
option_vacuum_capsule = { en = "[Item] Vacuum Capsule" },
option_yes = { en = "Yes" },
option_no = { en = "No" },
option_cryonic_rod = { en = "[Item] Cryonic Rod" },
option_power_cell = { en = "[Item] Power Cell" },
option_scripture = { en = "[Item] Scripture" },
option_grimoire = { en = "[Item] Grimoire" },
option_relic = { en = "[Item] Relic" },
option_health_booster = { en = "[Item] Health Booster" },
option_mine = { en = "Mine" },
option_ammo = { en = "[Item] Ammo" },
option_grenade = { en = "[Item] Grenade" },
option_plasteel = { en = "[Item] Plasteel" },
option_diamantine = { en = "[Item] Diamantine" },
option_following = { en = "Following" },
option_medicae_station = { en = "[Item] Medicae Station" },
option_uncharged_medicae = { en = "[Item] Uncharged Medicae" },
option_medipack = { en = "[Item] Medipack" },
option_medipack_down = { en = "[Item] Medipack Down" },
option_ammo_crate = { en = "[Item] Ammo Crate" },
option_need_ammo = { en = "Need Ammo" },
option_need_healing = { en = "Need Healing" },
option_look_there = { en = "Look There" },
option_go_there = { en = "Go There" },
option_enemy = { en = "Enemy" },
option_thanks = { en = "Thanks" },
option_my_pleasure = { en = "My Pleasure" },
option_take_this = { en = "Take This" },
option_stimm_health = { en = "[Item] Stimm (Health)" },
option_stimm_concentration = { en = "[Item] Stimm (Concentration)" },
option_stimm_power = { en = "[Item] Stimm (Power)" },
option_stimm_speed = { en = "[Item] Stimm (Speed)" },
option_scab_gunner = { en = "[Enemy] Scab Gunner" },
option_scab_plasma = { en = "[Enemy] Scab Plasma Gunner" },
option_scab_rager = { en = "[Enemy] Scab Rager" },
option_scab_mauler = { en = "[Enemy] Scab Mauler" },
option_scab_bomber = { en = "[Enemy] Scab Bomber" },
option_scab_flamer = { en = "[Enemy] Scab Flamer" },
option_scab_shotgunner = { en = "[Enemy] Scab Shotgunner" },
option_scab_trapper = { en = "[Enemy] Scab Trapper" },
option_scab_sniper = { en = "[Enemy] Scab Sniper" },
option_scab_captain = { en = "[Enemy] Captain" },
option_dreg_gunner = { en = "[Enemy] Dreg Gunner" },
option_dreg_rager = { en = "[Enemy] Dreg Rager" },
option_dreg_tox_bomber = { en = "[Enemy] Dreg Tox Bomber" },
option_dreg_tox_flamer = { en = "[Enemy] Dreg Tox Flamer" },
option_dreg_shotgunner = { en = "[Enemy] Dreg Shotgunner" },
option_mutant = { en = "[Enemy] Mutant" },
option_pox_hound = { en = "[Enemy] Pox Hound" },
option_pox_burster = { en = "[Enemy] Pox Burster" },
option_bulwark = { en = "[Enemy] Bulwark" },
option_crusher = { en = "[Enemy] Crusher" },
option_reaper = { en = "[Enemy] Reaper" },
option_plague_ogryn = { en = "[Enemy] Plague Ogryn" },
option_chaos_spawn = { en = "[Enemy] Chaos Spawn" },
option_daemonhost = { en = "[Enemy] Daemonhost (sleeping)" },
option_daemonhost_awake = { en = "[Enemy] Daemonhost (awake)" },
option_trapper_fled = { en = "[Enemy] Trapper Fled" },
option_houndmaster = { en = "[Enemy] Houndmaster" },
option_hs_missile_launcher = { en = "[Ability] Hive Scum - Missile Launcher" },
option_hs_focus = { en = "[Ability] Hive Scum - Focus" },
option_hs_rage = { en = "[Ability] Hive Scum - Rage" },
option_hs_stimm_field = { en = "[Ability] Hive Scum - Stimm Field" },
option_vet_voice_of_command = { en = "[Ability] Veteran - Voice of Command" },
option_zealot_dash = { en = "[Ability] Zealot - Chastise" },
option_zealot_stealth = { en = "[Ability] Zealot - Shroudfield" },
option_zealot_relic = { en = "[Ability] Zealot - Chorus" },
option_psyker_venting = { en = "[Ability] Psyker - Venting" },
option_psyker_shards = { en = "[Ability] Psyker - Assail" },
option_psyker_stance = { en = "[Ability] Psyker - Scrier's Gaze" },
option_psyker_biomancer_low = { en = "[Ability] Psyker - Biomancer (low)" },
option_psyker_biomancer_high = { en = "[Ability] Psyker - Biomancer (high)" },
option_ogryn_charge = { en = "[Ability] Ogryn - Bull Rush" },
option_ogryn_taunt = { en = "[Ability] Ogryn - Loyal Protector" },
option_ogryn_gunlugger = { en = "[Ability] Ogryn - Point-Blank Barrage" },
option_arb_charge = { en = "[Ability] Arbitrator - Charge" },
option_arb_shout = { en = "[Ability] Arbitrator - Shout" },
option_arb_stance = { en = "[Ability] Arbitrator - Stance" },
option_blitz_frag = { en = "[Blitz] Frag Grenade" },
option_blitz_krak = { en = "[Blitz] Krak Grenade" },
option_blitz_smoke = { en = "[Blitz] Smoke Grenade" },
option_blitz_shock = { en = "[Blitz] Shock Grenade" },
option_blitz_fire = { en = "[Blitz] Fire Grenade" },
option_blitz_tox = { en = "[Blitz] Tox Grenade" },
option_blitz_flash = { en = "[Blitz] Flash Grenade" },
option_blitz_rock = { en = "[Blitz] Big Rock" },
option_blitz_box = { en = "[Blitz] Grenade Box" },
option_blitz_box_cluster = { en = "[Blitz] Grenade Box (cluster)" },
option_blitz_ogryn_frag = { en = "[Blitz] Ogryn Frag Bomb" },
option_blitz_arb = { en = "[Blitz] Arbites Grenade" },
option_blitz_shock_mine = { en = "[Blitz] Shock Mine" },
option_vet_squad_leader = { en = "[Ability] Veteran - Squad Leader" },
option_vet_shock_trooper = { en = "[Ability] Veteran - Shock Trooper" },
option_banisher = { en = "[Ability] Banisher" },
option_banisher_impact = { en = "[Ability] Banisher (impact)" },
option_protectorate_start = { en = "[Ability] Protectorate (start)" },
option_protectorate_stop = { en = "[Ability] Protectorate (stop)" },
}
+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())
+16
View File
@@ -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"