dEN5-tech/dota2-csharp-vscripts

★ 0Forks 0C#GitHub ↗Compare

README

Dota 2 Custom Game — C# VScripts via CSharp.lua

Write your Dota 2 VScript logic in C#, compile it to Lua using CSharp.lua, and run it inside the Dota 2 VScript sandbox.


Requirements

Tool Version Link
.NET SDK 7.0+ https://dotnet.microsoft.com/download/dotnet/7.0
CSharp.lua 2.0+ https://github.com/yanghuan/CSharp.lua/releases
Dota 2 Workshop Tools latest Steam → Dota 2 → Workshop Tools

Project Structure

my-addon/
├── src/                        # C# source files
│   ├── EntryPoint.cs           # addon entry point
│   ├── GameMode.cs
│   ├── abilities/
│   │   └── MedusaSplitShot.cs
│   ├── modifiers/
│   │   └── ModifierMedusaSplitShot.cs
│   └── utils/
│       └── DotaConst.cs        # Dota 2 API constants & extern bindings
├── output/                     # compiled Lua output (git-ignored)
├── compile.bat                 # build script
└── README.md

Dota 2 addon layout:

game/dota_addons/<addonName>/scripts/vscripts/
├── addon_game_mode.lua         # minimal Lua bootstrap (required by Dota 2)
├── out.lua                     # compiled output — copy here after build
└── lib/
    └── timers.lua              # optional third-party Lua libs

CoreSystem Patches (required)

The CSharp.lua CoreSystem library uses os.* functions that are not available in the Dota 2 VScript sandbox. Apply the following patches to your local copy of CoreSystem before compiling.

CoreSystem.lua/DateTime.lua

-- lines 34–37: guard against nil os
local os = os
local ostime = os and os.time
local osdifftime = os and os.difftime
local osdate = os and os.date
-- lines 185–192: guard getTimeZone() called at module load time
local function getTimeZone()
  if not osdate then return 0, 0 end  -- sandbox fallback: UTC+0
  local date = osdate("*t")
  local dst = date.isdst
  local now = ostime(date)
  return osdifftime(now, ostime(osdate("!*t", now))) * 10000000,
         dst and 3600 * 10000000 or 0
end
-- line 195: fallback when ostime is nil
local time = System.config.time or ostime or function() return 0 end

CoreSystem.lua/Utilities.lua

