Add a GHPLUGIN target type for building GoldHEN plugins

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>
This commit is contained in:
Claude CodeandClaude Fable 5 committed 2026-07-28 17:57:14 +02:00
1 parent abd288c882
commit d863dd462d
11 files changed
+216 -28

No files matched your search

+14
View File
@@ -41,6 +41,10 @@ jobs:
type: sprx
extra_args: ""
artifact: build/ci_sprx.sprx
- name: ci_ghplugin
type: ghplugin
extra_args: ""
artifact: build/ci_ghplugin.prx
- name: ci_pkg
type: exe
extra_args: "--pkg"
@@ -86,6 +90,16 @@ jobs:
echo "OO_PS4_TOOLCHAIN=$HOME/OpenOrbis-PS4-Toolchain" >> "$GITHUB_ENV"
echo "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1" >> "$GITHUB_ENV"
- name: Install GoldHEN Plugin SDK
if: matrix.type == 'ghplugin'
run: |
set -e
git clone --depth 1 https://github.com/GoldHEN/GoldHEN_Plugins_SDK.git /tmp/goldhen-sdk
make -C /tmp/goldhen-sdk install
# 'make install' ships the headers and libGoldHEN_Hook.a but leaves crtprx.o
# behind in the SDK's own build dir; TYPE GHPLUGIN looks for it in lib/.
cp /tmp/goldhen-sdk/build/crtprx.o "$OO_PS4_TOOLCHAIN/lib/"
- name: Build OrbisForge CLI
run: dotnet publish src/OrbisForge.Cli -c Release -r linux-x64 --self-contained -p:PublishSingleFile=true -o publish/linux-x64
+14 -5
View File
@@ -9,8 +9,9 @@
OrbisForge generates self-contained CMake projects for the [OpenOrbis PS4
Toolchain](https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain), producing `eboot.bin`
executables, `.prx` user libraries, `.sprx` signed system libraries, and (optionally) full
signed/encrypted `.pkg` installers — through a single `add_orbis_target()` CMake function.
executables, `.prx` user libraries, `.sprx` signed system libraries, GoldHEN plugins, and
(optionally) full signed/encrypted `.pkg` installers — through a single `add_orbis_target()`
CMake function.
A Windows GUI wizard and a cross-platform CLI both scaffold the same project layout.
> The GUI wizard is Windows-only, built on [UIFramework](https://github.com/xeghosted/UIFramework)
@@ -19,9 +20,17 @@ A Windows GUI wizard and a cross-platform CLI both scaffold the same project lay
## Features
- **All three artifact types** — EXE (`eboot.bin`), PRX, and SPRX, via one `add_orbis_target()`
call. `TYPE` selects the right CRT object (`crt1.o` vs `crtlib.o`), the right `create-fself`
packaging mode (`--eboot` vs `--lib`), and a sensible default `-ptype`.
- **Every artifact type** — EXE (`eboot.bin`), PRX, SPRX, and GHPLUGIN, via one
`add_orbis_target()` call. `TYPE` selects the right CRT object (`crt1.o` vs `crtlib.o` vs
`crtprx.o`), the right `create-fself` packaging mode (`--eboot` vs `--lib`), and a sensible
default `-ptype`.
- **GoldHEN plugins** — `TYPE GHPLUGIN` builds a plugin the GoldHEN loader actually accepts.
It differs from a plain PRX in three ways that must all be right or the plugin builds
cleanly and then silently never loads: the [GoldHEN Plugin
SDK](https://github.com/GoldHEN/GoldHEN_Plugins_SDK)'s `crtprx.o` instead of `crtlib.o`,
entry at `_init`, and `SceLibcInternal`/`SceSysmodule` instead of `c`/`c++`. Requires the
SDK installed into the toolchain — the generated CMake fails with instructions if it isn't.
The scaffolded starter plugin shows a PS4 notification from `module_start`.
- **Optional full `.pkg` installer** — `add_orbis_package()` stages `eboot.bin` and a generated
`param.sfo` into a directory tree matching the final package layout, then drives
`create-gp4` + `PkgTool.Core pkg_build` to produce a real signed/encrypted `.pkg`.
+6 -3
View File
@@ -5,13 +5,15 @@ var help = """
OrbisForge - project scaffolder for the OpenOrbis PS4 Toolchain (CMake-based)
Usage:
orbisforge new --name <name> --type exe|prx|sprx [options]
orbisforge new --name <name> --type exe|prx|sprx|ghplugin [options]
orbisforge build [path] [--clean]
orbisforge deploy [path]
'new' options:
--name <name> Project / target name (required)
--type <type> exe, prx, or sprx (required)
--type <type> exe, prx, sprx, or ghplugin (required). ghplugin builds a
GoldHEN plugin and needs the GoldHEN Plugin SDK installed
into the toolchain (crtprx.o + libGoldHEN_Hook.a in lib/).
--output <dir> Output directory (default: current directory)
--pkg EXE only: also scaffold a full .pkg installer step
--ptype <ptype> Override create-fself -ptype (advanced)
@@ -33,6 +35,7 @@ var help = """
Examples:
orbisforge new --name hello_cmake --type exe
orbisforge new --name my_plugin --type sprx --output E:\Projects\PS4
orbisforge new --name notify_plugin --type ghplugin --output E:\Projects\PS4
orbisforge new --name my_game --type exe --pkg
orbisforge new --name my_game --type exe --deploy-host 192.168.1.50 --deploy-path /data/homebrew/my_game/
orbisforge build
@@ -121,7 +124,7 @@ int RunNew(string[] args)
if (typeText is null || !OrbisTargetTypeExtensions.TryParse(typeText, out var type))
{
Console.Error.WriteLine("--type is required and must be one of: exe, prx, sprx.");
Console.Error.WriteLine("--type is required and must be one of: exe, prx, sprx, ghplugin.");
return 1;
}
+13
View File
@@ -14,6 +14,10 @@ public enum OrbisTargetType
/// <summary>Signed system library/module -> &lt;name&gt;.sprx (ptype "system_dynlib").</summary>
Sprx,
/// <summary>GoldHEN plugin -> &lt;name&gt;.prx (ptype "fake"). Like Prx, but entered at
/// _init via the GoldHEN SDK's crtprx.o so the loader reaches module_start.</summary>
GoldHenPlugin,
}
public static class OrbisTargetTypeExtensions
@@ -23,12 +27,18 @@ public static class OrbisTargetTypeExtensions
OrbisTargetType.Exe => "EXE",
OrbisTargetType.Prx => "PRX",
OrbisTargetType.Sprx => "SPRX",
OrbisTargetType.GoldHenPlugin => "GHPLUGIN",
_ => throw new ArgumentOutOfRangeException(nameof(type), type, null),
};
public static bool IsLibrary(this OrbisTargetType type) =>
type is OrbisTargetType.Prx or OrbisTargetType.Sprx;
/// <summary>True when the project's primary source is src/&lt;name&gt;.cpp rather than
/// src/main.cpp. Libraries and GoldHEN plugins both have no main().</summary>
public static bool UsesNamedSourceFile(this OrbisTargetType type) =>
type.IsLibrary() || type is OrbisTargetType.GoldHenPlugin;
/// <summary>Path to the packaged artifact create-fself produces, relative to the
/// project root (i.e. under the CMake build directory).</summary>
public static string ArtifactRelativePath(this OrbisTargetType type, string name) => type switch
@@ -36,6 +46,7 @@ public static class OrbisTargetTypeExtensions
OrbisTargetType.Exe => "build/eboot.bin",
OrbisTargetType.Prx => $"build/{name}.prx",
OrbisTargetType.Sprx => $"build/{name}.sprx",
OrbisTargetType.GoldHenPlugin => $"build/{name}.prx",
_ => throw new ArgumentOutOfRangeException(nameof(type), type, null),
};
@@ -46,6 +57,8 @@ public static class OrbisTargetTypeExtensions
case "EXE": case "BIN": type = OrbisTargetType.Exe; return true;
case "PRX": type = OrbisTargetType.Prx; return true;
case "SPRX": type = OrbisTargetType.Sprx; return true;
case "GHPLUGIN": case "GOLDHEN": case "PLUGIN":
type = OrbisTargetType.GoldHenPlugin; return true;
default: type = default; return false;
}
}
+7 -1
View File
@@ -34,7 +34,13 @@ public static class ProjectGenerator
WriteFile(Path.Combine(root, ".vscode", "settings.json"), VsCodeTemplate.SettingsJson, files);
WriteFile(Path.Combine(root, ".gitignore"), Unix(GitignoreTemplate.Gitignore), files);
if (options.Type.IsLibrary())
if (options.Type == OrbisTargetType.GoldHenPlugin)
{
// A plugin is entered at module_start, so it gets neither a main() nor
// the library pair's public header.
WriteFile(Path.Combine(root, "src", $"{name}.cpp"), SourceTemplate.PluginCpp(name), files);
}
else if (options.Type.IsLibrary())
{
WriteFile(Path.Combine(root, "src", $"{name}.cpp"), SourceTemplate.LibCpp(name), files);
WriteFile(Path.Combine(root, "src", $"{name}.h"), SourceTemplate.LibHeader(name), files);
@@ -17,7 +17,7 @@ internal static class CMakeListsTemplate
sb.AppendLine("endif()");
sb.AppendLine();
var sources = options.Type.IsLibrary()
var sources = options.Type.UsesNamedSourceFile()
? $"src/{name}.cpp"
: "src/main.cpp";
@@ -6,20 +6,34 @@ internal static class OrbisTargetsTemplate
# add_orbis_target() / add_orbis_package() - generated by OrbisForge.
#
# add_orbis_target(<name>
# TYPE EXE|PRX|SPRX
# TYPE EXE|PRX|SPRX|GHPLUGIN
# SOURCES <files...>
# [LIBS <names...>] # default: c kernel c++
# [PTYPE <ptype>] # default: "fake" (EXE/PRX) or "system_dynlib" (SPRX)
# # (GHPLUGIN: SceLibcInternal kernel SceSysmodule GoldHEN_Hook)
# [PTYPE <ptype>] # default: "fake", or "system_dynlib" for SPRX
# [PAID <hex>] # default: 0x3800000000000011
# [LIBNAME <name>] # PRX/SPRX only, passed to create-fself -libname
# [LIBNAME <name>] # library types only, passed to create-fself -libname
# )
#
# Links <name> as a PS4 ELF via the OpenOrbis toolchain, then runs
# create-fself as a POST_BUILD step to produce the final artifact:
# EXE -> eboot.bin
# PRX -> <name>.prx
# SPRX -> <name>.sprx
# EXE -> eboot.bin
# PRX -> <name>.prx
# SPRX -> <name>.sprx
# GHPLUGIN -> <name>.prx
#
# GHPLUGIN builds a GoldHEN plugin. It differs from PRX in three ways that
# all have to be right or the plugin builds cleanly and never loads: it
# links the GoldHEN SDK's crtprx.o instead of crtlib.o, enters at _init,
# and links SceLibcInternal/SceSysmodule rather than c/c++. Requires the
# GoldHEN Plugin SDK installed into the toolchain. Entry points are
# module_start/module_stop, which must have C linkage.
# readoelf then runs over the packaged .oelf as a build-time sanity check.
# A plain, unsigned <name>.elf (copy of that same validated .oelf) is
# produced alongside it -- raw-ELF loader tools inspect a file's magic
# bytes rather than its extension, so they can't load eboot.bin/*.prx/
# *.sprx (which are SELF-wrapped, not plain ELF) no matter what it's
# renamed to; point those tools at the .elf instead.
function(add_orbis_target name)
set(options "")
@@ -29,11 +43,6 @@ internal static class OrbisTargetsTemplate
if(NOT ORBIS_SOURCES)
message(FATAL_ERROR "add_orbis_target(${name}): SOURCES is required")
# A plain, unsigned <name>.elf (copy of that same validated .oelf) is
# produced alongside it -- raw-ELF loader tools inspect a file's magic
# bytes rather than its extension, so they can't load eboot.bin/*.prx/
# *.sprx (which are SELF-wrapped, not plain ELF) no matter what it's
# renamed to; point those tools at the .elf instead.
endif()
if(NOT ORBIS_TYPE)
@@ -41,12 +50,11 @@ internal static class OrbisTargetsTemplate
endif()
string(TOUPPER "${ORBIS_TYPE}" ORBIS_TYPE)
if(NOT ORBIS_LIBS)
set(ORBIS_LIBS c kernel c++)
endif()
add_executable(${name} ${ORBIS_SOURCES})
set(_default_libs c kernel c++)
set(_entry_args "")
if(ORBIS_TYPE STREQUAL "EXE")
set(_crt_object "${OO_PS4_TOOLCHAIN}/lib/crt1.o")
set(_default_ptype "fake")
@@ -62,8 +70,35 @@ internal static class OrbisTargetsTemplate
set(_default_ptype "system_dynlib")
set(_out_name "${name}.sprx")
set(_pkg_flag "--lib")
elseif(ORBIS_TYPE STREQUAL "GHPLUGIN")
# GoldHEN enters a plugin at _init, which the SDK's crtprx.o forwards
# to module_start/module_stop. Built with the stock crtlib.o instead,
# a plugin links without complaint and then never loads on console --
# so the SDK is checked for here rather than failing silently later.
set(_crt_object "${OO_PS4_TOOLCHAIN}/lib/crtprx.o")
if(NOT EXISTS "${_crt_object}")
message(FATAL_ERROR
"add_orbis_target(${name}): TYPE GHPLUGIN needs the GoldHEN Plugin SDK "
"installed into the toolchain, but ${_crt_object} is missing. "
"Build the SDK (build_static.bat on Windows, make on Linux), then copy "
"crtprx.o and libGoldHEN_Hook.a into ${OO_PS4_TOOLCHAIN}/lib and the SDK's "
"include/* into ${OO_PS4_TOOLCHAIN}/include/GoldHEN.")
endif()
set(_default_ptype "fake")
set(_out_name "${name}.prx")
set(_pkg_flag "--lib")
set(_default_libs SceLibcInternal kernel SceSysmodule GoldHEN_Hook)
set(_entry_args "LINKER:-e,_init")
else()
message(FATAL_ERROR "add_orbis_target(${name}): TYPE must be EXE, PRX, or SPRX (got '${ORBIS_TYPE}')")
message(FATAL_ERROR "add_orbis_target(${name}): TYPE must be EXE, PRX, SPRX, or GHPLUGIN (got '${ORBIS_TYPE}')")
endif()
if(NOT ORBIS_LIBS)
set(ORBIS_LIBS ${_default_libs})
endif()
if(_entry_args)
target_link_options(${name} PRIVATE "${_entry_args}")
endif()
# Order matters here and mirrors the toolchain's own sample Makefiles:
@@ -88,6 +123,7 @@ internal static class OrbisTargetsTemplate
endif()
set(_out_path "${CMAKE_CURRENT_BINARY_DIR}/${_out_name}")
set(_elf_path "${CMAKE_CURRENT_BINARY_DIR}/${name}.elf")
add_custom_command(TARGET ${name} POST_BUILD
COMMAND "${OO_PS4_TOOLCHAIN_BIN}/create-fself"
@@ -108,6 +144,7 @@ internal static class OrbisTargetsTemplate
ORBIS_OUTPUT_PATH "${_out_path}"
ORBIS_OUTPUT_NAME "${_out_name}"
ORBIS_TYPE "${ORBIS_TYPE}"
ORBIS_ELF_PATH "${_elf_path}"
)
endfunction()
@@ -124,7 +161,6 @@ internal static class OrbisTargetsTemplate
set(oneValueArgs TITLE_ID CONTENT_ID TITLE VERSION ASSETS_DIR SCE_MODULE_DIR)
cmake_parse_arguments(PKG "" "${oneValueArgs}" "" ${ARGN})
set(_elf_path "${CMAKE_CURRENT_BINARY_DIR}/${name}.elf")
if(NOT PKG_TITLE_ID OR NOT PKG_CONTENT_ID OR NOT PKG_TITLE)
message(FATAL_ERROR "add_orbis_package(${target}): TITLE_ID, CONTENT_ID, and TITLE are required")
endif()
@@ -144,7 +180,6 @@ internal static class OrbisTargetsTemplate
endif()
set(_sce_sys "${CMAKE_CURRENT_SOURCE_DIR}/sce_sys")
ORBIS_ELF_PATH "${_elf_path}"
# create-gp4's --files list has no separate source-vs-target-path syntax:
# each entry is a path relative to the working directory it runs from,
@@ -41,4 +41,66 @@ internal static class SourceTemplate
int {{projectName}}_hello(char *buf, size_t buf_size, int num);
""";
public static string PluginCpp(string projectName) => $$"""
#include <stdio.h>
#include <string.h>
#include <stddef.h>
#include <GoldHEN/Common.h>
#define PLUGIN_NAME "{{projectName}}"
#define PLUGIN_VERSION "1.00"
// GoldHEN reads these to list the plugin.
#define attr_public __attribute__((visibility("default")))
attr_public const char *g_pluginName = PLUGIN_NAME;
attr_public const char *g_pluginDesc = "Generated by OrbisForge";
attr_public const char *g_pluginAuth = "unknown";
attr_public const char *g_pluginVersion = PLUGIN_VERSION;
// This is compiled as C++, so the entry points need C linkage: crtprx.o's
// _init resolves module_start/module_stop by their unmangled names.
extern "C" {
static void notify(const char *msg)
{
OrbisNotificationRequest req;
memset(&req, 0, sizeof(req));
req.type = NotificationRequest;
req.targetId = -1;
req.useIconImageUri = 1;
strncpy(req.iconUri, "cxml://psnotification/tex_icon_system", sizeof(req.iconUri) - 1);
strncpy(req.message, msg, sizeof(req.message) - 1);
// Non-blocking: module_start must not stall the title's startup.
sceKernelSendNotificationRequest(0, &req, sizeof(req), 0);
}
int module_start(size_t argc, const void *argp)
{
(void)argc;
(void)argp;
notify(PLUGIN_NAME " v" PLUGIN_VERSION " loaded");
return 0;
}
int module_stop(size_t argc, const void *argp)
{
(void)argc;
(void)argp;
notify(PLUGIN_NAME " unloaded");
return 0;
}
}
""";
}
+2
View File
@@ -71,6 +71,7 @@ namespace OrbisForge.Gui
_typeBox.Items.Add("EXE (eboot.bin, homebrew app)");
_typeBox.Items.Add("PRX (user-mode library)");
_typeBox.Items.Add("SPRX (signed system library)");
_typeBox.Items.Add("GHPLUGIN (GoldHEN plugin)");
_typeBox.SelectedIndex = 0;
_typeBox.Location = new Point(left, y);
_typeBox.Size = new Size(fieldWidth, 24);
@@ -208,6 +209,7 @@ namespace OrbisForge.Gui
{
case 1: return OrbisTargetType.Prx;
case 2: return OrbisTargetType.Sprx;
case 3: return OrbisTargetType.GoldHenPlugin;
default: return OrbisTargetType.Exe;
}
}
@@ -9,6 +9,7 @@ public class OrbisTargetTypeTests
[InlineData(OrbisTargetType.Exe, "EXE")]
[InlineData(OrbisTargetType.Prx, "PRX")]
[InlineData(OrbisTargetType.Sprx, "SPRX")]
[InlineData(OrbisTargetType.GoldHenPlugin, "GHPLUGIN")]
public void ToCMakeString_ReturnsExpectedToken(OrbisTargetType type, string expected)
{
Assert.Equal(expected, type.ToCMakeString());
@@ -18,17 +19,33 @@ public class OrbisTargetTypeTests
[InlineData(OrbisTargetType.Exe, false)]
[InlineData(OrbisTargetType.Prx, true)]
[InlineData(OrbisTargetType.Sprx, true)]
[InlineData(OrbisTargetType.GoldHenPlugin, false)]
public void IsLibrary_OnlyTrueForPrxAndSprx(OrbisTargetType type, bool expected)
{
Assert.Equal(expected, type.IsLibrary());
}
// A plugin isn't a "library" in the lib.cpp + public-header sense, but it still
// has no main(), so it shares the src/<name>.cpp layout.
[Theory]
[InlineData(OrbisTargetType.Exe, false)]
[InlineData(OrbisTargetType.Prx, true)]
[InlineData(OrbisTargetType.Sprx, true)]
[InlineData(OrbisTargetType.GoldHenPlugin, true)]
public void UsesNamedSourceFile_TrueForEverythingWithoutMain(OrbisTargetType type, bool expected)
{
Assert.Equal(expected, type.UsesNamedSourceFile());
}
[Theory]
[InlineData("exe", OrbisTargetType.Exe)]
[InlineData("EXE", OrbisTargetType.Exe)]
[InlineData("bin", OrbisTargetType.Exe)]
[InlineData(" prx ", OrbisTargetType.Prx)]
[InlineData("SPRX", OrbisTargetType.Sprx)]
[InlineData("ghplugin", OrbisTargetType.GoldHenPlugin)]
[InlineData("GoldHEN", OrbisTargetType.GoldHenPlugin)]
[InlineData(" plugin ", OrbisTargetType.GoldHenPlugin)]
public void TryParse_AcceptsKnownAliasesCaseInsensitively(string text, OrbisTargetType expected)
{
Assert.True(OrbisTargetTypeExtensions.TryParse(text, out var type));
@@ -53,6 +70,7 @@ public class OrbisTargetTypeTests
[Theory]
[InlineData(OrbisTargetType.Prx, "my_lib", "build/my_lib.prx")]
[InlineData(OrbisTargetType.Sprx, "my_plugin", "build/my_plugin.sprx")]
[InlineData(OrbisTargetType.GoldHenPlugin, "notify", "build/notify.prx")]
public void ArtifactRelativePath_Libraries_UseNameInFilename(OrbisTargetType type, string name, string expected)
{
Assert.Equal(expected, type.ArtifactRelativePath(name));
@@ -67,10 +67,36 @@ public class ProjectGeneratorTests : IDisposable
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