Imported from Polyphase-Labs/Polyphase-Engine (
.claude/skills/polyphase-buildtarget/SKILL.md). Install upstream withnpx skills add Polyphase-Labs/Polyphase-Engine --skill polyphase-buildtarget. Copyright stays with the author.
Polyphase Build-Target Addon Skill
A build-target addon is a special kind of native Polyphase addon: instead
of adding nodes / assets / Lua bindings, it registers a
PolyphaseBuildTargetDesc with the editor that teaches the build pipeline
how to compile, cook, package, and launch a project for a new platform — all
without modifying engine source. The engine ships only the framework + seven
built-in targets (Windows / Linux / macOS / Android / GameCube / Wii / 3DS); every
other platform lives in an addon DLL that brings its own SDK code and license.
| Resource | What it covers |
|---|---|
Documentation/Development/CustomBuildTarget.md |
Full developer guide for build-target addons. Source of truth. |
Engine/Source/Plugins/PolyphaseBuildTargetAPI.h |
PolyphaseBuildTargetDesc + PolyphaseBuildContext. ABI definition. |
Engine/Source/Plugins/EditorUIHooks.h |
RegisterBuildTarget / UnregisterBuildTarget hook signatures. |
Engine/Source/Editor/Packaging/BuildTargetRegistry.h / .cpp |
Registry behaviour, deep-copy + hot-reload cleanup. |
Engine/Source/Editor/Packaging/BuiltInBuildTargets.cpp |
Reference: how the engine registers its own seven targets. |
Engine/Source/Editor/Packaging/MacBundlePackager.cpp |
Reference: a canonical platform target with a real PostPackage (.app). |
Engine/Source/Editor/ActionManager.cpp (~lines 940, 1258, 1828, 2640) |
Phase 1 cook hook, addon-target compile dispatch, PostPackage, run. |
M:\Projects\Polyphase\Addons\BuildTargets\BuildTarget-DevEnv\Packages\com.polyphase.build.target.dreamcast\ |
Reference addon — Dreamcast via KallistiOS + mkdcdisc. |
When the headers disagree with this skill, the headers win. Always read
POLYPHASE_BUILD_TARGET_API_VERSION from the live header and copy it into
your descriptor verbatim — the engine rejects descriptors with the wrong
version.
When to use this skill (vs. the others)
- Use this skill when the user wants the editor to produce a build for a platform the engine doesn't ship a built-in for: Dreamcast, PS2, original Xbox via nxdk, Xbox 360, NDS, Saturn, embedded boards, etc.
- Use
polyphase-addonwhen the user wants to register custom Node / Asset / GraphNode types, expose Lua bindings, add editor menus or panels. - Use
polyphasewhen the user wants to edit engine source (i.e. modify how Windows / Linux / 3DS itself builds).
A single addon DLL can do both — register types and register a build
target — by combining polyphase-addon and this skill.
Mental model — the six things that matter
-
Registration happens inside
RegisterEditorUI(hooks, hookId), notOnLoad. The hookId is what scopes auto-cleanup on hot-reload. CallingRegisterBuildTargetfromOnLoadwill register but never unregister. -
The descriptor is deep-copied at registration. Every
const char*in the descriptor is duplicated into an owningstd::stringinside the registry. Your descriptor's string literals can live in static memory that disappears when the DLL is unloaded — the registry survives that. -
Function pointers in the descriptor stay bound to your DLL. Only the strings are owned by the registry. When your DLL unloads, the engine wipes the whole entry; it never invokes a stale callback.
-
basePlatformis your cook-compat anchor. Pick the built-inPlatformenum whose default asset cook (Asset::SaveStream) produces bytes your hardware can ingest. For Unix-ish toolchains pickPlatform::Linux. For nxdk pickPlatform::Windows. Override per-asset withCookAssetonly when default bytes don't fit (PowerVR2 twiddle, GS palettes, NDS tiles, swizzled DXT, etc.). -
GetCompileCommandis the only required callback. Everything else is optional. Build a single shell command line into the output buffer and return non-zero. The engine runs it withSYS_ExecFullfrom the project directory and streams stdout/stderr into the Packaging window. -
PostPackageruns after the compiled binary is inpackageOutputDir. Use it to wrap into a console-native image (CDI, ISO, CIA, NDS-rom, XBE…). The wrapping tool's input is the file the engine just copied; its output goes wherever you want — typically next to it.
Quickstart — one-shot a new build target
Step 1 — pick an id and base platform
| Decision | Convention |
|---|---|
targetId |
reverse-DNS, lowercase: homebrew.dreamcast, homebrew.ps2, xbox.nxdk |
category |
"Retro Consoles", "Handheld", "Mobile", "Embedded", whatever groups well |
basePlatform |
the closest built-in (Platform::Linux is usually right) |
binaryExtension |
post-wrap if you have a wrapper (.cdi for Dreamcast), else .elf |
Step 2 — scaffold the package
Place the addon under <Project>/Packages/<your.target.id>/ (or
under a dedicated BuildTarget-DevEnv/Packages/ workspace if you're shipping
the addon as a separate repo). Use the same layout as any
polyphase-addon package: package.json, Source/<Name>.cpp,
CMakeLists.txt, optional <Name>.vcxproj, build.bat, build.sh.
The only non-standard bit is the native.buildTargets array in
package.json:
{
"name": "com.example.dreamcast",
"version": "1.0.0",
"native": {
"target": "editor",
"sourceDir": "Source",
"binaryName": "com.example.dreamcast",
"entrySymbol": "PolyphasePlugin_GetDesc",
"apiVersion": 4,
"resolveMode": "source",
"buildTargets": [
{
"id": "homebrew.dreamcast",
"displayName": "Dreamcast (KallistiOS)",
"category": "Retro Consoles"
}
]
}
}
native.apiVersion must be >= 4. Earlier versions don't have
RegisterBuildTarget and your registration call will hit a null function
pointer.
Step 3 — write the descriptor + callbacks
The minimum-viable shape is in Documentation/Development/CustomBuildTarget.md
("End-to-end minimum"). Steal that, rename, and fill in the four callbacks
your platform needs:
| Callback | What you write |
|---|---|
Validate |
env-var / file-existence check that the SDK is reachable. |
GetCompileCommand |
one shell command line that invokes your toolchain on the project. |
GetCompiledBinaryPath |
absolute path to the linker output. Defaults to project/Build/Target/. |
PostPackage |
wrap the binary into a console-native image (if applicable). |
Optional but high-value: CookAsset (for native texture/audio formats),
RunInEmulator + RunOnDevice (to wire up the editor's Build & Run buttons),
DrawProfileOptions (to expose region/disc-format settings).
Step 4 — register on load
Inside RegisterEditorUI:
#if EDITOR
static void RegisterEditorUI(EditorUIHooks* hooks, uint64_t hookId)
{
static PolyphaseBuildTargetDesc gTarget{};
gTarget.apiVersion = POLYPHASE_BUILD_TARGET_API_VERSION;
gTarget.targetId = "homebrew.dreamcast";
gTarget.displayName = "Dreamcast (KallistiOS)";
gTarget.category = "Retro Consoles";
gTarget.basePlatform = 1; // Platform::Linux
gTarget.binaryExtension = ".cdi";
gTarget.supportsEmulator = 1;
gTarget.Validate = Dreamcast_Validate;
gTarget.GetCompileCommand = Dreamcast_GetCompileCommand;
gTarget.GetCompiledBinaryPath= Dreamcast_GetCompiledBinaryPath;
gTarget.PostPackage = Dreamcast_PostPackage;
gTarget.RunInEmulator = Dreamcast_RunInEmulator;
if (hooks->RegisterBuildTarget != nullptr)
{
hooks->RegisterBuildTarget(hookId, &gTarget);
}
}
#endif
Null-check hooks->RegisterBuildTarget defensively — older engine binaries
(pre-API-v4) leave it null and your addon should degrade gracefully.
Step 5 — verify
- Drop the addon into
<Project>/Packages/. - Open the project. The Addons window shows your addon; if it surfaces
"advertised target id never registered" warnings, your
RegisterBuildTargetcall didn't fire. - File → Build Profiles. Under the target dropdown you should see your
target inside its category, marked
[addon]. Hovering showsValidate's reason if the SDK isn't present. - Pick it, click Build. Engine cooks → calls your
GetCompileCommand→ runs it viaSYS_ExecFull→ callsGetCompiledBinaryPath→ copies toPackaged/<id>/→ callsPostPackage→ done.
Patterns and gotchas
Buffer discipline
All callbacks that return a string into a caller-provided buffer (Validate,
GetCompileCommand, GetCompiledBinaryPath, RunOnDevice, RunInEmulator)
get (char* out, size_t cap). Use snprintf(out, cap, ...) (never sprintf,
never strcpy). Return non-zero on success, zero on failure. The engine
treats zero as a build failure and surfaces it in the build log.
Context lifetime
PolyphaseBuildContext* and every string field inside it are valid only
for the duration of one callback invocation. Copy out projectDir,
packageOutputDir, etc. into your own std::string if you need them past
the call. Same for userData if you assigned it during PreCook — store it
in addon-side static state, not on the context.
Hot-reload behaviour
The descriptor is registered with the hookId parameter you receive in
RegisterEditorUI. When the editor hot-reloads your addon:
- Editor calls your
OnUnload. - Editor calls
RemoveAllHooks(hookId)— your target leaves the registry. - Editor
FreeLibrarys your DLL. - Editor rebuilds your DLL,
LoadLibrarys it. - New
RegisterEditorUIruns and re-registers the target.
If your descriptor's strings live in a function-local static (static PolyphaseBuildTargetDesc gTarget inside RegisterEditorUI) they're fine — the
registry deep-copies on entry. If your strings are computed and live in
addon-side heap, free them on OnUnload so you don't leak across reloads.
Per-profile options (region, BIOS, disc format)
Use DrawProfileOptions(void* profilePtr) to draw ImGui controls inside the
Packaging panel's auto-rendered "Target Options" header. Persist values
through BuildProfile::mTargetOptions — a flat std::unordered_map<string, string>. Read them back from build callbacks via
ctx->GetProfileSetting("key", buf, sizeof(buf)).
The signature takes the build context, not a BuildProfile* — the addon
never touches BuildProfile directly, which is what keeps the ABI a pure C
surface (PolyphaseBuildTargetAPI.h):
static void Dreamcast_DrawProfileOptions(const PolyphaseBuildContext* ctx)
{
static const char* regions[] = { "NTSC-U", "NTSC-J", "PAL" };
char buf[16] = {0};
ctx->GetProfileSetting("region", buf, sizeof(buf));
int sel = 0;
for (int i = 0; i < 3; ++i) if (strcmp(buf, regions[i]) == 0) sel = i;
if (ImGui::Combo("Region", &sel, regions, 3))
{
ctx->SetProfileSetting("region", regions[sel]);
}
}
DrawProfileOptions is the only callback that can write profile settings —
Validate receives just char* outReason. The engine calls it only while the
"Target Options" collapsing header is expanded, so anything you write there lands
the first time a user opens that header and persists in BuildProfiles.json
thereafter. Don't rely on it for anything that must be set before the first
build.
Static Content / Content Pak — nothing to implement, one flag to set
Shipped packages can obfuscate their content (Static Content) and fold it
into a single archive (Content Pak). Your target gets both for free — the
decode lives in Stream::ReadFile and SYS_FileOpenRead, above the SYS layer,
so even a target shipping its own SYS_AcquireFileData inherits it. Whatever
CookAsset writes is picked up by the sweep/pack automatically. This was
verified end-to-end on the Dreamcast target with zero addon changes.
The one thing worth setting is a UI hint. Content Pak hides itself when
Embedded is on and the target gains nothing from a pak — the only thing
embedding doesn't cover is the Vulkan .spv shaders, and fixed-function console
backends compile theirs in. The engine can't infer this:
basePlatformis a cook-compat anchor, not a statement about the runtime backend. Dreamcast, PSP, PS2, PS3 and N64 all declarePlatform::Linux; Xbox declaresPlatform::Windows. ButBuildTarget-LinuxARM64andBuildTarget-AndroidTVare genuine Vulkan targets declaring the same values. Nothing in that field distinguishes them.
So if your target has no runtime shader files (i.e. it isn't Vulkan), opt in:
// In DrawProfileOptions — the only callback with a settings-capable context.
char buf[8] = {0};
if (!ctx->GetProfileSetting("polyphase.hideContentPak", buf, sizeof(buf)))
{
ctx->SetProfileSetting("polyphase.hideContentPak", "1");
}
Because DrawProfileOptions only runs while the "Target Options" header is
expanded, the flag lands the first time a user opens it and persists from then
on. Users can equally set "polyphase.hideContentPak": "1" under targetOptions
in BuildProfiles.json. It's cosmetic either way — nothing breaks if unset.
It rides the existing mTargetOptions map, so there's no
POLYPHASE_BUILD_TARGET_API_VERSION bump and no descriptor change. Omitting it
is harmless — the checkbox just appears where it isn't useful.
Two things to be aware of when your target ships content:
- Raw assets stay plain.
.mp4/.json/.png/.rcssare never obfuscated or packed, because addon code opens them with its own I/O. Only.oct,.lua, the registry, shaders and the.octpare protected. - If your runtime reads content itself, go through
Stream::ReadFile(whole file) orSYS_FileOpenRead/Read/Seek(chunked). A rawfopenwill read encrypted bytes, or find nothing once a pak has pruned the loose tree.SYS_FileSeekoffsets are in decoded space.
Full reference: Documentation/Development/StaticContent.md.
CookAsset override — when and how
basePlatform is enough for ~80% of homebrew targets. Override only when
your hardware needs a format the engine doesn't produce. Branch on
assetTypeName:
static int32_t Dreamcast_CookAsset(const PolyphaseBuildContext* ctx,
const char* assetTypeName,
void* assetPtr, void* streamPtr)
{
if (strcmp(assetTypeName, "Texture") == 0)
{
auto* tex = static_cast<Texture*>(assetPtr);
auto* stream = static_cast<Stream*>(streamPtr);
return WritePvr2TwiddledTexture(tex, *stream) ? 1 : 0;
}
return 0; // fall back to default basePlatform cook
}
assetTypeName is whatever Object::RuntimeName() returns — match the
exact class name (e.g. "Texture", "SoundWave", "StaticMesh").
Licensing isolation
This is the whole point of the framework. Keep SDK-specific code 100% inside your addon DLL:
- ✅ KallistiOS / PS2SDK / nxdk / libnds / XDK / Xbox 360 XDK headers and
libs go in your addon's
External/<sdk>/and link statically into your addon DLL. - ❌ Never
#includean SDK header from the engine. Never add SDK libs to the engine's link line. The engine binary's license is your problem to preserve.
grep -r '<sdk-token>' Engine/Source/ after your work — it should return
zero hits.
Gotchas from real ports
These are paid-in-blood findings from completing the PSP port. Most apply to any new fixed-function or fixed-pipeline platform, not just PSP.
Build system
-
A new source DIRECTORY under
Engine/Sourcebreaks every build target at once. Console/addon Makefiles enumerate engine source dirs explicitly inSOURCES— they do not globEngine/Sourcerecursively — so a directory added to the engine is silently not compiled. Adding a file to an existing listed dir is fine; adding a dir is not. The failure surfaces only at link time, as an undefined symbol, on targets nobody builds daily — long after the change that caused it.Worked example (2026-09):
Engine/Source/Engine/Utils(Sha256.cpp) was added and listed in the five built-in Makefiles (Engine/Makefile_Wii,_GCN,_3DS,_Linux,_Mac) but in none of the 11 out-of-tree build-target Makefiles. Every one of them would have failed onSha256::HashHex, becauseEngine.cpp'sForceLinkage()references it unconditionally — there is no#ifto opt a platform out.When you add an engine source dir, update all of them:
- the 5 built-ins under
Engine/Makefile_* - every
Addons/BuildTargets/BuildTarget-*/Packages/com.polyphase.build.target.*/Makefile_*— and note PSP and N64 each have two copies (BuildTarget-DevEnvplus the per-target project;BuildTarget-N64andBuildTarget-N64_2). Missing the staging copy just reintroduces the bug later.
Verify by diffing what a built-in compiles against what a target compiles, rather than eyeballing lists — the only legitimate differences should be platform-specific dirs (
Audio/Dolphin,Graphics/GX,System/Dolphin, ...):# run from the engine root; compares a built-in Makefile against a target's import re from pathlib import Path real = {p.parent.as_posix().replace('Engine/Source/', '') for p in Path('Engine/Source').rglob('*') if p.suffix in ('.cpp', '.c')} def listed(mk): t = Path(mk).read_text(encoding='utf-8', errors='replace') a = set(re.findall(r'Engine/Source/([A-Za-z0-9_/]+)', t)) # $(POLYPHASE_PATH)/... form b = set(re.findall(r'(?m)^\s+Source/([A-Za-z0-9_/]+)\s', t)) # built-in relative form return a | b gap = sorted((listed('Engine/Makefile_Wii') & real) - listed('<target Makefile>')) print(gap) # expect only platform-specific dirs (Audio/Dolphin, Graphics/GX, ...) - the 5 built-ins under
-
Regular (non-build-target) addons are NOT affected by this — don't mass-edit them. They
file(GLOB_RECURSE SOURCES "Source/*.cpp")over their own tree only and referenceEngine/Source*purely astarget_include_directories; they link against the prebuilt engine rather than rebuilding it. Verified across all 55 addonCMakeLists.txtthat mentionEngine/Source/Engine: zero compile engine sources. An addon only cares that the symbol is exported — which forSha256::HashHexis exactly what theForceLinkage()call guarantees (MSVC drops an unreferenced.objfrom a static lib, soPOLYPHASE_APIalone is not enough to produce the export). -
Verify the SDK's Makefile actually tracks header deps. PSPSDK's
build.makemits no.dfiles in this distribution — header changes don't invalidate stale.ofiles. After editing any engine header that affects struct layout (e.g. resource structs inGraphicsTypes.h),rm -f *.oin the project's intermediate dir before rebuilding, or you'll get ABI-skew crashes insideFactory_<Type>::Createcallingmemcpyto a ~null destination. -
Beware addon-copy drift. If your addon ships from a separate workspace (e.g.
BuildTarget-DevEnv/Packages/...) AND a per-target test project (BuildTarget-PSP/Packages/...), edits to one need to sync to the other. Diagnose bydiff -qbetween the twoRuntime/<platform>/trees if a rebuild seems to ignore your change. -
Makefile_PSP(or equivalent) isn't tracked as a link dep, so changingLIBS = ...doesn't trigger a relink. After a libs change, delete the staged ELF (rm <project>.elf) before rebuilding. -
Build out-of-tree —
mkdir -p <projectDir>/Intermediate/<Platform> && cdinto it before invoking make, then passPROJECT_ROOT=<projectDir>so the Makefile resolves all sources/includes/outputs through that variable. Keeps.o/.d/.elffrom polluting the project root (which would otherwise triviallygit add .into a repo). The final binary still stages out to<projectDir>/Build/<Platform>/<name>.elfvia astage:target so the engine'sGetCompiledBinaryPathworks unchanged. PSPSDK'sbuild.makemits objects to$(CURDIR)— running make from a non-project dir is how you redirect the dropzone. -
Trailing-slash trap on
PROJECT_ROOT(cost a debug session). Ifctx->projectDirends in/(it usually does), and you pass it as a make variable, thenTARGET = $(notdir $(PROJECT_ROOT))returns empty — GNU make'snotdirsplits on the final/and finds nothing after it. Result: link target becomes.elf(filename literally starts with a dot), build silently succeeds with a 10 MB orphan, and the engine's post-build check reports "invalid executable size=0" with no make error in sight. Normalise at the top of the Makefile:override PROJECT_ROOT := $(patsubst %/,%,$(PROJECT_ROOT)) override POLYPHASE_PATH := $(patsubst %/,%,$(POLYPHASE_PATH)) -
overrideis REQUIRED for command-line variables. GNU make precedence: command-linemake VAR=valbeats plain:=reassignment in the Makefile body. SoPROJECT_ROOT := $(patsubst ...)is silently ignored when the caller setPROJECT_ROOTon the make line. Withoutoverridethe normalisation above becomes a no-op and the trailing slash survives. Debug:make -p -n ... 2>&1 | grep '^PROJECT_ROOT'prints what make actually resolved. -
Make
PROJECT_ROOTresolution robust so the Makefile works under both old-stylemake -C <projectDir>AND new-stylemake -C <projectDir>/Intermediate/<Platform> PROJECT_ROOT=...invocations. Walk-up search finds the.octpmarker:ifeq ($(origin PROJECT_ROOT), undefined) PROJECT_ROOT := $(strip \ $(if $(wildcard $(CURDIR)/*.octp), $(CURDIR), \ $(if $(wildcard $(CURDIR)/../*.octp), $(abspath $(CURDIR)/..), \ $(if $(wildcard $(CURDIR)/../../*.octp), $(abspath $(CURDIR)/../..), )))) endifLets the addon DLL get updated independently of the Makefile and the build still works either way.
-
Memory budget on
make -j. psp-gcc TUs peak at 1–2 GB each compiling the engine's larger files.make -j8on a 16 GB host OOM-kills the compiler silently and leaves a partial.o. Default to-j4and expose a per-profile slider for hosts with more RAM. Other consoles have similar ceilings — measure before you choose a default.
Engine integration — fixes the engine itself may need
Every new console addon may surface engine assumptions that only triggered on built-in platforms. Catalogue what you patched in the engine so the next addon doesn't rediscover:
Engine/Source/Input/InputConstants.hneeds an#elif PLATFORM_FOOarm OR a default#elsefallback — if neither matches, the fourINPUT_*_SUPPORTmacros stay undefined (false). Then#if INPUT_GAMEPAD_SUPPORTinInputUtils::InputAdvanceFrameevaluates false, thememcpy(mPrevGamepads, mGamepads, ...)is compiled out,mPrevGamepadsstays all-zeros, andIsGamepadButtonJustDownreturns TRUE every frame the button is held. Symptom: Button widget nav cycles through every entry per single d-pad press. The engine now has an#elsefallback (gamepad-only console default) so this won't bite the next addon unless the new platform needs keyboard/mouse/touch — in which case add an explicit arm.- Force window size in BOTH
OctPreInitializeANDOctPostInitializefor any fixed-resolution console. The boot flow is:OctPreInitialize(config) ← your override goes here (belt) ReadEngineConfig() ← reloads Config.ini, CLOBBERS your override Initialize() ← copies config → EngineState OctPostInitialize() ← re-override EngineState directly (suspenders)EngineConfig::mWindowWidth/Heightdefaults to 1280×720 (not zero), so theif (config.mWindowWidth == 0)idiom never fires. Set unconditionally.Renderer::GetViewportWidth/HeightreadsEngineStatelive every frame, so theOctPostInitializere-override fixes widget sizing on the firstUpdate(). - Have
PostPackagerewriteConfig.inifor fixed-resolution consoles. The engine packager copies the project'sConfig.iniverbatim into the output dir (two copies: root +<projectName>/) andConfig.inicarries the project's desktopWindowWidth/Heightbecause that's what the editor was running at. The Oct hooks above catch this at runtime, but rewriting the packaged file at package time is the cleaner fix:void ForceFixedWindowSizeInConfig(const std::string& path, int w, int h) { // read line-by-line, replace WindowWidth=/WindowHeight= if present, // append if absent, write back } - Embedded-assets vs embedded-scripts memory budgeting. The editor
generates
Generated/EmbeddedAssets.cpp(large — tens of MB of cooked asset bytes) andGenerated/EmbeddedScripts.cpp(small — Lua source text). On consoles with tight RAM (PSP: 32 MB total), pull onlyEmbeddedScripts.cppinto the executable — assets get loaded from removable storage at runtime viaAssetManager::Discover. Wire it viaconfig.mEmbeddedScripts = gEmbeddedScripts; config.mEmbeddedScriptCount = gNumEmbeddedScripts;inOctPreInitialize.
Graphics — choosing the right matrix path
- Don't mix matrix utility libs with the raw API. On PSP,
sceGum*retroactively corrupts already-issued 3D draws even when called after the draw — even when nosceGumDrawArrayis invoked. Use only the rawsceGuSetMatrix/sceGuDrawArraypath. (Seeproject_psp_pspgum_breaks_statememory.) Test the equivalent on your platform: write the matrix-utility path first, and if 3D primitives mis-render in surprising ways, drop to the raw API. - Link the VFPU/SIMD-accelerated lib variant when the platform has one.
PSPSDK ships
libpspgum.a(FPU) andlibpspgum_vfpu.a(VFPU) — the VFPU one is what all official samples link and is the actually-tested path. Pair with-lpspvfpufor VFPU context. - glm::mat4 and platform
*Matrix4types often share layout — both 16-float column-major on GL-style platforms. A directmemcpy(&dst, &src[0][0], 64)works; no per-element conversion needed.
Graphics — display + per-frame setup
- Trust the SDK's buffer-swap state machine unless you have proof it's
broken. Manually re-emitting
sceGuDrawBuffer/ equivalent each frame caused alternating-buffer flicker because the SDK was already tracking swap state internally. - The engine's
GFX_SetViewport/GFX_SetScissormay pass non-platform dimensions —Rendererpropagates the editor's scene-tab viewport, which on a fixed handheld may not match the platform's hard-coded screen resolution. Hardcode the platform's physical screen dims in these functions; don't trust the engine inputs. - Some platforms require certain clipping state always enabled. On PSP,
GU_CLIP_PLANESdisabled silently drops every 3D primitive (not just user clip-plane ones). When in doubt, mirror the canonical sample's init sequence exactly.
Graphics — resource lifecycle + cache coherency
- Flush CPU dcache before the GPU/GE reads a resource. Platforms where
the GE reads RAM via DMA (PSP, GameCube, others) bypass CPU caches.
Call
sceKernelDcacheWritebackRange(or equivalent) on:- Vertex / index buffers after writing (in
CreateStaticMeshResource) - Texture pixels after writing (in
CreateTextureResourceAND again inBindTextureif the resource was modified) - Any matrix or uniform data the GE will consume
- Vertex / index buffers after writing (in
- Verify per-vertex stride alignment. Engine
Vertextypes are usually 4-byte-aligned (8/12/16/32 byte natural), so packed structs are fine. But mixed sizes (e.g.uint32_t color + 3×int16 = 10 bytes) may need explicit padding to a natural alignment boundary; check by drawing a triangle with N=3 vertices and inspecting whether all three rasterize correctly. - GE vertex pointers are async-consumed; per-draw transforms need a
frame-scoped ring buffer, NOT a shared scratch.
sceGuDrawArray(and equivalents on PS2, Saturn, etc.) records the vertex pointer into the GE command list and returns immediately. The GE processes commands asynchronously, only finishing betweensceGuFinish/sceGuSyncat end- of-frame. So every vertex buffer pointer handed to DrawArray must remain valid (and contain the correct data) until end-of-frame sync. A single shared "scratch buffer" that's overwritten between draws means every command in the GE list points to the SAME memory — by the time the GE processes draw #1, the scratch has whatever draw #N's writer left. Use a ring buffer reset inGFX_BeginFrame(right aftersceGuStart):
Symptom of the broken-scratch version: UI widgets rendering with vertices that belong to a different widget — e.g. Button quads rendering with vertex extents that match the last-rendered Text widget's local-space cursor coords, so the quad appears "anchored at top with bottom empty." Resist the urge to fix by writing transformed vertices back into the widget's persistent resource buffer — the next dirty cycle will re-apply the transform on already-transformed data, doubling it.static void* sUIRing = nullptr; static uint32_t sUIRingCap = 0; static uint32_t sUIRingOffset = 0; void* GetUIScratch(uint32_t bytes) { // 16-byte align each slice for DMA const uint32_t aligned = (bytes + 15u) & ~15u; if (!sUIRing || sUIRingOffset + aligned > sUIRingCap) { // grow + reset (safe — only happens before any GE work this frame) } void* slice = (uint8_t*)sUIRing + sUIRingOffset; sUIRingOffset += aligned; return slice; } void ResetUIScratch() { sUIRingOffset = 0; } // called from GFX_BeginFrame
Graphics — engine vertex layout vs platform HW layout
- The engine repacks vertices for fixed-function GPUs that mandate field
order. Engine
Vertexis(pos, uv0, uv1, normal); PSP HW demands(tex, color, normal, pos)— you cannot justmemcpyengine vertex data into the GE's expected slot. Implement aRepackVerticeshelper in your addon that converts engine layout to HW layout once at create time (Phase 2-style — eventually move to cook time for performance). - Vertex flag mask MUST match struct layout exactly. If you set
GU_NORMAL_32BITFin the mask but the struct doesn't have 12 bytes for normal after texture, every subsequent field offsets wrong → no draw.
Graphics — texture sampling state
- Always set the full texture state explicitly per bind, even if it
looks redundant. PSP defaults for
sceGuTexScale/Offset/EnvColorcan get left in unexpected states by other engine calls. Five lines of redundant set-up is cheaper than chasing "right side of texture vanishes" bugs. - Force the texture matrix slot to identity. PSP has 4 matrix slots (proj/view/model/texture). The texture matrix transforms UVs; if something left it non-identity, UVs sample off the visible texture region.
- Use
TCC_RGBnotTCC_RGBAfor the texture-color-component mode unless you genuinely need texture alpha. With RGBA, any 0-alpha pixels in the texture (image borders, padding) become invisible. - Honour the asset's filter/wrap settings. Don't hardcode
GU_LINEAR/GU_REPEATinBindTexture— readtex->GetFilterType()andtex->GetWrapMode()and map them. Pixel-art widgets (4×4 calibration patterns, sprite fonts) shipFilterType::Nearestand render as smooth colour gradients if you ignore that. Dense test cards that bilinear- average to a single colour produce baffling "fullscreen green wash" bugs that look like rendering catastrophes when really it's just LINEAR filtering a small texture over a large surface. - bufWidth alignment is mandatory for fixed pixel formats (PSP, PS2, 3DS
C3D). PSP's
sceGuTexImage(level, width, height, bufWidth, data)requires the row stride to be a multiple of 16 bytes. For PSM_8888 (RGBA8, 4 B/texel) that meansbufWidth >= 4. A 2×2 fallback white texture withbufWidth=2reads row 0 correctly but row 1 from an offset that doesn't match the actual buffer layout — the bottom half samples garbage. The visible symptom is exactly "untextured widgets render with top half opaque, bottom half empty" (GE Debugger preview shows the texture itself with row 0 valid and row 1 transparent/checkered). Minimum legalbufWidthper format:- PSM_8888 / PSM_T32: 4 texels (16 B stride)
- PSM_5650 / PSM_5551 / PSM_4444 / PSM_T16: 8 texels
- PSM_T8: 16 texels
- PSM_T4: 32 texels Pad small engine-internal textures to satisfy this. User assets are almost always large enough that the requirement is automatic.
Graphics — 2D / through mode for shaderless platforms
- "Through mode" coordinates are not normalised. PSP
GU_TRANSFORM_2Dtreats float texcoords as texel units, not normalised 0..1. Engine widgets emit normalised UVs assuming a shader maps them; on a shaderless platform a vertex UV of(1.0, 1.0)samples one texel (the one at texel coord (1,1)) and that single texel's colour fills the entire face. Symptom: every textured UI widget renders as a single-colour smear (nearest filter) or a faintly graded wash (linear). Bake the UV-to-texel multiply into the vertex buffer at draw time — typically as part of aApplyUIDrawTransformhelper that also bakes per-vertex tint and any position scale/offset.sceGuTexScaledoes NOT apply in through mode — don't waste a day trying that first. - Bake position transforms into vertices for Text widgets. The engine
builds text glyph vertices in widget-LOCAL space at the font's native
size (cursor starts at
(0, 0)at e.g. 32 pt). Vulkan/GX apply the widget rect translation + scale via shader/TEV uniform. PSP / shaderless paths must bake the equivalent into the per-vertex buffer:
Without it, every Text widget renders at top-left at font-native size regardless of its anchor.posOffset = (text->GetRect().mX + justified.x, text->GetRect().mY + justified.y); posScale = text->GetScaledTextSize() / font->GetSize(); // then vertex.xy = vertex.xy * posScale + posOffset Widget::mTransformis aglm::mat3that silently drops translation. The engine builds it asglm::mat4(translate(pivot) * rotate * translate(-pivot))then assigns to amat3member.glm::mat3(mat4)truncates to the upper-left 3×3 — losing the translation columns. GameCube'sApplyWidgetRotationreadstrans3[2][0/1]for translation but it's always zero. SomTransformonly contains rotation; using it to transform a widget would rotate around screen origin, not around the widget's pivot. Reconstruct rotation-around-pivot directly:
Fast-path theconst float rad = widget->GetRotation() * DEGREES_TO_RADIANS; const glm::vec2 pivot = (widget->GetRect().mX + widget->GetRect().mWidth * widget->GetPivot().x, widget->GetRect().mY + widget->GetRect().mHeight * widget->GetPivot().y); // for each vertex: dx,dy = pos - pivot; new = pivot + rotate(dx,dy,rad)rotRad ≈ 0case so the common (non-rotated) widget pays no sin/cos cost per vertex.
Input
InputConstants.his the FIRST thing to audit on a new platform. If there's no#elif PLATFORM_FOOarm AND no fallback#else, your gamepad's transition detection silently doesn't work (see Engine integration above). Status as of writing: engine now has a gamepad-only#elsefallback for console addons. If your platform needs keyboard/mouse/touch, add an explicit arm.- Analog stick drift requires a deadzone. Cheap analog hardware (PSP,
Vita, original Xbox controllers) rests anywhere from 100..160 raw out of
0..255 — often well above the engine's 0.5 virtual-button threshold for
GAMEPAD_L_UP/DOWN/LEFT/RIGHT. Without a deadzone, drift wobbles the virtual buttons on/off and Button widget nav reads them as a stream of presses. 0.30 normalised (~38 raw) is a reasonable starting point — wide enough to absorb typical drift, narrow enough that deliberate stick push still crosses the 0.5 virtual-button threshold:auto applyDeadzone = [](float v) { if (v > 0.30f) return (v - 0.30f) / 0.70f; if (v < -0.30f) return (v + 0.30f) / 0.70f; return 0.0f; }; - Use non-blocking peek, not blocking read for the input poll. PSP's
sceCtrlPeekBufferPositivereturns the latest sample;sceCtrlReadBufferPositiveblocks until next VBlank sample, which halves effective input rate if the render loop already VSyncs. Same gotcha exists on other consoles — check whether the SDK's "read" call is blocking and prefer the "peek" variant unless you specifically need to sync. - Invert the Y axis if the platform reports stick Y as screen-down-
positive (PSP, DS). The engine's convention is
LTHUMB_Y > 0= stick pushed UP. Without inversion the L_UP virtual button never fires on upward stick push.
Filesystem (small/console media)
- FAT 8.3 returns uppercase short names. Files like
tent.octcome back fromsceIoDreadasTENT.OCT. Asset name normalisation should only fire when no lowercase letters are present in the filename (treat all- upper as the FAT 8.3 case and lowercase it; treat mixed-case as authored-as-is). Extension comparisons MUST be case-insensitive. - Don't interleave file I/O with directory iteration. PSP's
sceIoDreadkeeps internal state that gets corrupted if another file open/read happens between calls — entries silently get skipped. Drain the entire directory into astd::vectorfirst, then iterate the vector and process files. Likely true on other consoles too — when in doubt, collect-then-process.
Diagnostics — distinguishing real bugs from emulator quirks
- Emulator display layouts can lie about aspect ratio. PPSSPP's default
"Stretch" display setting scales X and Y of the framebuffer non-
uniformly to fill the host window. An OS screenshot of the PPSSPP
window then shows visuals with the wrong aspect ratio even though the
underlying framebuffer is correct. Before debugging a "wrong aspect" bug:
- Switch PPSSPP's display layout to "Auto" or "Stretch (maintain aspect)"
- OR use the emulator's native framebuffer screenshot (PPSSPP
File → Save Screenshot) which dumps at the PSP's exact 480×272 - Compare that against the editor's
Screenshots/GamePreview_*.pngat the same resolution — only then can you say there's a real rendering bug
- Add per-vertex / per-rect debug logging gated by frame count. When a
UI widget looks wrong, log its
mRect, vertexv0, and computed scratch slice before submitting to the GE. The[UIDBG]pattern (seeGraphics_PSPGU.cpp::GFX_DrawQuad) tracks down rendering issues that would otherwise need a GPU debugger. - The platform's GE-equivalent debugger is the fastest way to localise a
texture bug. PPSSPP's GE Debugger shows the actual bound texture data
the GE sees — for the bufWidth alignment bug above, the preview pane
visibly showed a 2×2 texture with row 0 white and row 1 transparent
even though the source buffer was four
0xFFFFFFFFs. Use this BEFORE speculating about UV/filter/blend bugs. - Per-frame validation log lines should always print "before" and
"after" snapshots. When
mPrevvsmCurrentstate matters (input transitions, scratch reuse, frame counters), log both at the same call site so you can prove the value didn't get corrupted between snapshots:
Found the InputConstants bug by adding exactly this; theLogDebug("[FOO] frame=%u before: state=%d after: state=%d &state=%p", ...);before/afterdiff showedInputAdvanceFramewas a no-op on PSP.
Authoring a Variant 2 platform runtime
If the addon ships an engine runtime (System, Input, Audio, Network,
Graphics for the new platform), follow these patterns in addition to the
descriptor work:
- Place platform-extension headers at the path
platformExtensionDirpoints at. The engine writesGenerated/PolyphasePlatform_*.hbridge files that#includeyours at compile time. - Inject struct members via the documented macros:
POLYPHASE_PLATFORM_ADDON_SYSTEMSTATE_MEMBERSPOLYPHASE_PLATFORM_ADDON_DIRENTRY_MEMBERSPOLYPHASE_PLATFORM_ADDON_VOID_THREAD_RETURN(if your thread fn returnsvoid)
Runtime/<platform>/owns all SDK references — never#includean SDK header from engine source.- Logging from very early boot — write to two sinks: stdout (visible in
emulator host log) AND a file on the platform's writable media (e.g.
ms0:/PSP/GAME/<id>/<name>.log). The platform may take seconds to set up the writable filesystem; without stdout you lose pre-init crashes. - Re-write
GFX_SetViewport/GFX_SetScissorto hardcode physical screen dims for fixed-resolution platforms (handhelds). Engine input here comes from editor window state and won't match. - Phase your
GFX_*work as Phase 2 / Phase 3 / Phase 4 / etc. (see the PSP plan template). Stub everything not in the current phase to a no-op so you can compile-link-boot incrementally.
Audio analysis hook (mandatory for streaming voices)
The engine ships a platform-independent audio analysis layer
(Engine/Source/Audio/AudioAnalysis.h) that powers AUD_GetRMS,
AUD_GetLoudness, AUD_GetFrequencies, AUD_GetSpectrum and the
Audio.* / audio3d:* Lua bindings. Static-SoundWave voices (anything
played through AudioManager) work automatically on every platform —
the analysis pulls PCM directly from the asset, no backend changes
needed.
Streaming voices are different. If your Audio_<Plat>.cpp
implements AUD_OpenStream / AUD_CloseStream /
AUD_SubmitStreamBuffer (push-PCM, used by the VideoPlayer addon and
similar), you MUST add three one-line hooks or every
AUD_GetStream* / Audio.GetStream* call will return 0 on your
platform — visualizers will look "dead" while audio plays correctly.
The required hook sites (no math, no logic — feed the analysis layer the bytes that are already being submitted):
#include "Audio/AudioAnalysis.h"
uint32_t AUD_OpenStream(uint32_t sampleRate, uint32_t numChannels, uint32_t bitsPerSample)
{
/* existing backend code … */
AudioAnalysis::OnStreamOpened(streamId, sampleRate, numChannels, bitsPerSample); // ← add
return streamId;
}
void AUD_CloseStream(uint32_t streamId)
{
AudioAnalysis::OnStreamClosed(streamId); // ← add
/* existing backend code … */
}
int32_t AUD_SubmitStreamBuffer(uint32_t streamId, const uint8_t* data, uint32_t byteSize)
{
int32_t accepted = /* existing backend code … */;
if (accepted > 0)
AudioAnalysis::OnStreamSubmitted(streamId, data, (uint32_t)accepted); // ← add
return accepted;
}
No #if PLATFORM_* guards required — AudioAnalysis::* is
platform-independent and stubbed out at the call site when
AUDIO_ANALYSIS_ENABLED or AUDIO_ANALYSIS_STREAMS_ENABLED is 0.
Memory cost. Each open stream allocates a ring buffer sized by
AUDIO_ANALYSIS_STREAM_SECONDS * sampleRate * channels * (bitsPerSample/8). At defaults (1 s, 48 kHz, stereo, 16-bit) that's
~192 KB per active stream. The full memory budget is in the engine
plan (Engine/Source/Engine/Constants.h):
| Slot | Default size |
|---|---|
| Hann window LUT | ~2 KB |
| Per-voice/stream cache | ~36 KB |
| FFT scratch (on stack) | ~4 KB (no persistent) |
| Streaming ring (per stream) | ~192 KB / stream |
Suggested per-platform overrides in your Constants_<Plat>.h:
- PSP (32 MB):
AUDIO_FFT_SIZE 256,AUDIO_ANALYSIS_STREAM_SECONDS 0.25f, orAUDIO_ANALYSIS_STREAMS_ENABLED 0if you don't ship streaming visualizers. - 3DS (64–128 MB):
AUDIO_FFT_SIZE 256; defaults otherwise. - GameCube (24 MB + 16 ARAM):
AUDIO_FFT_SIZE 256, streams at0.25f. - Dreamcast (16 MB):
AUDIO_ANALYSIS_ENABLED 0is the safe default until budget audit. - Modern desktop / Android: defaults.
If your backend has no streaming at all (legacy hardware, AUD_OpenStream
returns 0 unconditionally), nothing extra is required — AUD_Get*
static-voice analysis still works because it doesn't touch the backend.
The AUD_GetStream* calls naturally return 0 for unknown stream ids.
Reference addons
| Addon | Coverage |
|---|---|
…/Packages/com.polyphase.build.target.dreamcast/ |
KallistiOS / kos-cc / mkdcdisc / lxdream + PVR2 cook hook + region opt. |
…/Packages/com.polyphase.build.target.psp/ |
PSPSDK + WSL routing + PSPGU runtime + input/UI/scripts/asset registry complete. The most thoroughly debugged reference — covers Phases 2-5 (3D rendering, UI widgets w/ rotation, Lua scripts ticking, gamepad input with transition detection, asset registry with baked UUIDs, FAT filesystem quirks, out-of-tree builds, Config.ini auto-override). Read this for any Variant-2 addon with a full runtime. |
Most new targets are a structural copy of one of these with the SDK-specific bits swapped out.
Memory references (paid-in-blood findings)
Each of these is a captured ~/.claude/.../memory/project_*.md from the PSP
port. They go beyond what fits in this skill — read the relevant one before
chasing a similar symptom on your own platform:
project_psp_force_window_size— both Oct hooks needed; Config.ini reload bugproject_psp_post_package_config_override— packager Config.ini rewrite patternproject_psp_out_of_tree_build— Intermediate/PSP layout, trailing-slash + override gotchasproject_psp_ge_async_vertex_lifetime— frame-scoped ring buffer patternproject_psp_texture_bufwidth_alignment— 16-byte stride mandateproject_psp_transform2d_texcoords_are_texels— through-mode UV semanticsproject_psp_texture_filter_must_honour_asset— don't hardcode LINEAR/REPEATproject_input_constants_console_fallback— engine#elsearmproject_psp_pspgum_breaks_state— don't mix matrix utility lib with raw APIproject_psp_dir_iter_intermixed_io— sceIoDread state corruptionproject_psp_fat_8_3_uppercase— case-insensitive ext, lowercase normalisationproject_psp_scripts_embedded_assets_disk— RAM budget splitproject_psp_makefile_no_dep_tracking— manual rm -f *.o for header changesproject_psp_make_j_oom— -j4 default for psp-gccproject_addon_validate_must_be_cached— never per-frame, especially WSL-shelloutsproject_psp_addon_active_location— addon-copy-drift warning- audio-analysis stream ring (~192 KB/stream at default 48 kHz stereo 16-bit, 1 s window) —
flip
AUDIO_ANALYSIS_STREAMS_ENABLED 0on consoles ≤ 32 MB if not using streaming visualizers
Checklist for a one-shot
Addon scaffolding
- ☐ Resolved
POLYPHASE_PATH(env /C:\Polyphase//opt/Polyphase/ walk-up forPolyphaseConfig.cmake). - ☐ Read the live
PolyphaseBuildTargetAPI.hand copiedPOLYPHASE_BUILD_TARGET_API_VERSIONverbatim. - ☐
package.jsonhasnative.apiVersion >= 4and anative.buildTargetsentry matching the descriptor'stargetId. - ☐ Descriptor registered inside
RegisterEditorUI, notOnLoad. - ☐ Null-checked
hooks->RegisterBuildTarget(older engines don't have it). - ☐
GetCompileCommandusessnprintf(out, cap, ...), returns 1 on success. - ☐
Validatereturns 0 with a user-readable reason on missing SDK, never crashes. - ☐
PostPackage(if present) cleans its own temp files. - ☐ Zero SDK references in
Engine/Source/. - ☐ Built the addon (
build.bat/ CMake) and verified it appears in the Build Profile dropdown.
Build hygiene (out-of-tree)
- ☐ Make is invoked from
<projectDir>/Intermediate/<Platform>/, not the project root. - ☐
PROJECT_ROOTandPOLYPHASE_PATHare normalised withoverride ... := $(patsubst %/,%,...)to defend against trailing-slash inputs from the addon'sctx->projectDir. - ☐ Final binary stages to
<projectDir>/Build/<Platform>/<name>.<ext>via astage:target. - ☐ Project
.gitignorecoversIntermediate/,Build/,Packaged/plus*.o *.d *.elfas belt-and-suspenders. - ☐ Default
make -jis sized for the cheapest expected host RAM (psp-gcc TUs peak at 1-2 GB;-j4is safe).
Engine integration (fixed-resolution console)
- ☐
InputConstants.hhas an arm for your platform OR you're OK with the gamepad-only#elsefallback. - ☐
OctPreInitializesetsconfig.mWindowWidth/Heightunconditionally (NOT gated on== 0— defaults to 1280x720). - ☐
OctPostInitializere-setsGetEngineState()->mWindowWidth/Heightto defeatConfig.ini's desktop-resolution clobber. - ☐
PostPackagerewrites the packagedConfig.ini'sWindowWidth/Heightto the platform's native size (both root +<projectName>/copies). - ☐ Pulled only
EmbeddedScripts.cpp(NOTEmbeddedAssets.cpp) fromGenerated/if console RAM is tight (<= 32 MB). - ☐ If shipping
AUD_OpenStream/AUD_SubmitStreamBuffer: wiredAudioAnalysis::OnStreamOpened/Closed/Submitted(3 lines). Otherwise: setAUDIO_ANALYSIS_STREAMS_ENABLED 0in yourConstants_<Plat>.hto skip ring-buffer allocation.
Runtime / graphics
- ☐ Per-draw vertex transforms use a frame-scoped ring buffer, not a shared scratch (otherwise GE-async aliasing).
- ☐ DCache writeback before any DMA-read by the GE (vertex, texture, matrix data).
- ☐ Texture
bufWidthsatisfies the pixel format's 16-byte stride requirement (PSM_8888 → bufWidth >= 4). - ☐ For shaderless 2D mode: UVs baked to texel units (not normalised); position scale/offset baked for Text widgets; rotation reconstructed from
Widget::GetRotation()+GetPivot()(NOT frommTransform). - ☐ Texture binding honours
tex->GetFilterType()/GetWrapMode()instead of hardcoding LINEAR/REPEAT. - ☐
GFX_SetViewport/GFX_SetScissorhardcode platform-native dimensions (engine input from editor may not match).
Input
- ☐ Analog stick deadzone (~0.30 normalised) applied before passing to engine — defeats hardware drift.
- ☐ Non-blocking poll (Peek-equivalent), not the blocking Read-equivalent — won't halve frame rate.
- ☐ Y axis inverted if platform reports screen-down-positive (the engine convention is +Y = up).
Filesystem
- ☐ Directory iteration drains to a
std::vectorbefore processing files (don't interleave file I/O withdread-equivalents). - ☐ Case-insensitive extension matching (FAT short names come back uppercase).
- ☐ Asset name normalisation only fires when the filename has no lowercase letters (treat all-upper as FAT 8.3, mixed-case as authored).