9 Commits
Author SHA1 Message Date
Claude CodeandClaude Fable 5 a6859cb8c8 Install llvm in CI for the GoldHEN plugin job
The SDK's Makefile archives libGoldHEN_Hook.a with llvm-ar, which the runner's
clang package does not pull in, so the ci_ghplugin job failed with
"llvm-ar: not found".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 18:30:31 +02:00
Claude CodeandClaude Fable 5 6da25b6e69 Build generated projects natively on Windows instead of requiring WSL
The toolchain file hardcoded bin/linux for create-fself and readoelf, and
build.bat only ever shelled into WSL. On a Windows host with the toolchain
installed for Windows, a generated project could not be built at all.

Pick the host tool directory from CMAKE_HOST_WIN32/APPLE (still overridable
via -DOO_PS4_TOOLCHAIN_BIN for mixed setups), and have build.bat build
natively whenever OO_PS4_TOOLCHAIN is visible from Windows, falling back to
WSL otherwise. The native path needs a single-config generator because the
Visual Studio generators cannot drive this cross-compile, so it looks for
Ninja on PATH and then in the Visual Studio install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:57:24 +02:00
Claude CodeandClaude Fable 5 d863dd462d 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>
2026-07-28 17:57:14 +02:00
Claude CodeandClaude Fable 5 abd288c882 Emit a plain .elf alongside the packaged SELF artifact
create-fself output (eboot.bin, .prx, .sprx) is SELF-wrapped, not plain ELF.
Raw-ELF loader tools identify a file by its magic bytes rather than its
extension, so they cannot load those no matter what the file is renamed to.
Copy the validated .oelf to <name>.elf as well and expose the path as the
ORBIS_ELF_PATH target property, so those tools have something to point at.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:56:35 +02:00
Claude CodeandClaude Fable 5 895858e650 Write a .vscode/settings.json into generated projects
Sets cmake.configureOnOpen to false so the CMake Tools extension does not try
to drive the project itself. Outside a WSL/Remote-WSL session it picks a local
MSVC kit and configures with cl.exe, which cannot parse the toolchain file's
clang-style flags (-fPIC, -isysroot) and fails confusingly. Building goes
through tasks.json -> build.bat/build.sh instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:55:32 +02:00
Claude Code 53b26f0f98 Fix dark mode: theme the plain TextBox/CheckBox controls UIFramework can't skin 2026-07-22 10:56:35 +02:00
Claude Code c76d834ace Add light/dark mode toggle to the GUI, persisted across launches 2026-07-22 10:47:12 +02:00
Claude Code 88e5214781 Fix flaky CI: authenticate GitHub API calls to avoid shared-runner rate limiting 2026-07-22 10:40:27 +02:00
Claude Code baea90ea77 Add xUnit tests for OrbisForge.Core, wire into CI 2026-07-22 10:37:42 +02:00
19 changed files with 829 additions and 33 deletions

No files matched your search