-- lines 28–29
local os = os
local clock = os and os.clock
-- line 34
define("System.Environment", {
  Exit = os and os.exit,
  ...

CoreSystem.lua/Core.lua

-- line 128: remove assert to allow script_reload without crash
-- assert(rawget(scope, name) == nil, className)
rawset(scope, name, cls)

Why: Dota 2's require does not cache modules across script_reload. Without this patch, reloading the script crashes with attempt to call assert … MyAddon.ClassName.


Compilation

compile.bat

@echo off
setlocal

set COMPILER=..\CSharp.lua-2.0\CSharp.lua\CSharp.Lua.Launcher.dll
set CORESYSTEM=..\CSharp.lua-2.0\CoreSystem.lua
set ADDON_PATH=C:\...\dota 2 beta\game\dota_addons\<addonName>\scripts\vscripts

echo [1/2] Compiling C# to Lua...
dotnet %COMPILER% -s src -d output -c -p -include %CORESYSTEM%
if %errorlevel% neq 0 ( echo FAILED & pause & exit /b 1 )

echo [2/2] Copying to addon...
copy /Y "output\out.lua" "%ADDON_PATH%\out.lua" >nul

echo Done. Use script_reload in Dota 2 console.
pause

Compiler flags:

Flag Purpose
-c Lua 5.1 compatibility (Dota 2 uses Lua 5.1)
-p Disable debug.setmetatable (unavailable in VScript)
-include <path> Bundle CoreSystem + your code into a single out.lua
-s src Recursively compiles all .cs files in src/

Bootstrap Files

addon_game_mode.lua

require("out")          -- load CoreSystem + compiled C# code

function Precache(context) end

function Activate()
    MyAddon.EntryPoint.Setup()
end

src/EntryPoint.cs

using System;

namespace MyAddon {
    public static class EntryPoint {
        // Register Dota 2 globals from C# — no extra Lua needed
        /// @CSharpLua.Template = "Activate = {0}"
        private static extern void SetActivate(Action fn);

        /// @CSharpLua.Template = "Precache = {0}"
        private static extern void SetPrecache(Action<object> fn);

        /// @CSharpLua.Get = "GameRules.Addon"
        private static extern object GameRulesAddon { get; }

        /// @CSharpLua.Template = "{0}:Reload()"
        private static extern void CallReload(object addon);

        public static void Setup() {
            SetActivate(GameMode.OnActivate);
            SetPrecache(GameMode.OnPrecache);

            // Handle script_reload
            var addon = GameRulesAddon;
            if (addon != null) CallReload(addon);

            Console.WriteLine("[MyAddon] EntryPoint.Setup complete");
        }
    }
}

Dota 2 API Bindings Pattern

Use @CSharpLua.Template annotations in a shared base class so that ability/modifier subclasses contain zero Lua annotations:

// src/utils/BaseModifier.cs
namespace MyAddon.Base {
    public abstract class BaseModifier {
        // Lifecycle — override in subclasses
        public virtual bool IsHidden()    => false;
        public virtual bool IsPurgable()  => true;

        // Dota 2 API — all annotations live here
        /// @CSharpLua.Template = "{this}:GetAbility():GetSpecialValueFor({0})"
        protected extern float GetSpecialValueFor(string key);

        /// @CSharpLua.Template = "{this}:GetParent()"
        protected extern object GetParent();

        /// @CSharpLua.Template = "IsServer()"
        protected static extern bool IsServer();

        /// @CSharpLua.Template = "FindUnitsInRadius({0},{1},{2},{3},{4},{5},{6},{7},{8})"
        protected static extern object[] FindUnitsInRadius(
            int team, object origin, object cache, float radius,
            int teamFilter, int typeFilter, int flagFilter,
            int orderFilter, bool canGrow);
    }
}

Subclasses only contain game logic:

// src/modifiers/ModifierMedusaSplitShot.cs
using MyAddon.Base;

namespace MyAddon.Modifiers {
    public class ModifierMedusaSplitShot : BaseModifier {
        public override bool IsHidden()   => true;
        public override bool IsPurgable() => false;

        public void OnCreated(object kv) {
            float reduction = GetSpecialValueFor("damage_modifier");
            // ...
        }
    }
}

Register the class as a Dota 2 global in EntryPoint.Setup():

/// @CSharpLua.Template = "modifier_medusa_split_shot_lua = {0}"
private static extern void RegisterModifier(object cls);

// in Setup():
RegisterModifier(typeof(ModifierMedusaSplitShot));

KV file (npc_abilities_custom.txt):

"ScriptFile"  "out"

No wrapper .lua files needed — the class is already in global scope after Activate() runs.


CoreSystem Compatibility Matrix

Module Status Notes
Core, Exception, Boolean, Char Full —
String, Number, Math, Enum Full —
Array, Collections/* Full List<T>, Dictionary<K,V>, LINQ
Delegate, Interfaces, TimeSpan, Type Full —
Console.WriteLine Full maps to print()
Threading/* Partial needs Dota 2 Timers integration
Convert Partial BitConverter throws without struct lib
Random Partial always pass explicit seed: new Random(42)
DateTime Patched returns UTC+0 stub; see patches above
Utilities (Environment/Stopwatch) Patched os.* guarded; see patches above
IO/File No-op io unavailable; module loads but does nothing

Development Workflow

  1. Edit .cs files in src/
  2. Run compile.bat
  3. In Dota 2 Workshop Tools console: script_reload
  4. Check output in vConsole2 (~ key)

License

This project uses CSharp.lua under the Apache 2.0 License.

Contributors

dEN5-tech

Issues