commit 32257ece9cc913e53334ab08c9db316550d8768b Author: Marco Date: Mon Aug 24 16:27:02 2026 +0000 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 diff --git a/.github/workflows/check-upstream.yml b/.github/workflows/check-upstream.yml new file mode 100644 index 0000000..92b6229 --- /dev/null +++ b/.github/workflows/check-upstream.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2617be --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +report.json +report.txt +__pycache__/ +*.zip +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..c4dac55 --- /dev/null +++ b/README.md @@ -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_` 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. diff --git a/VoxCaster/VoxCaster.mod b/VoxCaster/VoxCaster.mod new file mode 100644 index 0000000..481423e --- /dev/null +++ b/VoxCaster/VoxCaster.mod @@ -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 = {}, +} diff --git a/VoxCaster/scripts/mods/VoxCaster/VoxCaster.lua b/VoxCaster/scripts/mods/VoxCaster/VoxCaster.lua new file mode 100644 index 0000000..10c09bc --- /dev/null +++ b/VoxCaster/scripts/mods/VoxCaster/VoxCaster.lua @@ -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_ 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 diff --git a/VoxCaster/scripts/mods/VoxCaster/VoxCaster_commands.lua b/VoxCaster/scripts/mods/VoxCaster/VoxCaster_commands.lua new file mode 100644 index 0000000..9173f17 --- /dev/null +++ b/VoxCaster/scripts/mods/VoxCaster/VoxCaster_commands.lua @@ -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 diff --git a/VoxCaster/scripts/mods/VoxCaster/VoxCaster_data.lua b/VoxCaster/scripts/mods/VoxCaster/VoxCaster_data.lua new file mode 100644 index 0000000..786cbbe --- /dev/null +++ b/VoxCaster/scripts/mods/VoxCaster/VoxCaster_data.lua @@ -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"), + }, + }, + }, +} diff --git a/VoxCaster/scripts/mods/VoxCaster/VoxCaster_localization.lua b/VoxCaster/scripts/mods/VoxCaster/VoxCaster_localization.lua new file mode 100644 index 0000000..117980d --- /dev/null +++ b/VoxCaster/scripts/mods/VoxCaster/VoxCaster_localization.lua @@ -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)" }, +} diff --git a/tools/check_upstream.py b/tools/check_upstream.py new file mode 100755 index 0000000..e3c0e0c --- /dev/null +++ b/tools/check_upstream.py @@ -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()) diff --git a/tools/rename.sh b/tools/rename.sh new file mode 100755 index 0000000..6ba5d42 --- /dev/null +++ b/tools/rename.sh @@ -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"