+42 -1
View File
@@ -8,6 +8,20 @@ on:
workflow_dispatch:
jobs:
unit-tests:
name: Unit tests (OrbisForge.Core)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Run tests
run: dotnet test tests/OrbisForge.Core.Tests --logger "console;verbosity=normal"
generate-and-build:
name: Generate + build (${{ matrix.name }})
runs-on: ubuntu-latest
@@ -27,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"
@@ -52,9 +70,19 @@ jobs:
dotnet-version: '8.0.x'
- name: Install OpenOrbis PS4 Toolchain
env:
# Unauthenticated GitHub API calls are capped at 60/hour PER IP, and GitHub-hosted
# runners share IP pools across the whole platform -- that budget gets exhausted by
# unrelated workflows constantly. Authenticating with the auto-provided GITHUB_TOKEN
# raises it to 5000/hour, scoped to this run.
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
URL=$(curl -s https://api.github.com/repos/OpenOrbis/OpenOrbis-PS4-Toolchain/releases/latest | grep browser_download_url | cut -d '"' -f4)
URL=$(curl -s -H "Authorization: Bearer $GH_TOKEN" https://api.github.com/repos/OpenOrbis/OpenOrbis-PS4-Toolchain/releases/latest | grep browser_download_url | cut -d '"' -f4)
if [ -z "$URL" ]; then
echo "::error::Could not resolve the OpenOrbis toolchain download URL (GitHub API call returned no browser_download_url)."
exit 1
fi
curl -L -o toolchain.tar.gz "$URL"
mkdir -p /tmp/oo-extract
tar -xzf toolchain.tar.gz -C /tmp/oo-extract
@@ -62,6 +90,19 @@ 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
# The SDK's Makefile archives with llvm-ar, which the clang package does
# not pull in on the runner image.
sudo apt-get install -y llvm
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
+1
View File
@@ -3,3 +3,4 @@ obj/
publish/
.vs/
*.user
TestResults/
+3
View File
@@ -4,6 +4,9 @@
<Project Path="src/OrbisForge.Core/OrbisForge.Core.csproj" />
<Project Path="src/OrbisForge.Gui/OrbisForge.Gui.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/OrbisForge.Core.Tests/OrbisForge.Core.Tests.csproj" />
</Folder>
<Project Path="external/UIFramework/src/UIFramework.Controls/UIFramework.Controls.csproj" />
<Project Path="external/UIFramework/src/UIFramework.Core/UIFramework.Core.csproj" />
</Solution>
+28 -6
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`.
@@ -34,7 +43,8 @@ A Windows GUI wizard and a cross-platform CLI both scaffold the same project lay
or the GUI checkbox. No credentials baked in by design — most PS4 FTP servers run anonymous.
- **GUI wizard (Windows)** — pick a name, type, output folder, optional `.pkg` packaging, and
optional FTP deploy target; detects your WSL distro and the OpenOrbis toolchain automatically,
and surfaces the latest available OpenOrbis release as a reference point.
and surfaces the latest available OpenOrbis release as a reference point. Light/dark mode
toggle, remembered across launches.
- **VS Code integration** — generated projects include a build task (Ctrl+Shift+B) and IntelliSense
configuration for the toolchain's unusual target triple.
- **CLI scaffolder (Windows + Linux + WSL)** — `orbisforge new`, plus `orbisforge build`/
@@ -85,9 +95,21 @@ dotnet publish src/OrbisForge.Cli -c Release -r win-x64 --self-contained -p:Pu
dotnet publish src/OrbisForge.Cli -c Release -r linux-x64 --self-contained -p:PublishSingleFile=true -o publish/linux-x64
```
## Testing
```
dotnet test tests/OrbisForge.Core.Tests
```
Unit tests cover `OrbisForge.Core`'s scaffolding logic directly (file sets per target type,
template content, sanitization, warnings) — fast, no toolchain required. CI's generate-and-build
matrix is the complementary end-to-end layer: it proves the *output* actually compiles against
the real OpenOrbis toolchain.
## Project layout
- `src/OrbisForge.Core` — scaffolding logic (multi-targeted `net8.0`/`netstandard2.0`)
- `tests/OrbisForge.Core.Tests` — xUnit tests for Core
- `src/OrbisForge.Cli` — cross-platform console scaffolder
- `src/OrbisForge.Gui` — Windows wizard (WinForms + UIFramework)
- `external/UIFramework` — git submodule
+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;
}
}
+8 -1
View File
@@ -31,9 +31,16 @@ public static class ProjectGenerator
WriteFile(Path.Combine(root, "build.bat"), Windows(BuildScriptsTemplate.BuildBat), files);
WriteFile(Path.Combine(root, ".vscode", "tasks.json"), VsCodeTemplate.TasksJson, files);
WriteFile(Path.Combine(root, ".vscode", "c_cpp_properties.json"), VsCodeTemplate.CCppPropertiesJson, files);
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);
@@ -24,19 +24,55 @@ internal static class BuildScriptsTemplate
cmake --build build
""";
// Windows entry point: shells into WSL. Deliberately avoids ever referencing an
// unquoted $PATH inside the wsl bash -c command line -- WSL auto-injects the full
// Windows PATH (with spaces and parentheses like "Program Files (x86)") into that
// environment, and passing it through unquoted breaks bash's parser. Instead this
// queries OO_PS4_TOOLCHAIN once via its own isolated call and builds an explicit,
// clean PATH from it.
// Windows entry point. Builds natively when the toolchain is visible from Windows
// (its bin\windows tools are then usable), otherwise shells into WSL.
//
// The WSL path deliberately avoids ever referencing an unquoted $PATH inside the
// bash -c command line -- WSL auto-injects the full Windows PATH (with spaces and
// parentheses like "Program Files (x86)") into that environment, and passing it
// through unquoted breaks bash's parser. Instead this queries OO_PS4_TOOLCHAIN once
// via its own isolated call and builds an explicit, clean PATH from it.
public const string BuildBat = """
@echo off
setlocal
setlocal EnableDelayedExpansion
set "SCRIPTDIR=%~dp0"
if "%SCRIPTDIR:~-1%"=="\" set "SCRIPTDIR=%SCRIPTDIR:~0,-1%"
rem vswhere lives under a path containing "(x86)"; resolve it up front, because
rem expanding that inside a parenthesised if-block breaks cmd's parser.
set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
if not defined OO_PS4_TOOLCHAIN goto :wsl
if /i "%~1"=="clean" (
if exist "%SCRIPTDIR%\build" rmdir /s /q "%SCRIPTDIR%\build"
echo Cleaned.
exit /b 0
)
where cmake >nul 2>&1
if errorlevel 1 echo cmake was not found on PATH. & exit /b 1
where clang >nul 2>&1
if errorlevel 1 echo clang was not found on PATH -- install LLVM and add its bin directory. & exit /b 1
rem The Visual Studio generators cannot drive this cross-compile, so a
rem single-config generator is required. Ninja ships inside VS when it is not
rem already on PATH.
set "NINJA="
for /f "delims=" %%i in ('where ninja 2^>nul') do set "NINJA=%%i"
if not defined NINJA if exist "%VSWHERE%" for /f "usebackq delims=" %%i in (`"%VSWHERE%" -latest -products * -property installationPath 2^>nul`) do (
if exist "%%i\Common7\IDE\CommonExtensions\Microsoft\CMake\Ninja\ninja.exe" set "NINJA=%%i\Common7\IDE\CommonExtensions\Microsoft\CMake\Ninja\ninja.exe"
)
if not defined NINJA echo Ninja was not found on PATH or in a Visual Studio install. Try: winget install Ninja-build.Ninja & exit /b 1
cmake -B "%SCRIPTDIR%\build" -S "%SCRIPTDIR%" -G Ninja -DCMAKE_MAKE_PROGRAM="!NINJA!" -DCMAKE_TOOLCHAIN_FILE="%SCRIPTDIR%/cmake/oo-ps4-toolchain.cmake"
if errorlevel 1 exit /b 1
cmake --build "%SCRIPTDIR%\build"
if errorlevel 1 exit /b 1
exit /b 0
:wsl
for /f "usebackq tokens=2 delims==" %%i in (`wsl -- bash -ic "env | grep ^OO_PS4_TOOLCHAIN="`) do set "OO_TOOLCHAIN_WSL=%%i"
if "%OO_TOOLCHAIN_WSL%"=="" (
echo OO_PS4_TOOLCHAIN is not set in your WSL environment. See the OpenOrbis PS4 Toolchain README.
@@ -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 "")
@@ -36,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")
@@ -57,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:
@@ -83,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"
@@ -93,8 +134,9 @@ internal static class OrbisTargetsTemplate
--paid ${ORBIS_PAID}
${_libname_args}
COMMAND "${OO_PS4_TOOLCHAIN_BIN}/readoelf" "$<TARGET_FILE:${name}>.oelf"
BYPRODUCTS "${_out_path}"
COMMENT "orbis: packaging ${name} -> ${_out_name} (type=${ORBIS_TYPE}, ptype=${_ptype})"
COMMAND "${CMAKE_COMMAND}" -E copy "$<TARGET_FILE:${name}>.oelf" "${_elf_path}"
BYPRODUCTS "${_out_path}" "${_elf_path}"
COMMENT "orbis: packaging ${name} -> ${_out_name} + ${name}.elf (type=${ORBIS_TYPE}, ptype=${_ptype})"
VERBATIM
)
@@ -102,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()
@@ -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;
}
}
""";
}
@@ -15,7 +15,18 @@ internal static class ToolchainTemplate
message(FATAL_ERROR "OO_PS4_TOOLCHAIN environment variable is not set")
endif()
set(OO_PS4_TOOLCHAIN "$ENV{OO_PS4_TOOLCHAIN}" CACHE PATH "OpenOrbis PS4 toolchain root")
set(OO_PS4_TOOLCHAIN_BIN "${OO_PS4_TOOLCHAIN}/bin/linux" CACHE PATH "OpenOrbis PS4 toolchain host tools")
# create-fself/readoelf are host binaries, so this follows the machine running
# the build, not the PS4 target. Overridable via -DOO_PS4_TOOLCHAIN_BIN=... for
# the mixed case (e.g. a Windows checkout of the toolchain driven from WSL).
if(CMAKE_HOST_WIN32)
set(_oo_host_bin "windows")
elseif(CMAKE_HOST_APPLE)
set(_oo_host_bin "macos")
else()
set(_oo_host_bin "linux")
endif()
set(OO_PS4_TOOLCHAIN_BIN "${OO_PS4_TOOLCHAIN}/bin/${_oo_host_bin}" CACHE PATH "OpenOrbis PS4 toolchain host tools")
set(CMAKE_C_COMPILER clang)
set(CMAKE_CXX_COMPILER clang++)
@@ -54,4 +54,15 @@ internal static class VsCodeTemplate
"version": 4
}
""";
// The CMakePresets.json we generate is for command-line/WSL cmake use, not for the
// CMake Tools extension to drive itself. Outside a WSL/Remote-WSL session it'd pick a
// local MSVC kit and try to configure with cl.exe, which can't parse the toolchain
// file's clang-style flags (-fPIC, -isysroot, ...) and fails confusingly. Building
// always goes through tasks.json (Ctrl+Shift+B) -> build.bat/build.sh instead.
public const string SettingsJson = """
{
"cmake.configureOnOpen": false
}
""";
}
+45
View File
@@ -0,0 +1,45 @@
using System;
using System.IO;
namespace OrbisForge.Gui
{
/// <summary>Tiny persisted preference store -- currently just the light/dark choice.
/// Not worth a full settings framework for one boolean.</summary>
internal static class GuiSettings
{
private static string FilePath
{
get
{
string dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OrbisForge");
Directory.CreateDirectory(dir);
return Path.Combine(dir, "settings.txt");
}
}
public static bool LoadDarkMode()
{
try
{
return File.Exists(FilePath) && File.ReadAllText(FilePath).Trim() == "dark";
}
catch
{
return false;
}
}
public static void SaveDarkMode(bool isDark)
{
try
{
File.WriteAllText(FilePath, isDark ? "dark" : "light");
}
catch
{
// Not worth surfacing to the user -- worst case the choice doesn't persist.
}
}
}
}
+49
View File
@@ -5,6 +5,8 @@ using System.IO;
using System.Windows.Forms;
using UIFramework.Controls;
using UIFramework.Core.Controls;
using UIFramework.Core.Skinning;
using UIFramework.Core.Skinning.Skins;
using OrbisForge.Core;
@@ -23,6 +25,7 @@ namespace OrbisForge.Gui
private readonly SkinLabel _envLabel = new SkinLabel();
private readonly SkinButton _generateButton = new SkinButton();
private readonly TextBox _statusBox = new TextBox();
private readonly CheckBox _darkModeCheck = new CheckBox();
private WslDetectionResult _wsl;
@@ -42,6 +45,20 @@ namespace OrbisForge.Gui
const int fieldWidth = 320;
int y = 16;
_darkModeCheck.Text = "Dark mode";
_darkModeCheck.AutoSize = true;
_darkModeCheck.FlatStyle = FlatStyle.Flat;
_darkModeCheck.Anchor = AnchorStyles.Top | AnchorStyles.Right;
_darkModeCheck.Location = new Point(ClientSize.Width - 110, y);
_darkModeCheck.Checked = GuiSettings.LoadDarkMode();
_darkModeCheck.CheckedChanged += (s, e) =>
{
ApplyTheme(_darkModeCheck.Checked, save: true);
RefreshPlainControlColors();
};
root.Controls.Add(_darkModeCheck);
ApplyTheme(_darkModeCheck.Checked, save: false);
var nameLabel = new SkinLabel { Text = "Project name:", Location = new Point(left, y), AutoSize = true };
y += 20;
_nameBox.Location = new Point(left, y);
@@ -54,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);
@@ -72,11 +90,13 @@ namespace OrbisForge.Gui
_pkgCheck.Text = "Also generate full .pkg installer (EXE only)";
_pkgCheck.Location = new Point(left, y);
_pkgCheck.AutoSize = true;
_pkgCheck.FlatStyle = FlatStyle.Flat;
y += 28;
_deployCheck.Text = "Deploy to PS4 via FTP after build";
_deployCheck.Location = new Point(left, y);
_deployCheck.AutoSize = true;
_deployCheck.FlatStyle = FlatStyle.Flat;
_deployCheck.CheckedChanged += (s, e) => UpdateDeployFieldsEnabled();
y += 24;
@@ -137,9 +157,37 @@ namespace OrbisForge.Gui
UpdatePkgCheckEnabled();
UpdateDeployFieldsEnabled();
RefreshPlainControlColors();
Shown += (s, e) => DetectEnvironment();
}
private static void ApplyTheme(bool dark, bool save)
{
SkinManager.Current = dark ? (ISkin)new DarkSkin() : new LightSkin();
if (save) GuiSettings.SaveDarkMode(dark);
}
// _statusBox and the CheckBoxes are plain WinForms controls -- UIFramework has no
// skinned equivalents for them (no multiline/scrollable SkinTextBox variant, no
// SkinCheckBox at all) -- so they don't auto-repaint on a skin change like every
// other control here does. Left at their WinForms defaults, they stay bright white
// regardless of the active skin, which looks broken next to a dark-themed form.
private void RefreshPlainControlColors()
{
var textAppearance = SkinManager.Current.GetAppearance(ElementKeys.TextBox, ElementState.Normal);
_statusBox.BackColor = textAppearance.Background;
_statusBox.ForeColor = textAppearance.ForeColor;
var panelAppearance = SkinManager.Current.GetAppearance(ElementKeys.Panel, ElementState.Normal);
var labelAppearance = SkinManager.Current.GetAppearance(ElementKeys.Label, ElementState.Normal);
foreach (var checkBox in new[] { _pkgCheck, _deployCheck, _darkModeCheck })
{
checkBox.UseVisualStyleBackColor = false;
checkBox.BackColor = panelAppearance.Background;
checkBox.ForeColor = labelAppearance.ForeColor;
}
}
private void UpdatePkgCheckEnabled()
{
bool isExe = SelectedType() == OrbisTargetType.Exe;
@@ -161,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;
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OrbisForge.Core\OrbisForge.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,78 @@
using OrbisForge.Core;
using Xunit;
namespace OrbisForge.Core.Tests;
public class OrbisTargetTypeTests
{
[Theory]
[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());
}
[Theory]
[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));
Assert.Equal(expected, type);
}
[Theory]
[InlineData("")]
[InlineData("elf")]
[InlineData("dll")]
public void TryParse_RejectsUnknownText(string text)
{
Assert.False(OrbisTargetTypeExtensions.TryParse(text, out _));
}
[Fact]
public void ArtifactRelativePath_Exe_IsEbootBin()
{
Assert.Equal("build/eboot.bin", OrbisTargetType.Exe.ArtifactRelativePath("anything"));
}
[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));
}
}
@@ -0,0 +1,309 @@
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);
}
}
@@ -0,0 +1,42 @@
using OrbisForge.Core;
using Xunit;
namespace OrbisForge.Core.Tests;
public class ProjectOptionsTests
{
[Theory]
[InlineData("my_project", "my_project")]
[InlineData("my project", "my_project")]
[InlineData(" spaced ", "spaced")]
[InlineData("a/b\\c", "a_b_c")]
[InlineData("kebab-case_2", "kebab-case_2")]
[InlineData("", "orbis_project")]
[InlineData(" ", "orbis_project")]
[InlineData("!!!", "___")]
public void SanitizedName_ReplacesNonAlphanumericSeparators(string input, string expected)
{
var options = new ProjectOptions { Name = input };
Assert.Equal(expected, options.SanitizedName);
}
[Fact]
public void SanitizedName_PreservesUnicodeLetters()
{
// char.IsLetterOrDigit is Unicode-aware, so accented letters pass through as-is
// rather than getting collapsed to underscores -- documenting that, not just assuming it.
var options = new ProjectOptions { Name = "héllo" };
Assert.Equal("héllo", options.SanitizedName);
}
[Theory]
[InlineData(null, false)]
[InlineData("", false)]
[InlineData(" ", false)]
[InlineData("192.168.1.50", true)]
public void IncludeDeploy_TracksWhetherHostIsSet(string? host, bool expected)
{
var options = new ProjectOptions { Name = "x", DeployHost = host };
Assert.Equal(expected, options.IncludeDeploy);
}
}