mirror of
https://github.com/xeghosted/OrbisForge.git
synced 2026-10-11 01:33:23 +02:00
A GoldHEN plugin is not a plain PRX. The loader enters the module at _init, which the GoldHEN SDK's crtprx.o forwards to module_start/module_stop. Built the stock way it links without complaint and then silently never loads on console, so add_orbis_target(TYPE GHPLUGIN) applies all three differences: crtprx.o instead of crtlib.o, -e _init, and SceLibcInternal/SceSysmodule instead of c/c++. It also checks for the SDK up front and fails with install instructions rather than producing an unloadable artifact. The scaffolded starter plugin shows a PS4 notification from module_start. It is emitted as C++ with extern "C" entry points -- generated projects are project(<name> CXX), and without that the mangled names leave crtprx.o's _init unresolved. Reachable as --type ghplugin (aliases: goldhen, plugin) and as a fourth entry in the GUI wizard. CI installs the SDK and builds one, since a plugin that links but cannot load is exactly the failure unit tests cannot catch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
310 lines
11 KiB
C#
310 lines
11 KiB
C#
using System.Text.Json;
|
|
using OrbisForge.Core;
|
|
using Xunit;
|
|
|
|
namespace OrbisForge.Core.Tests;
|
|
|
|
// xUnit creates a fresh instance of this class per test method, so the constructor/Dispose
|
|
// pair gives each test its own isolated temp directory without any shared-state risk.
|
|
public class ProjectGeneratorTests : IDisposable
|
|
{
|
|
private readonly string _outputDir;
|
|
|
|
public ProjectGeneratorTests()
|
|
{
|
|
_outputDir = Path.Combine(Path.GetTempPath(), "orbisforge-tests", Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(_outputDir);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(_outputDir, recursive: true); } catch { /* best effort cleanup */ }
|
|
}
|
|
|
|
[Fact]
|
|
public void Generate_Exe_WritesMainCppAndCoreFiles()
|
|
{
|
|
var result = ProjectGenerator.Generate(new ProjectOptions
|
|
{
|
|
Name = "my_exe",
|
|
Type = OrbisTargetType.Exe,
|
|
OutputDirectory = _outputDir,
|
|
});
|
|
|
|
Assert.Equal(Path.Combine(_outputDir, "my_exe"), result.RootDirectory);
|
|
AssertFileExists(result, "src/main.cpp");
|
|
AssertFileExists(result, "CMakeLists.txt");
|
|
AssertFileExists(result, "CMakePresets.json");
|
|
AssertFileExists(result, "cmake/oo-ps4-toolchain.cmake");
|
|
AssertFileExists(result, "cmake/OrbisTargets.cmake");
|
|
AssertFileExists(result, "build.bat");
|
|
AssertFileExists(result, "build.sh");
|
|
AssertFileExists(result, ".vscode/tasks.json");
|
|
AssertFileExists(result, ".vscode/c_cpp_properties.json");
|
|
AssertFileExists(result, ".vscode/settings.json");
|
|
AssertFileExists(result, ".gitignore");
|
|
AssertFileExists(result, "README.md");
|
|
|
|
AssertFileMissing(result, "src/my_exe.cpp");
|
|
AssertFileMissing(result, "deploy.bat");
|
|
AssertFileMissing(result, "sce_sys/icon0.png");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(OrbisTargetType.Prx)]
|
|
[InlineData(OrbisTargetType.Sprx)]
|
|
public void Generate_Library_WritesNamedSourceAndHeaderInsteadOfMain(OrbisTargetType type)
|
|
{
|
|
var result = ProjectGenerator.Generate(new ProjectOptions
|
|
{
|
|
Name = "my_lib",
|
|
Type = type,
|
|
OutputDirectory = _outputDir,
|
|
});
|
|
|
|
AssertFileExists(result, "src/my_lib.cpp");
|
|
AssertFileExists(result, "src/my_lib.h");
|
|
AssertFileMissing(result, "src/main.cpp");
|
|
}
|
|
|
|
[Fact]
|
|
public void Generate_GoldHenPlugin_WritesPluginSourceWithoutHeaderOrMain()
|
|
{
|
|
var result = ProjectGenerator.Generate(new ProjectOptions
|
|
{
|
|
Name = "notify_plugin",
|
|
Type = OrbisTargetType.GoldHenPlugin,
|
|
OutputDirectory = _outputDir,
|
|
});
|
|
|
|
AssertFileExists(result, "src/notify_plugin.cpp");
|
|
AssertFileMissing(result, "src/notify_plugin.h");
|
|
AssertFileMissing(result, "src/main.cpp");
|
|
|
|
var source = ReadFile(result, "src/notify_plugin.cpp");
|
|
|
|
// The loader reaches these through crtprx.o's _init, which resolves them by
|
|
// their unmangled names -- without extern "C" the C++ compiler would mangle
|
|
// them and the plugin would link but never load.
|
|
Assert.Contains("extern \"C\"", source);
|
|
Assert.Contains("int module_start(", source);
|
|
Assert.Contains("int module_stop(", source);
|
|
Assert.Contains("sceKernelSendNotificationRequest", source);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(OrbisTargetType.Exe, "TYPE EXE")]
|
|
[InlineData(OrbisTargetType.Prx, "TYPE PRX")]
|
|
[InlineData(OrbisTargetType.Sprx, "TYPE SPRX")]
|
|
[InlineData(OrbisTargetType.GoldHenPlugin, "TYPE GHPLUGIN")]
|
|
public void Generate_CMakeLists_DeclaresCorrectType(OrbisTargetType type, string expectedSnippet)
|
|
{
|
|
var result = ProjectGenerator.Generate(new ProjectOptions
|
|
{
|
|
Name = "typed_project",
|
|
Type = type,
|
|
OutputDirectory = _outputDir,
|
|
});
|
|
|
|
var content = ReadFile(result, "CMakeLists.txt");
|
|
Assert.Contains(expectedSnippet, content);
|
|
}
|
|
|
|
[Fact]
|
|
public void Generate_WithPtypeOverride_FlowsIntoCMakeLists()
|
|
{
|
|
var result = ProjectGenerator.Generate(new ProjectOptions
|
|
{
|
|
Name = "custom_ptype",
|
|
Type = OrbisTargetType.Sprx,
|
|
OutputDirectory = _outputDir,
|
|
PType = "secure_module",
|
|
});
|
|
|
|
var content = ReadFile(result, "CMakeLists.txt");
|
|
Assert.Contains("PTYPE secure_module", content);
|
|
}
|
|
|
|
[Fact]
|
|
public void Generate_WithoutPkg_DoesNotAddPackagingBlockOrAssets()
|
|
{
|
|
var result = ProjectGenerator.Generate(new ProjectOptions
|
|
{
|
|
Name = "no_pkg",
|
|
Type = OrbisTargetType.Exe,
|
|
OutputDirectory = _outputDir,
|
|
IncludePackaging = false,
|
|
});
|
|
|
|
var content = ReadFile(result, "CMakeLists.txt");
|
|
Assert.DoesNotContain("add_orbis_package", content);
|
|
AssertFileMissing(result, "sce_sys/icon0.png");
|
|
Assert.Empty(result.Warnings);
|
|
}
|
|
|
|
[Fact]
|
|
public void Generate_PkgOnPrx_IsIgnoredByGenerator()
|
|
{
|
|
// ProjectGenerator only wires packaging for EXE targets even if the flag is set --
|
|
// the CLI/GUI are expected to reject this combination before calling Generate, but
|
|
// Generate itself should not blow up or silently produce a broken PRX+pkg mix.
|
|
var result = ProjectGenerator.Generate(new ProjectOptions
|
|
{
|
|
Name = "prx_with_pkg_flag",
|
|
Type = OrbisTargetType.Prx,
|
|
OutputDirectory = _outputDir,
|
|
IncludePackaging = true,
|
|
});
|
|
|
|
AssertFileMissing(result, "sce_sys/icon0.png");
|
|
var content = ReadFile(result, "CMakeLists.txt");
|
|
Assert.DoesNotContain("add_orbis_package", content);
|
|
}
|
|
|
|
[Fact]
|
|
public void Generate_PkgWithMissingToolchain_WarnsPerAssetAndStillWritesOtherFiles()
|
|
{
|
|
var bogusToolchain = Path.Combine(_outputDir, "does-not-exist");
|
|
|
|
var result = ProjectGenerator.Generate(new ProjectOptions
|
|
{
|
|
Name = "pkg_project",
|
|
Type = OrbisTargetType.Exe,
|
|
OutputDirectory = _outputDir,
|
|
IncludePackaging = true,
|
|
ToolchainPathOverride = bogusToolchain,
|
|
});
|
|
|
|
Assert.Equal(4, result.Warnings.Count); // icon0.png, right.sprx, libSceFios2.prx, libc.prx
|
|
Assert.All(result.Warnings, w => Assert.Contains("not found, skipped", w));
|
|
|
|
var content = ReadFile(result, "CMakeLists.txt");
|
|
Assert.Contains("add_orbis_package", content);
|
|
AssertFileExists(result, "src/main.cpp"); // rest of generation still completed
|
|
}
|
|
|
|
[Fact]
|
|
public void Generate_WithDeployHost_WritesDeployScriptsWithConfiguredValues()
|
|
{
|
|
var result = ProjectGenerator.Generate(new ProjectOptions
|
|
{
|
|
Name = "deploy_me",
|
|
Type = OrbisTargetType.Exe,
|
|
OutputDirectory = _outputDir,
|
|
DeployHost = "192.168.1.50",
|
|
DeployPort = 2121,
|
|
DeployRemotePath = "/data/homebrew/deploy_me/",
|
|
});
|
|
|
|
AssertFileExists(result, "deploy.bat");
|
|
AssertFileExists(result, "deploy.sh");
|
|
|
|
var bat = ReadFile(result, "deploy.bat");
|
|
Assert.Contains("ftp://192.168.1.50:2121/data/homebrew/deploy_me/", bat);
|
|
Assert.Contains(@"build\eboot.bin", bat);
|
|
|
|
var sh = ReadFile(result, "deploy.sh");
|
|
Assert.Contains("ftp://192.168.1.50:2121/data/homebrew/deploy_me/", sh);
|
|
Assert.Contains("build/eboot.bin", sh);
|
|
}
|
|
|
|
[Fact]
|
|
public void Generate_WithoutDeployHost_DoesNotWriteDeployScripts()
|
|
{
|
|
var result = ProjectGenerator.Generate(new ProjectOptions
|
|
{
|
|
Name = "no_deploy",
|
|
Type = OrbisTargetType.Exe,
|
|
OutputDirectory = _outputDir,
|
|
});
|
|
|
|
AssertFileMissing(result, "deploy.bat");
|
|
AssertFileMissing(result, "deploy.sh");
|
|
}
|
|
|
|
[Fact]
|
|
public void Generate_GitignoreExcludesBuildDirectory()
|
|
{
|
|
var result = ProjectGenerator.Generate(new ProjectOptions
|
|
{
|
|
Name = "gi_project",
|
|
Type = OrbisTargetType.Exe,
|
|
OutputDirectory = _outputDir,
|
|
});
|
|
|
|
var content = ReadFile(result, ".gitignore");
|
|
Assert.Contains("build/", content);
|
|
}
|
|
|
|
[Fact]
|
|
public void Generate_VsCodeFiles_AreValidJson()
|
|
{
|
|
var result = ProjectGenerator.Generate(new ProjectOptions
|
|
{
|
|
Name = "vscode_project",
|
|
Type = OrbisTargetType.Exe,
|
|
OutputDirectory = _outputDir,
|
|
});
|
|
|
|
// JsonDocument.Parse throws on malformed JSON -- that's the assertion.
|
|
using var tasks = JsonDocument.Parse(ReadFile(result, ".vscode/tasks.json"));
|
|
using var cpp = JsonDocument.Parse(ReadFile(result, ".vscode/c_cpp_properties.json"));
|
|
using var settings = JsonDocument.Parse(ReadFile(result, ".vscode/settings.json"));
|
|
|
|
Assert.True(tasks.RootElement.TryGetProperty("tasks", out _));
|
|
Assert.True(cpp.RootElement.TryGetProperty("configurations", out _));
|
|
Assert.True(settings.RootElement.TryGetProperty("cmake.configureOnOpen", out var configureOnOpen));
|
|
Assert.False(configureOnOpen.GetBoolean());
|
|
}
|
|
|
|
[Fact]
|
|
public void Generate_SanitizesNameForDirectoryAndCMakeTarget()
|
|
{
|
|
var result = ProjectGenerator.Generate(new ProjectOptions
|
|
{
|
|
Name = "my cool game!",
|
|
Type = OrbisTargetType.Exe,
|
|
OutputDirectory = _outputDir,
|
|
});
|
|
|
|
Assert.Equal(Path.Combine(_outputDir, "my_cool_game_"), result.RootDirectory);
|
|
var content = ReadFile(result, "CMakeLists.txt");
|
|
Assert.Contains("project(my_cool_game_ CXX)", content);
|
|
// The human-readable name is preserved verbatim elsewhere (e.g. the printf greeting).
|
|
Assert.Contains("my cool game!", ReadFile(result, "src/main.cpp"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Generate_FilesWrittenList_MatchesActualFilesOnDisk()
|
|
{
|
|
var result = ProjectGenerator.Generate(new ProjectOptions
|
|
{
|
|
Name = "verify_list",
|
|
Type = OrbisTargetType.Sprx,
|
|
OutputDirectory = _outputDir,
|
|
DeployHost = "10.0.0.1",
|
|
});
|
|
|
|
Assert.NotEmpty(result.FilesWritten);
|
|
Assert.All(result.FilesWritten, f => Assert.True(File.Exists(f), $"Listed but missing on disk: {f}"));
|
|
}
|
|
|
|
private static void AssertFileExists(GeneratedProject result, string relativePath)
|
|
{
|
|
var full = Path.Combine(result.RootDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
|
Assert.True(File.Exists(full), $"Expected file not found: {relativePath}");
|
|
}
|
|
|
|
private static void AssertFileMissing(GeneratedProject result, string relativePath)
|
|
{
|
|
var full = Path.Combine(result.RootDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
|
Assert.False(File.Exists(full), $"Expected file NOT to exist: {relativePath}");
|
|
}
|
|
|
|
private static string ReadFile(GeneratedProject result, string relativePath)
|
|
{
|
|
var full = Path.Combine(result.RootDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
|
return File.ReadAllText(full);
|
|
}
|
|
}
|