Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions doc/classes/ProjectSettings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1115,6 +1115,9 @@
<member name="dotnet/project/assembly_reload_attempts" type="int" setter="" getter="" default="3">
Number of times to attempt assembly reloading after rebuilding .NET assemblies. Effectively also the timeout in seconds to wait for unloading of script assemblies to finish.
</member>
<member name="dotnet/project/project_directory" type="String" setter="" getter="" default="&quot;&quot;">
Directory containing the [code].csproj[/code] file, relative to [code]res://[/code]. By default, the [code].csproj[/code] is expected in the project root alongside [code]project.godot[/code]. Set this when using a multi-project layout where the main C# project is in a subdirectory (e.g. [code]MainProject[/code]).
</member>
<member name="dotnet/project/solution_directory" type="String" setter="" getter="" default="&quot;&quot;">
Directory that contains the [code].sln[/code] file. By default, the [code].sln[/code] files is in the root of the project directory, next to the [code]project.godot[/code] and [code].csproj[/code] files.
Changing this value allows setting up a multi-project scenario where there are multiple [code].csproj[/code]. Keep in mind that the Godot project is considered one of the C# projects in the workspace and it's root directory should contain the [code]project.godot[/code] and [code].csproj[/code] next to each other.
Expand Down
95 changes: 77 additions & 18 deletions modules/mono/csharp_script.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ void CSharpLanguage::init() {
GLOBAL_DEF("dotnet/project/assembly_name", "");
#ifdef TOOLS_ENABLED
GLOBAL_DEF("dotnet/project/solution_directory", "");
GLOBAL_DEF("dotnet/project/project_directory", "");
GLOBAL_DEF(PropertyInfo(Variant::INT, "dotnet/project/assembly_reload_attempts", PROPERTY_HINT_RANGE, "1,16,1,or_greater"), 3);
#endif

Expand Down Expand Up @@ -2221,6 +2222,29 @@ void CSharpScript::_bind_methods() {
ClassDB::bind_vararg_method(METHOD_FLAGS_DEFAULT, "new", &CSharpScript::_new, MethodInfo("new"));
}

#ifdef TOOLS_ENABLED
static void _register_csharp_global_class(const String &p_script_path, const CSharpScript::TypeInfo &p_type_info) {
if (!p_type_info.is_global_class) {
return;
}
if (p_script_path.begins_with("csharp://")) {
// Assembly-backed scripts bypass EditorFileSystem; register directly with ScriptServer.
ScriptServer::add_global_class(p_type_info.class_name, p_type_info.native_base_name,
CSharpLanguage::get_singleton()->get_name(), p_script_path,
p_type_info.is_abstract, p_type_info.is_tool);
if (EditorNode::get_singleton()) {
EditorNode::get_editor_data().script_class_set_icon_path(p_type_info.class_name, p_type_info.icon_path);
EditorNode::get_editor_data().script_class_set_name(p_script_path, p_type_info.class_name);
}
} else {
EditorFileSystem *efs = EditorFileSystem::get_singleton();
if (efs) {
efs->update_file(p_script_path);
}
}
}
#endif

void CSharpScript::reload_registered_script(Ref<CSharpScript> p_script) {
// IMPORTANT:
// This method must be called only after the CSharpScript and its associated type
Expand All @@ -2241,11 +2265,9 @@ void CSharpScript::reload_registered_script(Ref<CSharpScript> p_script) {
p_script->_update_exports();

#ifdef TOOLS_ENABLED
// If the EditorFileSystem singleton is available, update the file;
// otherwise, the file will be updated when the singleton becomes available.
EditorFileSystem *efs = EditorFileSystem::get_singleton();
if (efs && !p_script->get_path().is_empty()) {
efs->update_file(p_script->get_path());
String script_path = p_script->get_path();
if (!script_path.is_empty()) {
_register_csharp_global_class(script_path, p_script->type_info);
}
#endif
}
Expand Down Expand Up @@ -2619,12 +2641,7 @@ Error CSharpScript::reload(bool p_keep_state) {
_update_exports();

#ifdef TOOLS_ENABLED
// If the EditorFileSystem singleton is available, update the file;
// otherwise, the file will be updated when the singleton becomes available.
EditorFileSystem *efs = EditorFileSystem::get_singleton();
if (efs) {
efs->update_file(script_path);
}
_register_csharp_global_class(script_path, type_info);
#endif
}

Expand Down Expand Up @@ -2830,10 +2847,22 @@ Ref<Resource> ResourceFormatLoaderCSharpScript::load(const String &p_path, const
// TODO ignore anything inside bin/ and obj/ in tools builds?

String real_path = p_path;
[[maybe_unused]] bool is_assembly_backed = false;
if (p_path.begins_with("csharp://")) {
// This is a virtual path used by generic types, extract the real path.
real_path = "res://" + p_path.trim_prefix("csharp://");
real_path = real_path.substr(0, real_path.rfind_char(':'));
String virtual_suffix = p_path.trim_prefix("csharp://");
int colon_idx = virtual_suffix.find_char(':');
if (colon_idx >= 0) {
// Generic type virtual path (csharp://path:GenericType).
String base_path = "res://" + virtual_suffix.substr(0, colon_idx);
if (FileAccess::exists(base_path)) {
real_path = base_path;
} else {
is_assembly_backed = true;
}
} else {
// Assembly-backed script (csharp://AssemblyName/Namespace.ClassName.cs).
is_assembly_backed = true;
}
}

Ref<CSharpScript> scr;
Expand All @@ -2846,8 +2875,10 @@ Ref<Resource> ResourceFormatLoaderCSharpScript::load(const String &p_path, const
}

#ifdef DEBUG_ENABLED
Error err = scr->load_source_code(real_path);
ERR_FAIL_COND_V_MSG(err != OK, Ref<Resource>(), "Cannot load C# script file '" + real_path + "'.");
if (!is_assembly_backed) {
Error err = scr->load_source_code(real_path);
ERR_FAIL_COND_V_MSG(err != OK, Ref<Resource>(), "Cannot load C# script file '" + real_path + "'.");
}
#endif // DEBUG_ENABLED

// Only one instance of a C# script is allowed to exist.
Expand All @@ -2874,6 +2905,26 @@ Ref<Resource> ResourceFormatLoaderCSharpScript::load(const String &p_path, const

scr->reload();

#ifdef TOOLS_ENABLED
// Assembly-backed scripts need explicit registration since reload() is a no-op for them.
if (is_assembly_backed && scr->is_valid()) {
CSharpScript::TypeInfo ti;
String base_type;
bool is_abstract = false;
bool is_tool = false;
String class_name = CSharpLanguage::get_singleton()->get_global_class_name(
p_path, &base_type, &ti.icon_path, &is_abstract, &is_tool);
if (!class_name.is_empty()) {
ti.class_name = class_name;
ti.native_base_name = StringName(base_type);
ti.is_global_class = true;
ti.is_abstract = is_abstract;
ti.is_tool = is_tool;
_register_csharp_global_class(p_path, ti);
}
}
#endif

if (r_error) {
*r_error = OK;
}
Expand All @@ -2890,7 +2941,10 @@ bool ResourceFormatLoaderCSharpScript::handles_type(const String &p_type) const
}

String ResourceFormatLoaderCSharpScript::get_resource_type(const String &p_path) const {
return p_path.has_extension("cs") ? CSharpLanguage::get_singleton()->get_type() : "";
if (p_path.has_extension("cs") || p_path.begins_with("csharp://")) {
return CSharpLanguage::get_singleton()->get_type();
}
return "";
}

Error ResourceFormatSaverCSharpScript::save(const Ref<Resource> &p_resource, const String &p_path, uint32_t p_flags) {
Expand Down Expand Up @@ -2937,5 +2991,10 @@ void ResourceFormatSaverCSharpScript::get_recognized_extensions(const Ref<Resour
}

bool ResourceFormatSaverCSharpScript::recognize(const Ref<Resource> &p_resource) const {
return Object::cast_to<CSharpScript>(p_resource.ptr()) != nullptr;
const CSharpScript *scr = Object::cast_to<CSharpScript>(p_resource.ptr());
if (!scr) {
return false;
}
// Assembly-backed scripts (csharp://) have no source file to save.
return !scr->get_path().begins_with("csharp://");
}
11 changes: 11 additions & 0 deletions modules/mono/editor/Godot.NET.Sdk/Godot.NET.Sdk/Sdk/Sdk.targets
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@
<PackageReference Include="GodotSharpEditor" IsImplicitlyDefined="true" Version="$(PackageVersion_GodotSharp)" Condition=" '$(Configuration)' == 'Debug' " />
</ItemGroup>

<!--
Propagate GodotProjectDir to all ProjectReference items so that source generators
in referenced assemblies compute res:// paths relative to the main Godot project root,
not relative to the referenced project's own directory.
-->
<Target Name="GodotPropagateProjectDirToReferences" BeforeTargets="ResolveProjectReferences">
<ItemGroup>
<ProjectReference Update="@(ProjectReference)" AdditionalProperties="GodotProjectDir=$(GodotProjectDir)" />
</ItemGroup>
</Target>

<!-- iOS-specific build targets -->
<Import Project="$(MSBuildThisFileDirectory)\iOSNativeAOT.targets" Condition=" '$(GodotTargetPlatform)' == 'ios' " />

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing;
using Microsoft.CodeAnalysis.Text;
using System;
using System.Collections.Generic;
Expand All @@ -22,6 +25,29 @@ private static (string, SourceText) MakeAssemblyScriptTypesGeneratedSource(IColl
);
}

/// <summary>
/// Creates a verifier with a custom GodotProjectDir, for testing out-of-tree source paths.
/// </summary>
private static CSharpSourceGeneratorVerifier<ScriptPathAttributeGenerator>.Test MakeVerifierWithCustomProjectDir(
string godotProjectDir, string assemblyName = "TestProject")
{
var verifier = new CSharpSourceGeneratorVerifier<ScriptPathAttributeGenerator>.Test();

verifier.TestState.AnalyzerConfigFiles.Add(("/.globalconfig", $"""
is_global = true
build_property.GodotProjectDir = {godotProjectDir}
"""));

verifier.SolutionTransforms.Add((Solution solution, ProjectId projectId) =>
{
Project project = solution.GetProject(projectId)!
.WithAssemblyName(assemblyName);
return project.Solution;
});

return verifier;
}

[Fact]
public async Task ScriptBoilerplate()
{
Expand Down Expand Up @@ -78,4 +104,45 @@ public async Task NamespaceMultipleClassesSameName()
verifier.TestState.GeneratedSources.Add(MakeAssemblyScriptTypesGeneratedSource(new string[] { "global::NamespaceA.SameName", "global::NamespaceB.SameName" }));
await verifier.RunAsync();
}

/// <summary>
/// Tests that an in-tree external assembly script (source inside Godot project dir)
/// gets a normal res:// path.
/// </summary>
[Fact]
public async Task ExternalAssemblyInTree()
{
var verifier = CSharpSourceGeneratorVerifier<ScriptPathAttributeGenerator>.MakeVerifier(
new string[] { "ExternalScript.cs" },
new string[] { "ExternalModule.ExternalScript_ScriptPath.generated.cs" }
);
verifier.TestState.GeneratedSources.Add(
MakeAssemblyScriptTypesGeneratedSource(new string[] { "global::ExternalModule.ExternalScript" }));
await verifier.RunAsync();
}

/// <summary>
/// Tests that an out-of-tree external assembly script (source outside Godot project dir)
/// gets a synthetic csharp:// path instead of an invalid res://../ path.
/// </summary>
[Fact]
public async Task ExternalAssemblyOutOfTree()
{
// Set GodotProjectDir to a subdirectory that won't contain the source file.
// The Roslyn test framework resolves source paths relative to CWD. By setting
// GodotProjectDir to a non-existent subdirectory, the computed relative path
// will start with "../", triggering the csharp:// synthetic path logic.
string deepProjectDir = Path.Combine(Constants.ExecutingAssemblyPath, "nonexistent", "godot_project");

var verifier = MakeVerifierWithCustomProjectDir(deepProjectDir, "ExternalLib");

string source = File.ReadAllText(Path.Combine(Constants.SourceFolderPath, "ExternalScript.cs"));
verifier.TestState.Sources.Add(("ExternalScript.cs", SourceText.From(source)));

// Skip exact source check because SourceFile contains a machine-dependent absolute path.
verifier.TestBehaviors |=
Microsoft.CodeAnalysis.Testing.TestBehaviors.SkipGeneratedSourcesCheck;

await verifier.RunAsync();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Godot;
namespace ExternalModule {

[ScriptPathAttribute("res://ExternalScript.cs")]
partial class ExternalScript
{
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Godot;

namespace ExternalModule;

public partial class ExternalScript : Node
{
private int _health;

public override void _Ready()
{
_health = 100;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ private static godot_bool InitializeFromGameProject(IntPtr godotDllHandle, IntPt

ManagedCallbacks.Create(outManagedCallbacks);

ScriptManagerBridge.LookupScriptsInAssembly(typeof(global::GodotPlugins.Game.Main).Assembly);
var mainAssembly = typeof(global::GodotPlugins.Game.Main).Assembly;
ScriptManagerBridge.LookupScriptsInAssembly(mainAssembly);
ScriptManagerBridge.LookupScriptsInReferencedAssemblies(mainAssembly, mainAssembly.Location);

return godot_bool.True;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,23 @@ IEnumerable<ClassDeclarationSyntax> classDeclarations
if (attributes.Length != 0)
attributes.Append("\n");

string scriptPath = RelativeToDir(cds.SyntaxTree.FilePath, godotProjectDir);
string relPath = RelativeToDir(cds.SyntaxTree.FilePath, godotProjectDir);

string scriptPath;
string? sourceFile = null;
if (relPath.StartsWith("../", StringComparison.Ordinal) ||
relPath.StartsWith("..\\", StringComparison.Ordinal))
{
string assemblyName = context.Compilation.AssemblyName ?? "UnknownAssembly";
string qualifiedName = symbol.FullQualifiedNameOmitGlobal();
scriptPath = $"csharp://{assemblyName}/{qualifiedName}.cs";
sourceFile = Path.GetFullPath(cds.SyntaxTree.FilePath);
}
else
{
scriptPath = $"res://{relPath}";
}

if (!usedPaths.Add(scriptPath))
{
context.ReportDiagnostic(Diagnostic.Create(
Expand All @@ -105,9 +121,17 @@ IEnumerable<ClassDeclarationSyntax> classDeclarations
return;
}

attributes.Append(@"[ScriptPathAttribute(""res://");
attributes.Append(@"[ScriptPathAttribute(""");
attributes.Append(scriptPath);
attributes.Append(@""")]");
attributes.Append(@"""");
if (sourceFile != null)
{
// Escape backslashes for the C# string literal
attributes.Append(@", SourceFile = """);
attributes.Append(sourceFile.Replace("\\", "\\\\"));
attributes.Append(@"""");
}
attributes.Append(@")]");
}

INamespaceSymbol namespaceSymbol = symbol.ContainingNamespace;
Expand Down
Loading
Loading