Building the project for consoles

Guide how to build, deploy and run Jazz² Resurrection on game consoles.

Beside the desktop and mobile platforms the game runs on ten consoles. Each of them is cross-compiled with its own SDK, most of them have a bespoke window/input backend instead of SDL2 or GLFW, and seven of them have no programmable shaders at all — so they are driven by one of six rendering backends written for their fixed-function graphics hardware (the Wii and the GameCube share one). This page covers the whole path for each one: installing the toolchain, configuring the build, packaging the result, copying it to the device and getting a log back out of it. For everything that is not console-specific see Building the project.

ConsoleToolchain (CMake toolchain file)Rendering backendWindow backendBuild artifact
Nintendo 64libdragon (cmake/toolchains/n64.cmake)RDP — fixed-functionN64jazz2.z64 (bootable ROM image)
Sega DreamcastKallistiOS (kallistios.toolchain.cmake)PVR — fixed-functionDcjazz2.cdi (bootable disc image)
Nintendo WiidevkitPPC + libogc (Wii.cmake)GX — fixed-functionOgcboot.dol (in a staged sd/ tree)
Nintendo GameCubedevkitPPC + libogc (GameCube.cmake)GX — fixed-functionOgcJazz2.dol (in a staged sd/ tree)
Nintendo 3DSdevkitARM + libctru + citro3d (3DS.cmake)PICA — fixed-functionCtrJazz2.3dsx (in a staged sdmc/ tree)
PlayStation Portablepspdev (pspdev.cmake)GU — fixed-functionPspEBOOT.PBP (in a staged ms0/ tree)
PlayStation 2ps2dev (ps2dev.cmake)GS — fixed-functionPs2jazz2.iso (bootable disc image)
PlayStation 3ps3toolchain + PSL1GHT (cmake/toolchains/ps3dev.cmake)RSX (native libgcm, shaders)Ps3jazz2.pkg (and an unsigned jazz2.self)
PlayStation VitaVitaSDK (vita.toolchain.cmake)GXM (native sceGxm, the default), OpenGL via vitaGL (ES 2.0 profile) or SoftwareSDL2jazz2.vpk
Nintendo SwitchdevkitA64 (Switch.cmake)OpenGLSDL2jazz2.nro

The rendering backend is not a choice on the first eight — NCINE_PREFERRED_RHI is pinned to the one backend the console has and any other value is a configure error. The PlayStation 3 is the exception among them: its pin is to RSX, which unlike the six fixed-function backends is a full shader backend, so it keeps the post-processing chain and differs from a desktop build mainly in when its shaders are compiled (see PlayStation 3). The PS Vita and the Switch are ordinary shader platforms that happen to be consoles, so most of what follows applies to them only in the deployment part.

What every console build shares

Three properties separate a console build from a desktop one, and they explain most of the extra steps below.

  • The host tools are not built. ShaderCompiler and AssetPacker run on the build machine, so they are skipped for every cross-compiled target. Everything they produce is either committed to the repository (the generated shader headers) or prepared by hand ahead of time (the game content).
  • The game data is converted in advance for all of them, into a content tree prepared by AssetPacker. The Nintendo 64 plays from a cartridge ROM, the Dreamcast and the PlayStation 2 play from a disc and the GameCube has nowhere to put a converted installation, so those four have no first-run conversion compiled in at all (NCINE_HAS_WRITABLE_CACHE in "Sources/Main.h") and mark their content as verified without looking at it, a build without a prepared tree reaches the main menu and finds no episode to play. The Wii, the PSP and the PlayStation 3 do have the conversion — their content sits on a writable SD card, memory stick or hard disk — but they recognize a prepared tree and skip it, and so do the Vita and the Switch, which otherwise convert on first run like a desktop build.
  • Seven of them have no shaders. The RDP, PVR, GX, GU, GS and PICA backends (the Wii and the GameCube share GX) implement each effect as a short list of fixed-function hardware passes, transpiled from the very same .shader files by ShaderCompiler — see console fixed-function blocks. Because those backends expose no shader capability, the post-processing chain (and with it the rescale filters) does not exist there, the level viewport is aspect-fitted into the console's native output instead.

Preparing the game content

The content tree is prepared with AssetPacker, a host tool built as part of an ordinary desktop build. Every data-prepared console consumes the same tree, so it is prepared once and pointed at from each console build. Only the cinematics are ever decided per console: the Dreamcast wants a profile of its own, and the Nintendo 64 a conversion of its own that also covers the sound and the music. The one other difference is the image compression: the profiles of the consoles that cannot convert on the device at all (dreamcast, ps2, gamecube and n64) write the sprite sheets and tilesets in LZ4, which their builds decode faster and which takes less space, and a generic console tree keeps the game's own format (see LZ4 sprite sheets and tilesets). The whole procedure is four steps.

1. Build the tool. It is host-only and part of the regular desktop configuration (NCINE_BUILD_ASSET_PACKER), so any desktop build directory already has it. Naming the target builds only the tool rather than the whole game:

cmake -B ./build/ -D CMAKE_BUILD_TYPE=Release
cmake --build ./build/ --target AssetPacker --parallel $(nproc)

The binary lands at "build/Utilities/AssetPacker/AssetPacker".

2. Convert. A conversion has two inputs — the original game data and the game's own content, none of which is derived from the other — and they are named separately, so nothing has to be staged or copied together first. The second of them is the repository's own "Content":

./build/Utilities/AssetPacker/AssetPacker ./build/ConsoleContent \
    --source=<path to the original Jazz Jackrabbit 2> --content=./Content --target=console

The target directory is the content directory — the tree is written straight into it rather than into a "Content" subdirectory of it, and it is created if it does not exist. A whole game installation keeps the two halves together already, in "Source" and "Content", and can be given as a single positional argument instead ("AssetPacker <installation> ./build/ConsoleContent --target=console"). A checkout is not one of those, though, and a bare copy of the original game is only half of one: convert that without "--content=" and the tree comes out with no fonts, no "Metadata" and no translations, which is a game that cannot draw so much as its menu. The conversion warns when it is about to produce one.

Which profile to pass is decided by the cinematics and by nothing else, because every console consumes the same layout:

--target=ForCinematics
consoleEvery console below except the Dreamcast (wii, gamecube and psp are accepted spellings of it and do exactly the same thing)The original .j2v files copied unchanged
dreamcastSega DreamcastRe-encoded into Jazz2::VideoFormat, whose decoder is a memcpy() — the SH-4 cannot inflate the original container inside a frame

Four options are worth knowing here, the rest are in asset-packer-usage: --originals-only keeps only the episodes the original game shipped, --shareware-only narrows that to the Shareware Demo, --skip-non-episode-levels drops the levels belonging to no episode, and --video-downscale=N (2 to 4; it re-encodes on any profile, since downscaling means re-encoding either way) shrinks the cinematics. All four exist to make the tree smaller, which is what a read-only disc and a cartridge care about.

3. Check what came out. A complete tree looks like this, and the conversion prints what it wrote as it goes:

build/ConsoleContent/Prebaked.pak          # sprites and sounds, plus "Animations" and "Metadata"
build/ConsoleContent/Episodes/…            # the episodes and their levels
build/ConsoleContent/Tilesets/…
build/ConsoleContent/Music/…
build/ConsoleContent/Cinematics/intro.j2v  # and "ending.j2v"
build/ConsoleContent/Translations/…        # the ".mo" files

"Prebaked.pak" is the one to look for — see below for why its name is what makes the tree work. A tree with "Source.pak" in its place came from the desktop profile and is not what a console wants.

4. Point the console build at it with NCINE_CONTENT_DIR:

cmake -B ./build/<console>/ … -D NCINE_CONTENT_DIR=$PWD/build/ConsoleContent

The variable is cached, so it has to be given on the first configuration of a build directory — or set again by re-running CMake on an existing one. Left unset it defaults to the repository's own "Content", which builds and boots into the main menu with no episode to play. Each console build then stages that directory into whatever it packages, under the name "Content", at the place the console looks for it:

ConsoleContent directory the game readsCache / save data
Nintendo 64"rom:/Content/" (inside the ROM's DragonFS image)"rom:/Cache/" — read-only, so nothing is ever written; the preferences live on the cartridge EEPROM ("eeprom:/JAZZ2CFG")
Dreamcast"/cd/Content/" (inside the disc image)"/cd/Cache/" — read-only, so nothing is ever written; the preferences and the resumable state live on a memory card ("/vmu/<port><unit>/Jazz2" and "Jazz2.resume" beside it)
Wii"sd:/apps/Jazz2/Content/""sd:/apps/Jazz2/Cache/"
GameCube"carda:/Jazz2/Content/""carda:/Jazz2/Cache/"
Nintendo 3DS"sdmc:/3ds/Jazz2/Content/""sdmc:/3ds/Jazz2/Cache/"
PlayStation Portable"ms0:/PSP/GAME/Jazz2/Content/""ms0:/PSP/GAME/Jazz2/Cache/"
PlayStation 2"cdfs:/Content/" (inside the disc image)"cdfs:/Cache/" — read-only, and nothing is staged there
PlayStation 3"/app_home/Content/" (next to the EBOOT.BIN, see below)"/dev_hdd0/game/JAZZ20000/USRDIR/Cache/" — writable
PlayStation Vita"app0:/Content/" (inside the VPK)"ux0:/data/jazz2/Cache/"
Switch"romfs:/" (embedded in the .nro)"sdmc:/Games/Jazz2/Cache/"

"/app_home" on the PlayStation 3 is the alias the loader maps the running executable's own directory to, which is USRDIR rather than the package root. Going through it rather than through the title's real path means the same build runs both as an installed package and straight out of the staged package directory, which is how RPCS3 boots the build tree — and the application id can change without any path following it.

What marks the tree as prepared is the package the tool writes its sprites and sounds into: a prepared tree gets "Prebaked.pak", where a cache the game converted itself keeps "Source.pak". That package also holds the game's own "Animations" and "Metadata" directories, so a few hundred small files are one file to open on media where that is the expensive part, the translations, levels, tilesets, music and cinematics sit next to it as ordinary files. Finding that file is how a platform that could convert (the Wii, the PSP, the PS3, the Vita and the Switch) knows not to — it skips the conversion, leaves the Cache directory alone and never looks for the original game files, which are not deployed with such a tree anyway. To convert on the device instead, leave the prepared tree out and put an original installation into the console's Source directory.

Three of them have nowhere to put anything at runtime, and the tree is authored into the medium they boot from rather than copied onto one: the Nintendo 64 packs it into the ROM's DragonFS image, the Dreamcast and the PlayStation 2 into a disc image. Two things follow for all three and are worth having in mind before authoring one:

  • What is on the medium is all the game will ever have. There is no first-run conversion compiled in (NCINE_HAS_WRITABLE_CACHE) and no writable Cache, so these three do not check their content at all — they mark it as verified and load it. An incomplete tree is not reported, it simply presents as a main menu with nothing to play.
  • The staging directory the image is authored from is a build artifact, and it is added to rather than replaced. Each of the three copies NCINE_CONTENT_DIR into a Content subdirectory of it on every build, so the directory on the disc is always named Content no matter what the source directory is called — but a file that has since been removed from the tree survives there and is packed into every later image. The N64 arm deletes the staging directory first for exactly that reason, the Dreamcast and PS2 arms do not: delete "<build dir>/cd/" by hand after regenerating a tree.

What is available on which console

Everything below is decided at configure time from the platform, not from a build parameter. The values are what a default Release configuration of each console produces.

FeatureN64DreamcastWiiGameCube3DSPSPPS2PS3VitaSwitch
Threads (NCINE_WITH_THREADS)NoYesYesYesYes (a pthread shim over libctru)YesNoNoYesYes
Asynchronous tracing (DEATH_TRACE_ASYNC)NoNoNoNoNoNoNoNoNoNo
Audio backendN64 (libdragon's RSP mixer)AICA (KallistiOS sound driver)ASND (libogc DSP mixer)ASND (libogc DSP mixer)NDSP (software mixer into a DSP channel)PSP (software mixer into an sceAudio channel)PS2 (software mixer into the audsrv stream)PS3 (software mixer over libaudio)OpenAL if the SDK has oneOpenAL if the SDK has one
Sound effectsYesYesYesYesYes (needs the DSP firmware, see Limits and known issues)YesYesYesSDK-dependentSDK-dependent
Module musicYes (XM64, converted ahead by the asset packer, see Limits and known issues)Yes (libxmp, see Limits and known issues)Yes (libxmp, see Limits and known issues)Yes (libxmp, see Limits and known issues)Yes (libxmp)Yes (libxmp, see Limits and known issues)Yes (libxmp, see Limits and known issues)Yes (libopenmpt, built from source, single-threaded)Yes (libopenmpt; libxmp is packaged too, see Limits and known issues)SDK-dependent
Underwater low-pass filterNo — no filter stage in the mixerNo — the driver leaves it offNo — no filter stage on the DSPNo — no filter stage on the DSPNo — no filter stage in the mixerNo — no filter stage in the mixerNo — no filter stage in the mixerNo — no filter stage in the mixerSDK-dependentYes
Local splitscreenNo — needs threadsYesYesYesYes (built, but the console has one controller)Yes (built, but the console has one controller)No — needs threadsNo — needs threadsYesYes
Online multiplayerNoNoNoNoYes (ENet only, see Limits and known issues)Yes (ENet only, see Limits and known issues)NoNoYes (ENet only, see Limits and known issues)Yes
Post-processing and rescale filtersNo — fixed-functionNo — fixed-functionNo — fixed-functionNo — fixed-functionNo — fixed-functionNo — fixed-functionNo — fixed-functionYes — the RSX has shadersYesYes
External .shader filesNoNoNoNoNoNoNoNo — no runtime compilerYesYes
Converts the game data itselfNoNoNoNoNoNoNoNoYesYes

Audio backends

Sound is structured the way rendering is: WITH_AUDIO says the audio subsystem is compiled in at all, and exactly one audio backend is compiled with it, chosen at configure time from the platform. The backend implements nCine::IAudioDevice — buffers, sources and the streaming queue — and nCine::AudioBuffer, nCine::IAudioPlayer and nCine::AudioStream contain no backend calls of their own, so a console only has to provide a device.

Each backend lives in "nCine/Audio/Backends/<backend>/", the way the rendering ones live under "nCine/Graphics/RHI/":

BackendWITH_*What it drives
ALWITH_OPENALThe system OpenAL on desktop, and the SDK's own on the Switch and Vita
ASNDWITH_ASNDlibogc's DSP mixer, the only sound API devkitPro ships for PowerPC. Comes with the toolchain, so there is nothing to install
AICAWITH_AICAThe Dreamcast sound processor through KallistiOS, using its wavetable channels for sound effects and its snd_stream driver for music
PS3WITH_PS3AUDIOPSL1GHT's libaudio. The console offers no mixer at all — only a ring of 256-sample blocks of interleaved floats that the hardware scans out — so this backend is the mixer, on the PPE
PSPWITH_PSPAUDIOOne sceAudio hardware channel. pspdev does ship an OpenAL, but the console cannot afford it (see below), so this is the SDL backend's integer mixer again — on a thread of its own here, which is the one thing that sets it apart from the others
NDSPWITH_NDSPOne channel of libctru's NDSP, the 3DS's DSP driver. The PSP backend's design again — the same mixer on a thread of higher priority than the game's, woken by the DSP's frame callback — with the one difference that the DSP resamples the channel from the mixing rate itself, so any rate the option offers is valid and no upsampling pass is needed. Needs the DSP firmware, see Limits and known issues
N64WITH_N64AUDIOlibdragon's mixer, which mixes on the RSP (the programmable half of the graphics chip) into the AI ring — the console has no sound chip at all, the AI is a bare stereo DAC fed by DMA. Sound effects stream from the cartridge (.wav64 files the asset packer writes beside the package), the music is libdragon's .xm64 player, and the cinematics' soundtracks play on channels lent to libdragon's video player — the engine itself decodes no audio there (NCINE_HAS_NATIVE_AUDIO)
PS2WITH_PS2AUDIOaudsrv, the IRX that owns the SPU2 — the sound chip is on the I/O Processor, so the only thing the EE can do with it is hand it PCM. The same mixer again, topped up from the frame like the N64's because this console has no threads; the SPU2 resamples the stream from the mixing rate in hardware, so that rate is a free choice here too
none—The silent fallback when no backend is available, or when the game is started with audio turned off

Nothing has to be installed by hand for any of them — each console's own SDK provides what its backend needs. NCINE_WITH_AUDIO is left at ON everywhere and turns the whole subsystem off when cleared.

The backends that mix on the main thread are fed once per rendered frame, so anything that blocks that thread for longer than they have queued ahead starves them — a level load above all. What starving sounds like is the hardware's business and is not always silence, so the engine announces such an operation rather than leaving each backend to discover it: nCine::IAudioDevice::beginBlockingOperation() and its counterpart are called around the level load and the episode scan, and are a no-op on every backend but the PlayStation 2's, which stops its stream for the duration (see Limits and known issues).

Two details of it are deliberate. It mixes on its output thread, double-buffered right before each block is handed to sceAudioOutputPannedBlocking(), rather than on the main thread once per frame the way the other software backends do: on this console a frame that runs long — a busy scene, a level load — is common, and a main-thread mixer with 93 ms of lead went audibly silent in exactly those scenes. The price is a lock around every source and buffer operation, and it is a kernel semaphore, not a spin lock: the mixer thread has the higher priority, so spinning on a lock the main thread holds would never let it be released. Music can still gap in such a scene, because its next chunk is decoded and queued from the main thread; only the decoder's priority could change that, at the cost of the frame rate in the same scenes.

The same reasoning decides when a level's or the menu's music starts. The stream is fed from the main thread once per frame, and the frames right after a level or the main menu is created are the ones that still load deferred resources — a stream started during that loading ran dry against it and came back with an audible gap after a second of playing, on every console. So Jazz2::LevelHandler and Jazz2::UI::Menu::MainMenu open the music while loading but start it only two frames in (_musicStartDelay), once those frames are through.

Getting a log out of a console

Every console writes the same trace output the desktop build does, but each one needs a different receiver. This is the single most useful thing to set up before debugging anything.

ConsoleWhere the trace goes
Nintendo 64stderr through libdragon's debug sinks — the ISViewer register window (ares prints it to its terminal) and the USB log of an EverDrive/SC64 flashcart, both probed once at startup. The boot console mirrors startup messages on screen until the renderer takes the framebuffer over
Dreamcastdbgio — the framebuffer console during startup (visible on screen), then the SCIF serial port once the renderer takes over. Emulators print it to their own log, dc-tool shows it in its console
Wii / GameCubeA USB Gecko on EXI channel 1 (memory-card slot B), probed once at startup, without the adapter nothing is written and the game runs normally
Nintendo 3DSsvcOutputDebugString — Azahar prints it into its log under Debug.Emulated (hidden by its default filter, see Deploying and running) and a debugger attached to the console shows it, plus the console on the bottom screen, which shows every line until the renderer takes the top screen over and warnings and errors afterwards. Jazz2.log next to the .3dsx on the SD card is written as well
PlayStation Portablestdout through sceIoWrite on fd 1 — PPSSPP prints it into its log, psplink into its console, plus the debug screen on the display until the GU session takes the framebuffer over
PlayStation 2The Emotion Engine's SIO transmit register (0x1000F180) — a bare ELF has no connected stdout. PCSX2 captures it as EE console output with EnableEEConsole on, and a serial cable picks it up on hardware. Plus the boot console on the display (PS2SDK's libdebug) until the renderer takes the Graphics Synthesizer over, which is after the disc wait, the IOP module loads and the content and memory-card probes — so a boot that stops on hardware stops with its last trace line on the television
PlayStation 3sysTtyWrite — PSL1GHT routes fd 1 and 2 straight to that lv2 syscall, so an ordinary write reaches the console TTY on hardware and RPCS3's log in the emulator. No cable and no on-screen fallback are needed
PlayStation VitasceClibPrintf — picked up by the usual host-side console tools (psp2shell, VitaCompanion)
SwitchsvcOutputDebugString — picked up by a debug logger on the host, writing the trace to a file on the SD card is forced on this platform as well

DEATH_TRACE_LOG_PATH additionally forces the trace into a file, which is worth using on the platforms with writable storage (not the Dreamcast, whose only medium is a read-only disc). Verbose I/O lines use the deferred trace level and are only flushed when an error follows them, so a sparse log is normal and does not mean files are not being opened.

The header of that file names the machine it came from, which on a desktop is the host name. A console has none, so nCine::Application::GetDeviceHostname() answers with the nearest thing it has — the nickname the console was given in its settings (Wii, 3DS, Switch), or an identifier that is fixed for the device where there is no name at all: the WLAN MAC address (PSP), the OpenPSID (PS Vita, PS3), the i.Link ID (PS2, which every console has whether or not it has the port), the 64-bit system ID the BIOS answers with (Dreamcast, through the SYSINFO syscall rather than the factory settings partition — that one holds the region code and the default language and no identifier). The GameCube and the Nintendo 64 keep nothing of the kind that software can reach, so the slot stays empty there. The same value opens the device ID the update check and the public server list send, so a console is counted once on the server side rather than as a new device on every launch.

Tile layers are indexed meshes

A tile layer goes out as one mesh rather than one draw per tile (TILEMAP_USE_SINGLE_DRAW, on everywhere except the software renderer), and so do the destructible debris and, on the backends that have shaders, the lighting quads. That mesh is indexed: a quad contributes its four distinct corners, and the two triangles that draw it come from six indices out of one pattern the whole engine shares (RenderResources::GetQuadIndices() — 0, 1, 2, 0, 2, 3 per quad, counted from the draw's first vertex, written once into an array long enough for the longest draw the streaming buffers allow, so it never moves and a render queue may hold the pointer until its draw phase). The alternative, and what this used to be, is six vertices per quad with two of the corners written out twice.

At 8 floats a vertex that is 128 bytes of corners plus 12 of indices per quad instead of 192 bytes of vertices — a third less mesh data to write and, on top of that, a third less to copy into the streaming buffer every frame. The fixed-function backends gain twice over, because there the copy is a plain memcpy into host storage that the draw dispatch then reads back, so the round trip per quad falls from 384 bytes to 268. A single draw also reaches further: with the 64 KB vertex / 8 KB index buffers a constrained platform configures, one command now covers 512 quads where the six-vertex form managed 341.

How the indices are consumed differs by tier, and it is the one place where a fixed-function backend does more work rather than less:

  • The backends with shaders (GL, GXM, RSX, D3D11, Vulkan) let the hardware pull vertices through the index buffer, which is also where the third fewer shaded vertices come from. Base-vertex addressing makes every chunk index from zero; the OpenGL|ES profiles below 3.2 have no glDrawElementsBaseVertex() and fold the base into the vertex format's offset instead (RenderCommand::Issue()).
  • The fixed-function backends (GU, GX, PVR, GS, RDP, PICA, LegacyGL) transform on the CPU and read the vertex stream themselves, so their tile-mesh dispatch resolves the indices: ResolveHostIndices() turns the draw's index range into a host pointer and a vertexAt() resolver maps a triangle's element slot to the vertex it names. Their quad recognition (which is what lets a tile go out as one GE rectangle, one GX_QUADS quad or one GS sprite instead of two triangles) is unchanged by it: the slots the six-vertex form duplicated are repeated indices here, so both forms compare the very same vertex.

Only RenderResources::GetMaxQuadsPerDraw() decides how long a chunk may be, from the two streaming buffer sizes and what a 16-bit index can address (sceGxm rejects a draw whose indices pass 63999, which is the lower of the two ceilings) — a mesh larger than that is split on whole-quad boundaries.

What a level's grids cost

A level is two dense grids over the same tile positions — the tile layers the tile map draws and the event grid the spawner reads — and together they are the largest thing a level load asks a console heap for. A large level is 768x64 tiles or more with up to eight layers behind it: at the 12 bytes of LayerTile a tile that is over 2 MB of layers, and at the 24 bytes of an event tile another 1.2 MB of events — on the Nintendo 64 that is over half of everything the game has. Two storage decisions bring that down, and both are invisible to the code that reads them.

  • The event grid is sparse. Almost every cell of it is empty — a level holds a few thousand events over tens of thousands of cells — so the grid is a uint16_t slot per cell into a dense array that holds a tile only where an event exists (EventMap EventLayout). That is two bytes a cell instead of twenty-four, 96 KB instead of 1.2 MB for the grid above. Reading an empty cell returns a shared empty tile, so every query reads as it did before; a write goes through Edit(), which gives the cell a tile of its own, and Find() distinguishes the two without creating one. A reference from Edit() is valid only until the next one, because the dense array may grow. This applies on every platform — there is nothing to trade.
  • Layers that are only ever drawn store four bytes a tile instead of twelve: a tile id, its flips and its alpha are all a background layer needs, where the sprite layer additionally carries the destructible, suspend and parameter fields the collision code writes into. The compact form is decoded on read into a small ring of scratch tiles, so Layout[i] still yields a LayerTile and a reference survives the next few lookups, which is all any reader here needs; writing one goes through Set(). Unlike the sparse events this is* a trade (a reference into a compact layer does not stay valid indefinitely), so it is enabled only where memory is the binding constraint — LayerLayout::CompactDrawOnlyLayers, today the Nintendo 64, the PSP, the Dreamcast, the 3DS and the PlayStation 2. The desktop and the larger consoles keep plain arrays.

Both grids are allocated in blocks of 4096 tiles rather than as one run, so a heap that several level loads have fragmented never has to find a contiguous megabyte, and both allocate without throwing: a level whose grids do not fit fails to load rather than aborting the process, which reaches the player as the main menu's ("Cannot load specified level!") message. That matters most where there is no virtual memory to hide behind, which is every console on this page.


Nintendo 64

The oldest and by far the tightest target: a 93 MHz VR4300 with 8 MB of RDRAM (with the Expansion Pak, which this port requires — the base console's 4 MB cannot hold the game) shared between the CPU and the graphics hardware, and an RDP rasterizer whose entire texture memory is a 4 KB TMEM that every primitive samples from. The port renders through the RDP backend ("Sources/nCine/Graphics/RHI/RDP") on top of libdragon, driving the rdpq command queue directly, presented by the N64 window backend in 320x240 RGB565 (the VI's resample filter smooths the scanout).

Toolchain

libdragon publishes a prebuilt mips64-elf GCC, so unlike the Dreamcast nothing has to be compiled except the library itself — which must come from the preview branch (the toolchain file verifies this through the OpenGL headers only that branch installs, and also that the installed libdragon.a was not built with NDEBUG — the log channels depend on symbols that build strips). The preview branch moves; when reproducibility matters, check out and record a specific revision rather than its tip:

# The prebuilt cross-compiler (also available as .deb; extract anywhere and point N64_INST at it)
curl -LO https://github.com/DragonMinded/libdragon/releases/download/toolchain-continuous-prerelease/gcc-toolchain-mips64-x86_64.rpm
rpm2cpio gcc-toolchain-mips64-x86_64.rpm | cpio -idm
export N64_INST=$PWD/opt/libdragon
export PATH="$N64_INST/bin:$PATH"

# The library and its host tools, from the preview branch
git clone -b preview https://github.com/DragonMinded/libdragon.git
make -C libdragon -j $(nproc) libdragon && make -C libdragon install
make -C libdragon -j $(nproc) tools && make -C libdragon tools-install

# zlib is required for the compressed game content and is not packaged; eleven files compile it
curl -L https://github.com/madler/zlib/releases/download/v1.3.1/zlib-1.3.1.tar.gz | tar xz
cd zlib-1.3.1
for f in adler32 compress crc32 deflate infback inffast inflate inftrees trees uncompr zutil; do
    mips64-elf-gcc -march=vr4300 -mtune=vr4300 -mabi=o64 -O2 -c $f.c; done
mips64-elf-gcc-ar rcs libz.a *.o
cp libz.a "$N64_INST/mips64-elf/lib/" && cp zlib.h zconf.h "$N64_INST/mips64-elf/include/"

Preparing the content tree

Unlike the other consoles this one does not consume the shared tree: it gets a conversion of its own, --target=n64, which turns the sound effects, the music and the cinematics into libdragon's formats with libdragon's own host tools — which is what gives the console a soundtrack at all, and what makes the whole game fit on a cartridge (see Preparing content for the Nintendo 64 for what exactly changes). Everything else in Preparing the game content applies to it:

./build/Utilities/AssetPacker/AssetPacker ./build/ConsoleContentN64 \
    --source=<path to the original Jazz Jackrabbit 2> --content=./Content \
    --target=n64 --n64-tools=$N64_INST

audioconv64 is required, videoconv64 plus ffmpeg and ffprobe encode the cinematics, and a packer built with libopenmpt pre-renders the few tracks that are not in the game's own module format and mixes the music into the cinematics' soundtracks. The sprite sheets and tilesets are written in LZ4, which the N64 build decodes (NCINE_WITH_LZ4, see LZ4 sprite sheets and tilesets). The N64 build below then takes that tree as its NCINE_CONTENT_DIR.

A tree of the plain console profile still works, but without music, and its cinematics have to be re-encoded at half size to play at a watchable rate (recompress-video --video-downscale=2: in the original format the intro costs about 136 ms per frame).

Building

export N64_INST=<toolchain prefix>
cmake -B ./build/n64/ -D CMAKE_BUILD_TYPE=Release \
    -D CMAKE_TOOLCHAIN_FILE=./cmake/toolchains/n64.cmake \
    -D NCINE_CONTENT_DIR=<n64 content tree>
make -j $(nproc) -C ./build/n64/

The toolchain file is the project's own (like the PlayStation 3's — libdragon is a Makefile-based SDK with no CMake file to point at) and reproduces the machine flags of libdragon's n64.mk. Link-time optimization is force-disabled conservatively (libdragon's --wraped constructor sequencing and its linker script have never been exercised under LTO) and Release compiles with -O2 — the fast-math triplet already comes in with the toolchain flags, and code size matters more here than anywhere else.

The build produces "build/n64/jazz2.elf" and the bootable "build/n64/jazz2.z64": a POST_BUILD step reproduces libdragon's ROM layout — the symbol table for on-console backtraces (n64sym), the stripped and compressed executable (n64elfcompress), and a DragonFS image with the content tree (mkdfs) are concatenated behind a table of contents (n64tool), and ed64romconfig marks eeprom16k in the header so emulators and flashcarts provide the 2 KB EEPROM the preferences are saved to, and a Rumble Pak in the first controller. The content is staged first by "cmake/n64_stage_content.cmake", which leaves out what the console cannot play (the original music formats, the original of a cinematic that has a video). The real-time clock is deliberately not declared: an EverDrive cannot provide it together with EEPROM, and the game does not need one.

Deploying and running

  • Emulator — "jazz2.z64" boots directly in ares or simple64; both default to an Expansion Pak. ares also prints the trace (ISViewer) to its terminal.
  • Real hardware — copy the ROM onto an EverDrive-64 or SC64 flashcart; the save type is read from the header. An Expansion Pak must be installed.
  • New content without the toolchain — the content of a finished ROM can be replaced with the asset packer alone, AssetPacker swap-content jazz2.z64 --content=<n64 content tree> (see Replacing the content of a disc image).

Limits and known issues

  • Everything is memory. 8 MB of unified RDRAM is half the Dreamcast's main memory with no separate video or sound RAM to hide anything in — the framebuffers, every texture, every sound and the heap all share it, and about 6.3 MB of it is heap. The port keeps the strictest budgets of any console: episode backgrounds are not loaded, debris caps are half the other consoles', the outgoing level's assets are released before the incoming level loads, and the lightmap is combined at a twelfth of the output resolution (27x20 texels; every other console's map is a quarter or a sixth).
  • Fragmentation is the harder half of that. After a few levels and returns to the menu the heap has several megabytes free and no large contiguous run left in it, so what fails is not the last allocation but the first big one — which used to be a whole tile layer, a whole event grid or a whole tileset atlas. The port asks for none of those as one block any more: the grids come in 4096-tile blocks with the sparse and compact forms of the section above, the atlas is built and uploaded one chunk of at most 256 KB at a time rather than materialized whole, a direct-colour texture is converted straight into its RDP store instead of keeping a second full-size host copy, and every one of them fails soft: a level that does not fit returns to the menu with a message instead of aborting. Animations are all deferred here (the metadata is parsed, the sheets are read on first use), with the few that would otherwise hitch in play read behind the loading screen.
  • The binary is the other half of the budget. 1.9 MB of the 8 MB is the executable itself, and what is left after it is the heap, so code size and level size come out of the same place. Three parts of the C++ runtime used to be linked in for nothing and were cut, which gave the heap 0.55 MB (5.8 MB to 6.3 MB). The bundled JSON library composed its assertion messages and parsed its numbers through string streams, which instantiated the whole <locale> facet machinery — 350 KB, most of it wide-character facets nothing here can reach — so it formats with fputs() and parses with strtod() instead. libstdc++'s default std::terminate handler names the exception being propagated, which links the C++ demangler into a build that has exceptions disabled and can only reach terminate through a pure-virtual call, so the port defines that handler itself ("nCine/MainApplication.cpp"). And the compiler's own type information is dead weight where every cast goes through runtime_cast<T>(), which walks hierarchies annotated by hand, so the game target is compiled without RTTI everywhere — on the desktop too, where it costs nothing to do so (DEATH_USE_RUNTIME_CAST, which brings the tables back when it is turned off; a dependency built from sources keeps them either way). The last two are shared with the Dreamcast and the PlayStation 2, where they are worth 82 KB of the same kind.
  • What the heap gets back on top of that. The display is double buffered rather than triple — a third buffer only pays when a frame takes about one refresh period, and one here takes three or more, so it was 150 KB standing idle (measured: it changed the frame time by 0.4%, inside the noise). And the tileset atlas is stored without the one-pixel border of duplicated edge pixels every other platform keeps (TileSet::TilePadding), which is another 75 to 84 KB: the border exists so that a sampler reaching outside a tile reads that tile's own edge, and here the tile layer is drawn 1:1 with point sampling, so no sample can land outside. Any platform that scales the layer or samples it bilinearly still needs it.
  • Music plays from converted modules, not from the originals. Neither libopenmpt nor libxmp fits: a decoded module's runtime state plus its streaming buffers cost between 0.5 MB and 4 MB of the console's 8 MB (measured over the shipped tracks). The asset packer instead translates the game's Galaxy Music System modules into libdragon's .xm64, whose player keeps only the patterns in memory and streams the (VADPCM-compressed) instruments from the cartridge, and it pre-renders the three tracks in other formats into compressed recordings. All of the game's .j2b modules are the kind with one sample per instrument and no envelopes, so the translation loses nothing structurally (checked against libopenmpt renders of the originals: 0.80–0.96 spectral correlation, the remaining difference is libxm's interpolation and volume ramps).
  • Audio costs about 3 ms of CPU per frame in a level with a 16-channel module and sound effects playing (mixer_try_play, measured in ares with libdragon's profiler: the module player's tick ~0.7 ms, the rest the per-round fetch and command setup of the playing channels; the mixing itself runs on the RSP). What keeps it there is that every streamed channel is VADPCM: libdragon's mixer fetches at most ~120 samples of a raw PCM stream per round, so a single raw effect used to cut every round of the frame that short, and every playing channel pays for every round - with the effects in raw PCM, music and effects together cost 6.4 ms (and whole frames ~6 ms more, the RSP being busy with nine mixing rounds instead of three). The Huffman stage of VADPCM is left off too, because the CPU would have to undo it (0.4 ms a frame). Uncompressed instruments are worse still: they multiply the cartridge reads.
  • A paused sound effect starts over when it resumes. The mixer has no paused state, so pausing the game (or an actor going quiet) gives the effect's channel up, and resuming it is a seek. A VADPCM stream can only be restarted on a frame its file saved the decoder state for, and the effects are converted without such seek points, so the seek goes to the start — libdragon asserts on any other position ("invalid VADPCM seeking point", which is what resuming the game right after picking up a gem used to crash with).
  • The ROM must fit in 64 MB — the ceiling of the flashcarts and of the PI bus mapping. A tree prepared with --target=n64 does with room to spare: the whole soundtrack and both cinematics included it is 39 MB (the two cinematics are 12 MB of it, the music 8.8 MB, the tilesets 8.2 MB in LZ4 - see LZ4 sprite sheets and tilesets - the sound effects 3.7 MB and the package 2.9 MB). The packaging step fails the build if the image ever outgrows the cartridge, rather than producing one whose tail is unreachable on hardware.
  • The cinematics are MPEG-1 video played by libdragon's player, which decodes on the CPU and the RSP together and paces itself by its soundtrack. It owns the main thread while it plays, so the game hands it the display and two mixer channels between two frames. The player feeds the mixer only twice per frame and the mixer tops up one 16 ms buffer per call while the RSP is busy decoding, which starved the soundtrack to about 0.8x speed and let the picture finish seconds ahead of it; the port therefore tops the audio up once more per frame and holds a frame back while the soundtrack is behind it ("nCine/Backends/N64/N64FullMotionVideo.cpp").
  • Level loading was 3.65 s for castle1 and is about 2.4 s now (in ares), most of the difference being work that was done twice or through too many calls: the tileset atlas is built once, after the level's tile usage is known, instead of being built whole and then rebuilt pruned; the layers are read in blocks rather than two stream reads per tile; and the image decoder no longer rehashes the pixels its own index operations hand it. The tileset image is LZ4 now (0.18 s to decode castle1's instead of 0.29, see LZ4 sprite sheets and tilesets), which leaves the metadata the events ask for as the largest part.
  • Where a level frame goes (castle1, walking, music and sound effects, ares, NCINE_PROFILING; ~25–27 ms in all, before the integer paths below): building the RDP commands 9.5–12 ms (tile layers about half of it, sprites most of the rest), the scene visit 4.6–5.7 ms (tile map 2–3, HUD 1.3–1.6, lighting 1.0), actor updates 3.8–5 ms, audio ~3.4 ms, collisions and cameras 1.4–3 ms. The frame is CPU-bound - the RDP is never waited on. A tile's TMEM window is derived from the same fixed-point texel coordinates its rectangle is drawn with; derived from the floats, an atlas that is not a power of two wide made half the windows 33 texels wide, too big for their 1 KB slot, and sent them through the synchronized upload path. The upload itself is autosynced by libdragon (it has to be, see InitializeRdp in the RDP device), so fewer uploads is what makes it cheaper, not fewer syncs.
  • Tiles and sprites reach the RDP as integers. A tile of a layer mesh, cached or streamed, is one 16-byte record — its position, its top-left texel in the atlas chunk, its alpha and its flip bits (RHI::RDP::TileRecord) — drawn as PrimitiveType::Points, a form only this backend is given. The two float corners a tile took before had to be scaled back to texels and rounded onto the RDP's fixed-point grid for every tile; the record already holds the integers the rectangle command takes, so what is left per tile is its position through the view transform, the window check and the command. A sprite whose texel window fits TMEM goes the same way: its corners are rounded onto the grid once, and the window, the steps and the command all come from those integers (SubmitSpriteRect() in the RDP device). That includes the HUD icons, which are drawn scaled and filtered. A point-sampled mirrored sprite now starts one 1/32-texel step inside its far edge, as mirrored tiles already did: started on the edge itself, its first column sampled outside the window, was clamped back onto the last texel, and showed that texel twice while the first never appeared. Measured on the castle1 walk with the draw statistics switched on, so the absolute numbers run a little high: tile layers 7.3 to 4.4 ms, the whole RDP dispatch 10.6 to 7.3 ms, and the frame 29.5 to 25.3 ms. Most of that is the tiles. A sprite went from about 50 to 40 us, and most of what is left is its TMEM upload. Compare release builds, not builds with probes added or removed: code layout alone moved the tile figure by 2 ms between otherwise identical runs.
  • The preferences live on the cartridge EEPROM through libdragon's eepromfs ("eeprom:/JAZZ2CFG", 2 KB). An emulator or flashcart that ignores the header's save type leaves the game running with no way to save, which is warned about at startup, not an error.
  • The FPU's exception traps are masked at startup. libdragon enables the overflow, divide-by-zero and invalid-operation traps when it is built without NDEBUG, which is how its prebuilt library ships (and what the toolchain file checks for, because the log channels need it). The game's arithmetic assumes IEEE behaviour like everywhere else — a "nearest player" search starts from FLT_MAX and squares the distance to it, which is an infinity on every other platform and was a CPU exception here — so the port clears the enable bits in FCR31 during initialization and keeps only the flush-to-zero bit the VR4300 needs for denormals.
  • The video filters are already at their cheapest. The console's video interface can run an anti-aliasing pass and a de-dither pass while it scans the framebuffer out, and both cost RDRAM bandwidth that the CPU and the RDP would otherwise have. Neither is enabled here: the display is opened with FILTERS_RESAMPLE, which is bilinear resampling with AA and de-dither off, and the RDP writes undithered pixels as well (rdpq_set_mode_standard() selects SOM_RGBDITHER_NONE). The de-dither filter could not be used at this resolution in any case — the hardware supports it only above 320 pixels of width — and going one step further to no resampling at all (VI_AA_MODE_NONE) hits a documented hardware bug on NTSC consoles at exactly this width, for a saving that is not where the bandwidth goes.
  • TMEM is per-primitive, not per-texture. Every textured primitive samples through a 4 KB window (2 KB for paletted texels — the palette occupies the upper half) that the backend uploads before the draw, so the cost of a frame tracks how many distinct texture windows it touches, not how many pixels it covers. The tile map renders one 32x32 tile per upload with consecutive same-tile runs coalesced, and a sprite whose frame is larger than a window is drawn as bands of rows through the same machinery rather than through libdragon's blitter. Consecutive windows alternate between two halves of that space, each with a tile descriptor of its own (TILE0 and TILE1). A two-cycle draw — the colorized text — also samples through the descriptor after its own, which for TILE1 is one nothing else programs and which a console powers up with random contents (emulators zero it), so the backend gives every tile it does not use a harmless descriptor at startup. The same draws skip alpha compare on the screen, where the hardware evaluates it a pixel off in two-cycle mode, and keep it only in a render target, whose alpha bit is the coverage every write stores.
  • A hung RDP reports itself on the screen. A command the RDP chokes on does not fault, the chip just stops, and libdragon's own report of that goes to the debug log, which a flashcart without USB cannot deliver — while the emulators do not reproduce these hangs at all. So the backend watches the RDP from the VI interrupt, and once it has been busy on the same command for 100 ms it replaces the picture with its registers, the state commands last sent to it and the command words around where it stopped, and halts. A photo of that screen is enough to tell which command it was.
  • Additive blending is alpha-over here. The RDP's blender computes its additive form without a clamp, so a sum past white wraps to black instead of saturating — libdragon's own header calls the mode broken, and it is. Every additive pass therefore maps to the alpha-weighted form, which over a dark scene is very close to the intent (at the low alphas the glows actually use, the two differ by a few percent of an already dark destination). Effects that genuinely need summing accumulate their passes on the CPU and go out as one draw, so they are unaffected; a bright shot over a bright wall is the case where the difference shows.
  • The classic main menu background is drawn differently. The non-Reforged menu repeats each of its three tiles 96 times across one rotated quad far larger than the screen, which this hardware cannot express: texel coordinates are 10.5 fixed point (+-1024 texels) and the largest of the three tiles is 16 KB of paletted texels against a 2 KB budget. The port draws the same motion with different geometry — the two small tiles as one rotated view-sized quad each, wrapped by the tile descriptor, and the large one once per period — so it animates as it should without matching the original pixel for pixel. The large tile is what the menu costs: it cannot be resident, so each of its quads is blitted in eight TMEM-sized chunks of two rotated triangles at roughly 450 us, and the smaller the tile is drawn the more quads the view holds. Its zoom is therefore narrower here than the original's (0.75 +- 0.05 against 0.6 +- 0.2), which holds the count at 14 to 17 instead of swinging it between 12 and 41 — a steady 40 ms menu frame in place of one that swung between 36 and 51.
  • It runs, but not smoothly. This is the one target whose CPU is far below what the game was written for — an order of magnitude under the next-slowest console here — and it shows: a level frame measures between about 28 and 45 ms in ares depending on the level and what is on screen (roughly 22 to 36 frames per second, where an untuned build was 55 ms), so treat this port as experimental rather than as a finished one. The numbers above for the cinematics (55 ms per frame for a decoder that is little more than memcpy()) are the honest scale of the machine. Where the frame goes, measured rather than guessed: roughly a third in the RDP dispatch (of which the rdpq calls themselves are the smaller part — most of it is CPU work per primitive), a sixth in the tile map, and the remainder spread over actor updates, collisions, the HUD and the software lightmap. The two structural wins so far were giving each tile layer a cached mesh of its visible window (rebuilt only when that window moves by a whole tile, which for a slow parallax layer is rare, with animated tiles kept out of it and re-resolved every frame) and a packed record per tile in place of its vertices, cached and streamed alike (see above); both are N64-only and sit behind the ordinary tile-mesh path. The debris shares that mesh shader but keeps the indexed four-corner form, because a spinning particle's quad is not a rectangle and cannot be described by a position and a texel corner.
  • Sprites got the same treatment. Nearly every sprite of a frame is drawn by one generated effect — a single modulate pass carrying the instance colour, shared by the sprite defaults, their batched forms and both palette remaps — and the general dispatch built a 600-byte effect context, a pass descriptor, four corners and a merge slot per instance only to fold all of it back into the two corners and the one primitive colour the RDP takes. That effect is recognized once when the program is linked (RdpShaderProgram::DispatchFacts::PlainSprite), and an axis-aligned, textured instance of it then goes straight from its instance block to a textured rectangle. The shortcut is exact rather than an approximation: the pass carries no offset colour, no screen offset and no blend of its own, and its colour is saturated before it is packed, so the two-cycle doubling combiner the general path reaches for above 1.0 cannot come up. A rotated or untextured instance keeps the general path. Measured on the same level scene, with the same 41 instances a frame: 33.5 ms to 27.9 ms per frame (30 to 36 frames per second; the sprite loop itself 9.4 ms to 7.2 ms).
  • Text is drawn without shadows. Everywhere else each string goes out twice — a faint black copy offset under it, then the text itself — and on this console every glyph is a textured rectangle with its own TMEM upload, so the shadows were close to half of what any text cost. Font::ShadowsEnabled is off here and every shadow draw is guarded by it, so none of that work is done at all (only the text shadows — the handful of sprite shadows under the HUD icons and the logo carrot stay): 118 ms to 74 ms a frame on the first-run screen, the most text-heavy one, and 38 to 28 ms on the main menu. A level's HUD has little text and gains about a millisecond.
  • Glyphs go straight into batches. A glyph used to be a render command of its own, filled in the visit, then sorted and copied into a batch by RenderBatcher — three passes over a few hundred commands that were cold in the 8 KB data cache every time, about 80 us a glyph before the RDP saw it. Font::DrawString() now writes each glyph's instance straight into a batched command through the canvas's per-font, per-shader, per-layer records (RenderBatcher::BeginDirectBatch()), which leaves about 30 us a glyph: 65 ms to 51 ms a frame on the first-run screen. Before that, a render command had also been leaving its visit order uninitialized, and a canvas never sets one, so same-layer commands of the same material sorted apart and hardly batched at all — fixing that alone took the same screen from 74 to 65 ms. The main menu and a level are paced by the RDP and the display at about 25 ms, so there the CPU time saved shows up as time spent waiting for a framebuffer rather than as a shorter frame.

Sega Dreamcast

The tightest optical-disc target and the second-oldest overall: a 200 MHz SH-4 with 16 MB of main memory, 8 MB of video memory and a PowerVR2 (CLX2) that has no programmable shading, no hardware scissor and reads textures in 16-bit formats only. The port renders through the PVR backend ("Sources/nCine/Graphics/RHI/PVR") on top of KallistiOS, presented by the Dc window backend in 640x480 RGB565.

Toolchain

KallistiOS is built from source together with its sh-elf cross-compiler, there is no prebuilt package.

git clone https://github.com/KallistiOS/KallistiOS.git kos
cd kos

# Build the sh-elf (SH-4) cross-compiler
make -C utils/kos-chain

# The sample configuration matches the paths the toolchain just used
cp doc/environ.sh.sample environ.sh
source environ.sh
make -j $(nproc)

# zlib is required for the compressed game content and comes from kos-ports
git clone https://github.com/KallistiOS/kos-ports.git ../kos-ports
make -C ../kos-ports/zlib install

Edit "utils/kos-chain/Makefile.cfg" first if the compilers should not be installed into the default prefix, and "environ.sh" afterwards so KOS_BASE, KOS_CC_BASE and KOS_PORTS point at the tree that was just built.

To get a bootable disc image, mkdcdisc has to be on PATH as well:

git clone https://gitlab.com/simulant/mkdcdisc.git
cd mkdcdisc
meson setup build && ninja -C build
sudo cp ./build/mkdcdisc /usr/local/bin/

Preparing the content tree

A disc is this console's only medium. There is no first-run conversion compiled in, no writable Cache and nothing to copy a tree onto afterwards — what is authored into the image is the whole of what the game will ever have, and the build is what authors it, so the tree has to exist before the build runs. Prepare it as in Preparing the game content, with one difference specific to this console:

# 1. The tool (any desktop build directory already has it)
cmake -B ./build/ -D CMAKE_BUILD_TYPE=Release
cmake --build ./build/ --target AssetPacker --parallel $(nproc)

# 2. Convert, with the Dreamcast profile - NOT "--target=console"
./build/Utilities/AssetPacker/AssetPacker ./build/DreamcastContent \
    --source=<path to the original Jazz Jackrabbit 2> --content=./Content --target=dreamcast

# 3. Prebaked.pak is what makes it a prepared tree; the cinematics have to be the re-encoded ones
ls ./build/DreamcastContent

Two more things this console does not forgive, both because it never gets a second chance at runtime:

  • The tree must already carry "Prebaked.pak". Unlike the PlayStation 2 and Nintendo 64 packaging steps, the Dreamcast one does not rename a "Source.pak" on the way in, so a desktop profile cache handed to it produces a disc the game marks as verified, loads nothing from, and shows a main menu with no episode on. Check for the file, not for the directory.
  • Trim the tree before authoring, not after. Nothing can be added to or removed from the image once it is written, and --originals-only, --skip-non-episode-levels and --video-downscale=N are the knobs for it. Note that Music has to stay — unlike on the PlayStation 2, this console does play it (through libxmp, see Limits and known issues).

Building

source <path to KOS>/environ.sh
cmake -B ./build/dreamcast/ -D CMAKE_BUILD_TYPE=Release \
    -D CMAKE_TOOLCHAIN_FILE=${KOS_BASE}/utils/cmake/kallistios.toolchain.cmake \
    -D NCINE_CONTENT_DIR=$PWD/build/DreamcastContent
make -j $(nproc) -C ./build/dreamcast/

NCINE_CONTENT_DIR is a cached variable and has to be passed on the first configuration of the build directory (or set again by re-running CMake on it) — left out it defaults to the repository's own "Content", which reaches the main menu and has no episode to play. Link-time optimization is force-disabled for this platform because it makes the sh-elf GCC 15.2 abort, and Release compiles with -O2 rather than the default -O3 — code size matters here, and DEATH_USE_FAST_MATH is never applied on this platform because fast-math reordering is untested on the SH-4.

The build produces "build/dreamcast/jazz2.elf" and, if mkdcdisc was found, the bootable "build/dreamcast/jazz2.cdi" with the content tree included as "/cd/Content". The disc is authored by a POST_BUILD step that does two things:

# 1. Stage the tree under a directory literally named "Content"
cmake -E copy_directory <NCINE_CONTENT_DIR> ./build/dreamcast/cd/Content
# 2. Author the image from the ELF and that directory
mkdcdisc -e ./build/dreamcast/jazz2.elf -d ./build/dreamcast/cd/Content \
    -n "Jazz2 Resurrection" -o ./build/dreamcast/jazz2.cdi

The staging step exists because mkdcdisc puts the directory it is handed onto the disc under its own name, and the game looks for "/cd/Content/". That is what lets NCINE_CONTENT_DIR point at a directory called anything at all — and it is also what makes a hand-written mkdcdisc invocation against "build/DreamcastContent" produce a disc with a "/cd/DreamcastContent/" on it that the game never finds.

Changing the content of a finished image

Because the content is authored into the image, changing it normally means building the image again — with the KallistiOS toolchain and mkdcdisc at hand, which is the one step of this that cannot be done anywhere else. It does not have to be:

./build/Utilities/AssetPacker/AssetPacker swap-content ./build/dreamcast/jazz2.cdi \
    --content=./build/DreamcastContent

swap-content opens a finished image, keeps its bootstrap and its executable exactly as they are (1ST_READ.BIN is copied across still scrambled) and writes it back out around a different Content, which is what a --target=dreamcast conversion produced. It takes no toolchain, runs on any platform the packer does, and gives back an image the same size as the one it was handed whenever the new content fits in the space the disc already has — which it usually does, since the image is mostly the empty run that pushes the files out to the edge of the disc. The directory named by --content= becomes Content on the disc whatever it is called here, so the staging step above has no counterpart. See Replacing the content of a disc image for what it does to the file system, and note that it takes the image apart rather than patching it, so nothing of the staging directory is added to, never cleared trap applies to it. The PlayStation 2 image is handled by the same command.

Deploying and running

  • Emulator — "jazz2.cdi" boots directly in Flycast, lxdream or redream, no further preparation.
  • Real hardware — burn the CDI image with a tool that understands the Dreamcast's multi-session layout, or serve it from an optical-drive emulator such as GDEMU/MODE.
  • dc-tool over serial or a Broadband Adapter uploads "jazz2.elf" for a fast edit-run cycle, but the game reads its content from "/cd/Content/" — so a disc with the content still has to be present.

To read the log in Flycast, set Debug.SerialConsoleEnabled = yes in "flycast/emu.cfg" while the emulator is not running — it rewrites that file on exit and can silently revert the setting, which looks exactly like a game that died before main(). Startup messages go to the on-screen framebuffer console, not to serial, so a crash during initialization has to be read off the screen, the guest's serial output is block-buffered, so a crash also loses everything written since the last flush.

Limits and known issues

  • The logical view is 640x480, not the 720x405 bound the 16:9 platforms use. These panels are 4:3 and 480 lines tall, so the shared bound would have fitted a 540x405 view into them and let the hardware stretch it by a sixth; the raised one reaches the panel exactly, which is one less resampling stage and sprites drawn at the size they are shown at. It is about 1.4x the pixels, so it is also the first thing to turn down through Rendering Resolution in Options > Graphics if the frame rate matters more (see Jazz2::Rendering::UpscaleRenderPass::DefaultViewWidth).
  • Main memory is the binding constraint. The heap window between the loaded ELF and the top of RAM is roughly 13.8 MB. Exhaustion is not a clean error — it appears in the log as Out of memory. Requested sbrk_base … followed by std::bad_alloc and an abort, which the emulator reports only as a CPU exception. Video memory has its own message, Out of PVR memory allocating …, and the render-to-texture variant of it names the target size.
  • The engine keeps several console-only budgets for this reason: live debris particles are capped (bursts are coarsened rather than truncated, so an explosion still looks like one), the outgoing level's assets are released before the incoming level loads, and the host copies of PVR textures are kept RLE-compressed. On top of the budgets the user has the "Particle Quality" option in Options > Graphics (Jazz2::ParticleQuality, on every platform): Low leaves out every other particle of a burst and halves the weather density, Off drops the debris and weather effects entirely, and High is the unrestricted effect; every producer in Jazz2::Tiles::TileMap honours it.
  • Audio costs almost nothing in main memory here, which is worth knowing on a console whose heap is the binding constraint: the AICA has its own 2 MB of sound RAM and every fully loaded sound effect lives there, not on the 13.8 MB heap. What main memory does hold is the module decoder, the three 16 KB buffers a stream is refilled through, and the few sounds too long for a channel (see below). Measured in Flycast, a level of the first episode has about 100 sounds resident in 1.2 MB of the 1.86 MB of sound RAM the driver leaves, and the heap sits at 3–5 MB after a level has loaded and around 7 MB while it plays with its music; the memory line the port writes to the log after every level load (Memory (level loaded): heap … VRAM … sound RAM …, see nCine::Backends::DcPlatform) is where to read the numbers of a particular level. Deferring sound effects to their first play, as the Nintendo 64 does, would therefore free no main memory here and would put a GD-ROM seek into the middle of gameplay instead.
  • Module music plays through libxmp, not libopenmpt (NCINE_WITH_XMP, on by default here) — and that decoder is the one audio allocation big enough to matter on the heap above. Measured over this game's tracks, one loaded module costs libopenmpt 4.6-12.5 MB against libxmp's 0.5-4 MB; the larger levels have to fit in the same 13.8 MB, and with libopenmpt they ran it out. libopenmpt does play the whole soundtrack here and can be put back with -D NCINE_WITH_XMP=OFF (it is compiled from source either way — KallistiOS packages neither library), at the price of the levels that then no longer load. What libxmp costs instead is the four .mo3 tracks, which it cannot read, and slightly less exact playback.
  • The two kinds of player use two different parts of the sound processor, see nCine::AicaAudioDevice. A sound effect is one AICA wavetable channel (two for a stereo sample, since a channel is mono — the sample is de-interleaved into one block per channel when it is uploaded), and when it has finished is predicted from its length and rate, not read back: the only state the hardware exposes is the channel's key-on bit, which the console never clears by itself when a one-shot sample runs out (emulators do, which is how a source pool that ran dry after ten seconds of play went unnoticed in Flycast). Music goes through KallistiOS's snd_stream driver, of which there are only four.
  • A channel addresses at most 65534 samples from wherever it is pointed at, which a long sound effect can exceed (about 3 seconds of 22 kHz audio). A sound that fits after one halving of its sample rate is played that way, as the intro's two-second effects at 44 kHz are: nCine::AicaAudioDevice::uploadBuffer halves it and says so in the log, and since the hardware resamples every channel anyway the sound still plays to its end at the right pitch and only loses some high end. A sound that would have to be halved again** (the sugar rush jingle is 21 seconds, two of the intro's effects are over three seconds at 44 kHz) is kept in main memory instead and played through one of the four snd_stream handles, fed from there while it plays, so it is heard whole and at its full rate — the price is its size on the heap (464 KB for the sugar rush), that the handles are shared with the music, and that, like any stream, it cannot change pitch once started. Only when that memory cannot be had is such a sound halved more than once.
  • 8-bit samples are converted to signed on upload. WAV keeps them unsigned, the AICA plays them as two's complement, and a sample uploaded as it is comes out with every zero crossing turned into a full-scale step — loud crackling over the whole effect. Almost every sound of the original game is 8-bit, so this is what "all sound effects are distorted" means on this console.
  • Panning and distance attenuation are computed on the SH4 and folded into the channel volume and pan, because the hardware has no notion of a listener. Each channel does have a low-pass filter, but the KallistiOS driver switches it off and exposes no command to reach it, so nothing is muffled underwater.
  • Module music is rendered at 22050 Hz rather than at the output rate: the AICA resamples every channel in hardware for free, so a higher rate would only cost the SH4 more time in the module mixer — the one place this console has no headroom to spare.
  • A stream is resampled at the rate it was started with and the driver cannot change it afterwards, so pitch changes apply to sound effects only.
  • Streams are stopped for the duration of a load. The driver's ring buffer is topped up once per frame from the main thread, holds a third of a second, and is looped by the channel while it waits for more — so a level load off the disc, which blocks that thread for several seconds, would play the last fragment of the music over and over until it is done (the PlayStation 2's sound module does the same, see nCine::IAudioDevice::beginBlockingOperation). The device stops every stream when a load begins and starts it again where it was when the load ends, which makes a load silent rather than grinding. Sound effects on channels are unaffected.
  • Textures are 16-bit (ARGB4444, or palettized with the hardware's palette banks), the PVR has no hardware scissor (clipping is geometric), and there is no post-processing tier, so the rescale filters and the lightmap's shader path are unavailable — the lightmap is combined on the CPU instead.
  • Saving needs writable storage that a disc is not. Preferences and the resumable state go to the first attached VMU ("/vmu/a1/Jazz2" and "/vmu/a1/Jazz2.resume" for the card in the first port's first slot). KallistiOS buffers such a file whole and commits it when the handle closes, so it is written like any other file. With no card inserted there is nowhere to save, which is warned about at startup rather than treated as an error. The level cache has no home here either way — it is read from the disc.
  • Both save files carry a VMS header, which is what the console's file manager lists a save by: a short and a long description and a 32x32 icon. A file without one is shown as unusable data that can only be deleted, which is what these two were before (see Jazz2::PreferencesCache::DescribeNextMemoryCardFile — KallistiOS attaches the header itself when the file is closed, so it is set before the file is opened). The header costs two extra blocks per file of the card's two hundred. The icon is drawn in "Sources/Icons/Dreamcast/Icon.ico" (32x32 at 4 bits per pixel, fifteen colours and transparency — an animated icon is not supported, because the VMU's frames would all have to share one palette) and converted into the layout the header wants at configure time by "cmake/ncine_dreamcast_icon.cmake", which slices it out of the file's own bytes and needs no image tool on the host. Replacing that file is the whole of changing the icon.
  • Online multiplayer is disabled, local splitscreen works.

Nintendo Wii

A 729 MHz PowerPC (Broadway) with 24 MB of fast MEM1 and 64 MB of MEM2, and the Hollywood GPU — a fixed-function design with a 16-stage TEV combiner. The port renders through the GX backend ("Sources/nCine/Graphics/RHI/GX") on libogc, presented by the Ogc window backend, which adopts whatever video mode the console prefers (PAL 50 Hz or NTSC/EDTV 60 Hz, interlaced or progressive).

Toolchain

# Install devkitPro's pacman, then the Wii group and zlib
sudo dkp-pacman -S --needed wii-dev ppc-zlib

export DEVKITPRO=/opt/devkitpro

ppc-zlib is not part of the wii-dev group but is required for the compressed game content. The toolchain file is "$DEVKITPRO/cmake/Wii.cmake".

Building

cmake -B ./build/wii/ -D CMAKE_BUILD_TYPE=Release \
    -D CMAKE_TOOLCHAIN_FILE=${DEVKITPRO}/cmake/Wii.cmake \
    -D NCINE_CONTENT_DIR=$PWD/build/ConsoleContent
make -j $(nproc) -C ./build/wii/

Beside "build/wii/jazz2.dol" the build stages the SD card layout the Homebrew Channel expects, so its contents can be copied to a card as they are:

build/wii/sd/apps/Jazz2/boot.dol
build/wii/sd/apps/Jazz2/Content/…

Deploying and running

  • Real hardware — copy the contents of "build/wii/sd/" to the root of an SD card or USB storage device and launch Jazz² Resurrection from the Homebrew Channel. An "apps/Jazz2/meta.xml" is optional, without it the entry is listed under its directory name.
  • Dolphin — build a FAT32 image from the same staging tree and enable the virtual SD card:
truncate -s 512M sd.raw && mkfs.vfat -F 32 sd.raw
mcopy -i sd.raw -s ./build/wii/sd/apps ::/
# Dolphin.ini: [Core] WiiSDCard = True, WiiSDCardPath = <path to sd.raw>
flatpak run org.DolphinEmu.dolphin-emu -e ./build/wii/jazz2.dol -b

For the log, set a USB Gecko in memory-card slot B (SlotB = 7 in Dolphin.ini) and read the TCP socket Dolphin opens for it on port 55020. The adapter is probed once at startup, so a build without one behaves normally and simply writes nothing. Frame dumps are the practical way to check rendering: DumpXFBTarget = True in "GFX.ini" writes every displayed frame as a PNG.

Limits and known issues

  • The logical view is 640x480 on a 4:3 set, not the 720x405 bound the 16:9 platforms use — the panel is 480 lines tall, so the shared bound would have fitted a 540x405 view into it and let the hardware stretch it by a sixth. It is about 1.4x the pixels, which Rendering Resolution in Options > Graphics scales back down. A Wii whose system settings say the television is widescreen is a different case and unaffected by the bound: it renders the same 640x480 and asks the set to stretch it, so the view is fitted to 16:9 instead (640x360 — see nCine::Backends::OgcGfxDevice::displayAspect(), which reads CONF_GetAspectRatio(); without it a 4:3 composition was 33% too wide there).
  • Module music plays through libxmp, not libopenmpt. Both play the whole soundtrack, but they do not cost the same: measured over this game's tracks, one loaded module costs libopenmpt 4.6-12.5 MB against libxmp's 0.5-4 MB, and neither devkitPro SDK packages libopenmpt, so that path also compiles the entire library into the binary (the GameCube .dol is 5.6 MB with it and 4.3 MB without). On a console with 24 MB and no second pool (see Limits and known issues; the Wii shares this port and its content) that decides it, so NCINE_WITH_XMP is on by default here. What it costs is the four .mo3 tracks, which libxmp cannot read, and slightly less exact playback; -D NCINE_WITH_XMP=OFF goes back to libopenmpt.
  • Sound goes through libogc's ASND, not OpenAL — devkitPro ships no OpenAL for PowerPC. The DSP mixes up to 16 voices at 48 kHz and resamples each one, so the voice count rather than CPU time is the limit, that is also the source count the engine advertises, well below the 64 it uses on desktop. Panning and distance attenuation are computed on the CPU and folded into the per-voice volume, because ASND has no notion of a listener — see AsndAudioDevice::computeVolume().
  • ASND holds only two buffers per voice (one playing, one queued) where a streaming player expects an OpenAL-style queue, so the queue itself is kept in the backend and fed into the voice one buffer at a time from nCine::AsndAudioDevice::updatePlayers.
  • Sample data is read by the DSP straight out of main memory by DMA, so every buffer is 32-byte aligned and padded. Unlike the Dreamcast, there is no separate sound RAM — a level's sound effects come out of the same heap as everything else, which is worth remembering on the GameCube in particular.
  • There is no filter stage on the DSP, so nothing is muffled underwater.
  • Online multiplayer is disabled, local splitscreen works.
  • No post-processing tier, so no rescale filters, the lightmap is combined on the CPU.
  • Textures are converted to the GX formats (including CI8 with a hardware TLUT for palettized ones) and the effect passes are expressed as TEV stages with swap tables and a KONST colour, all generated from the .shader files.
  • Palette data is byte-swapped for the big-endian CPU on load — worth knowing when comparing a palette dump with the desktop build.

Nintendo GameCube

The Wii's predecessor and the same code path: a 486 MHz Gekko and the Flipper GPU, driven by the same GX backend and Ogc window backend. The one difference that matters in practice is memory — 24 MB of main RAM and no MEM2, which makes it the tighter of the two PowerPC targets — and that its storage is an SD card in a memory-card slot.

Toolchain

sudo dkp-pacman -S --needed gamecube-dev ppc-zlib

export DEVKITPRO=/opt/devkitpro

As on the Wii, ppc-zlib is not part of the console's package group. The toolchain file is "$DEVKITPRO/cmake/GameCube.cmake".

Building

cmake -B ./build/gamecube/ -D CMAKE_BUILD_TYPE=Release \
    -D CMAKE_TOOLCHAIN_FILE=${DEVKITPRO}/cmake/GameCube.cmake \
    -D NCINE_CONTENT_DIR=$PWD/build/ConsoleContent
make -j $(nproc) -C ./build/gamecube/

The staged layout differs from the Wii's, because the GameCube has no Homebrew Channel and reads the card directly:

build/gamecube/sd/Jazz2/Jazz2.dol
build/gamecube/sd/Jazz2/Content/…

Deploying and running

  • Real hardware — copy the contents of "build/gamecube/sd/" to an SD card in an SD Gecko (memory-card slot A) or an SD2SP2 adapter, and start "Jazz2/Jazz2.dol" from Swiss. The game reads its content from "carda:/Jazz2/Content/", which is slot A by definition.
  • Dolphin — attach an SD adapter to slot A and point it at a FAT32 image built exactly like the Wii one above. Keep slot B free for the USB Gecko if the log is needed, the game writes its trace to EXI channel 1, which is slot B.

Limits and known issues

Everything in Limits and known issues applies, with two additions:

  • Memory is much tighter than on the Wii — 24 MB total, with no second pool to fall back on. The console budgets described for the Dreamcast are active here as well, and audio is one more claim on that pool here: the ASND backend keeps every decoded sound effect in main memory for the DSP to read by DMA.
  • There is no Bluetooth, so only GameCube controllers are read (the Wii build additionally links wiiuse and bte for Wii Remotes).

Nintendo 3DS

A 268 MHz ARM11 MPCore (804 MHz with the L2 cache on a New 3DS, which the game asks for at startup) with 64 MB of application memory on the original model (the New 3DS has more, and the first line of the log reports how libctru split what it got between the heap and the GPU-visible linear heap) and a DMP PICA200 driven through citro3d. The port renders through the PICA backend ("Sources/nCine/Graphics/RHI/PICA"), presented by the Ctr window backend on the top screen in the console's native 400x240. The bottom screen is a text console: it shows every startup message until the renderer takes over, and warnings and errors afterwards.

The PICA200 is a fixed-function part with one programmable stage — its vertex shader — and here that stage is a passthrough ("Sources/Shaders/Pica/Sprite.v.pica", assembled by picasso and embedded at build time). Everything the other consoles do on the CPU, this one does on the CPU as well: the quads are transformed before they reach the GPU, the effects come from the pica fixed-function tables ShaderCompiler generates (see Fixed-function blocks) and are realized as texture-combiner programs, and the lighting is the software lightmap multiplied over the scene. What the GPU does have, unlike the GE or the RDP, is a 6-stage texture combiner — so the tint mix of the transition and the 2x/4x output scales of the combine passes are single-pass combiner setups here.

Toolchain

devkitPro publishes everything through its pacman repository: the 3ds-dev group (devkitARM, libctru, citro3d, the 3ds-cmake toolchain files and the 3dstools, picasso and bin2s the packaging step runs) and three ports: 3ds-zlib for the compressed content, 3ds-curl and 3ds-mbedtls for the update check and the online server list:

sudo dkp-pacman -S --needed 3ds-dev 3ds-zlib 3ds-curl 3ds-mbedtls

export DEVKITPRO=/opt/devkitpro
export DEVKITARM=$DEVKITPRO/devkitARM
export PATH=$DEVKITPRO/tools/bin:$DEVKITARM/bin:$PATH

Root is not actually needed. The packages are plain tarballs ("https://pkg.devkitpro.org/packages/", the dkp-libs and dkp-linux databases list them) and extract with bsdtar into any prefix; DEVKITPRO then points at the opt/devkitpro inside it and nothing else cares. The one trap: the server answers a generic download client with 403 and expects pacman's own User-Agent (pacman/6.1.0 (Linux x86_64) libalpm/14.0.0 works).

The CMake toolchain file is "$DEVKITPRO/cmake/3DS.cmake". It defines NINTENDO_3DS and __3DS__, links through 3dsx.specs, and provides the packaging helpers ctr_add_shader_library(), ctr_generate_smdh() and ctr_create_3dsx() the build uses. Two things it brings along are worth knowing about:

  • Threads come from devkitARM itself. libsysbase, which 3dsx.specs links, implements POSIX threads over the lock and condition-variable hooks libctru fills with its LightLock and CondVar, so the plain find_package(Threads) succeeds and NCINE_WITH_THREADS stays on. What it does not have is sched_yield() (provided by "Sources/nCine/Backends/Ctr/CtrLibcCompat.cpp" as a zero-length sleep), the scheduling-parameter calls and thread affinity — the engine leaves the latter alone here, and the application sees a single core anyway: the second one is the system's.
  • libctru's sub-headers are plain C without extern "C" — only the umbrella <3ds.h> wraps them. Anything that includes <3ds/svc.h> or <3ds/synchronization.h> on its own from C++ (the spinlock does, to stay a header) has to wrap the include itself, or the symbols come out C++-mangled and the link fails.

Module music is libxmp, built from source by the configure step like on the other fixed-function consoles — libopenmpt is out of the question on this CPU.

Building

export DEVKITPRO=/opt/devkitpro
export DEVKITARM=$DEVKITPRO/devkitARM
export PATH=$DEVKITPRO/tools/bin:$DEVKITARM/bin:$PATH
cmake -B ./build/3ds/ -D CMAKE_BUILD_TYPE=Release \
    -D CMAKE_TOOLCHAIN_FILE=$DEVKITPRO/cmake/3DS.cmake \
    -D NCINE_CONTENT_DIR=$PWD/build/ConsoleContent
cmake --build ./build/3ds/ --parallel $(nproc)

Like the PSP and the Wii, the console cannot convert the original game data in any reasonable time and is fed the tree the AssetPacker's console profile prepared ahead (see Preparing the game content); without it the build falls back to the repository's Content, which boots into the menu but has no level to load.

The build produces "build/3ds/jazz2.elf", "build/3ds/jazz2.3dsx" and "build/3ds/jazz2.smdh" (the icon and titles the Homebrew Launcher shows), and stages the SD card layout the game expects under "build/3ds/sdmc/":

build/3ds/sdmc/3ds/Jazz2/Jazz2.3dsx
build/3ds/sdmc/3ds/Jazz2/Jazz2.smdh
build/3ds/sdmc/3ds/Jazz2/Content/…

The game reads its content from "sdmc:/3ds/Jazz2/Content/" and writes the converted cache, the configuration, the saves and Jazz2.log next to it (see Jazz2::ContentResolver).

Deploying and running

  • Real hardware — copy "build/3ds/sdmc/3ds/Jazz2" onto the SD card as "/3ds/Jazz2" and start it from the Homebrew Launcher; that needs custom firmware or another way to run unsigned homebrew. 3dslink -a <console IP> build/3ds/jazz2.3dsx sends a fresh build to a console with the launcher's network loader open, the content has to be on the card already.
  • Azahar (the continuation of Citra) boots the .3dsx directly: azahar -w build/3ds/jazz2.3dsx. Its virtual SD card is "user/sdmc/" (for the flatpak "~/.var/app/org.azahar_emu.Azahar/data/azahar-emu/sdmc/"), and the staged "3ds/Jazz2" tree has to be copied in there — like PPSSPP, the emulator resolves "sdmc:/" against its own card, not against the directory the .3dsx was loaded from. Networking needs nothing: the emulated soc:u runs on the host's own sockets.

The game's trace goes to svcOutputDebugString(), which Azahar prints into "user/log/azahar_log.txt" under the Debug.Emulated class — hidden by the default filter; log_filter=*:Info Debug.Emulated:Debug in its qt-config.ini shows it. The Jazz2.log on the virtual card is written too, and unlike PPSSPP's memory stick the emulator flushes it as the game writes. Two renderer notes for the emulator:

  • Its OpenGL renderer requires OpenGL 4.3 and its Vulkan renderer a real Vulkan driver. On a machine that has neither (a virtual machine with a 4.1 virtual GPU, for one) Mesa's llvmpipe is the answer: LIBGL_ALWAYS_SOFTWARE=1 in the emulator's environment gives it a 4.5 context in software, and the game runs at full speed in it.
  • The emulator's own software renderer (graphics_api=0) is correct but a scalar per-pixel rasterizer: the menu renders at about one frame per second in it. Good for a screenshot, useless for anything timed.

Limits and known issues

  • The GPU samples textures bottom-up. Texture coordinate v = 0 reads the last row in memory and v = 1 the first — the convention tex3ds encodes for, and the reason Citra's rasterizer flips t — so the texture stores write the source's first row last (PicaTexture::BuildPage()). A render target needs no flip of its own either: the rasterizer writes clip-space y = -1 into the row v = 0 samples, as OpenGL does, so the render-target projection (PicaDevice::ApplyDrawTarget()) sends a pass's first raster row there. The target is then top-down in texture coordinates like on every other backend (see nCine::RHI), and the padding of a target whose size is not a power of two lies below the image instead of shifting it.
  • No palette textures. The PICA200 has no colour lookup, so the engine's R8 and RG8 stores (the palette-indexed sprites) are baked into RGBA4 on the CPU per palette row, two baked rows per texture being kept (PicaTexture::EnsureBakedStore()). A sprite that is drawn with three or more palette rows in one frame rebuilds its store every draw and the log says so once.
  • Textures are 8..1024 texels, powers of two, 8x8 Morton-tiled, in the linear heap. Bigger textures are paged the way the GE backend pages them, and a store whose real size is not a power of two does not repeat correctly, because the hardware wraps at the padded size — the same limitation as on the PSP, and the same content rule: repeating textures are powers of two. Every store and vertex buffer has to be flushed with GSPGPU_FlushDataCache() before the GPU reads it; the device does so per batch.
  • 16-bit colour everywhere. The panels are 16-bit, so the screen target is RGB565 and every texture is RGBA4 or RGB565; the lightmap is quantized to 4 bits per channel as well. The resulting banding is the console's, not the port's.
  • The frame is one 400x240 view on the top screen, without stereoscopic 3D, and nothing is drawn on the bottom screen but the console. There is no rendering-resolution option. The "Sample Rate" option in Options > Sounds is there, as on the PSP: the mixer's cost is linear in the rate, and the DSP resamples the channel to its own 32728 Hz whatever it is, so the presets run up to that.
  • Text is entered through the system's software keyboard. libctru's swkbd is a library applet: it takes both screens over, reads the pad itself and blocks the thread it is called on until the dialog closes, and it must not run while a citro3d frame is open. MainApplication::ShowScreenKeyboard() therefore only notes the request, and UpdateScreenKeyboard() runs the applet from the frame loop once the frame has been presented — the game pauses underneath for the duration — then hands the edited string back to the field that asked for it (a player name, a highscore entry, a server address), the same replace-not-append contract the PSP's and the Vita's dialogs follow. The press that closes the applet is still held when the game resumes, so the input backend reports the pad as idle until every button has been released once.
  • A mid-frame clear splits the command list. C3D_RenderTargetClear() is a memory fill the GPU's transfer engine runs, not a command in the list, so the device calls C3D_FrameSplit() before it (see PicaDevice::Clear()) or the fill would race the draws already queued; a scissored clear is drawn as a quad instead.
  • Online multiplayer works, over ENet only. libctru's BSD sockets (soc:u) come with getaddrinfo() and poll(), so unlike on the PSP the vendored enet.h needed arms only for the two calls libctru has no spelling of at all: sendmsg() and recvmsg(), which go through sendto() (the buffers gathered into one datagram first) and recvfrom(). ENET_IPV6=0 selects ENet's IPv4 arm as on the Sony handhelds — there is no in6_addr anywhere in libctru's headers — and WITH_WEBSOCKET is off for the Vita's reason: IXWebSocket includes "netinet/ip.h", which libctru does not have. What the service does need is a 1 MB buffer of its own, page-aligned and lent to it for the life of the process: CtrPlatform::Initialize() hands it over with socInit() before anything can open a socket and Shutdown() takes it back. There is no counterpart to the PSP's access-point handling — the WLAN is the system's business on this console, and a console that is offline simply fails to connect. Verified under Azahar against an ENet peer on the host: the emulator puts soc:u straight onto the host's own sockets, so a server on 127.0.0.1 is the host and nothing has to be set up for it.
  • HTTPS needs the CA bundle staged next to the content. devkitPro's libcurl is built on mbedTLS and looks for its trust store at "sdmc:/config/ssl/cacert.pem", which nothing on a fresh card provides, so the build fetches the bundle at configure time and stages it as "Content/cacert.pem", where ApplyPlatformWebRequestOptions() points libcurl — the PSP's and the Vita's arrangement. Without it the update check and the public server list fail to verify with an mbedTLS -0x3E00 read error naming that default path. The console's clock is real time (unlike pspdev's, see Limits and known issues), so certificate validity periods check out without help. One thing to know when porting further: WebRequest.cpp picks its libcurl backend by an explicit list of targets, and a target missing from it gets a session with no backend at all — the update check then does nothing and logs nothing.
  • Asynchronous tracing is off for the PSP's reason: the log on the SD card and the emulator's log have to be complete when the process dies.
  • The lighting approximation and the trigonometric shortcut (sinApprox(), see the PSP notes) are enabled with the PSP's values and have not been tuned against hardware — no console was available to this port, everything above was verified in Azahar.

PlayStation Portable

A 222 MHz (raised to 333 MHz at startup) MIPS Allegrex with 32 MB of memory (24 MB usable, 64 MB on the 2000/3000 models) and a Graphics Engine driven through sceGu. The port renders through the GU backend ("Sources/nCine/Graphics/RHI/GU"), presented by the Psp window backend in the console's native 480x272.

Toolchain

pspdev ships prebuilt for Linux, macOS and Windows, can be built from source with its build-all.sh, and is also published as the pspdev/pspdev container image (which is what the CI workflow uses):

# Prebuilt SDK (see https://github.com/pspdev/pspdev/releases)
tar -xJf pspdev-<platform>.tar.xz -C ~/sdk

export PSPDEV=~/sdk/pspdev
export PATH=$PSPDEV/bin:$PATH

Everything the game needs is in the SDK itself — zlib, libvorbis and an OpenAL Soft whose output backend drives sceAudio. Further ports are installed with psp-pacman. The CMake toolchain file is "$PSPDEV/psp/share/pspdev.cmake", the psp-cmake wrapper passes it automatically, and $PSPDEV/bin must stay on PATH because the packaging step invokes psp-fixup-imports, mksfoex and pack-pbp from there.

Building

export PSPDEV=~/sdk/pspdev
export PATH=$PSPDEV/bin:$PATH
cmake -B ./build/psp/ -D CMAKE_BUILD_TYPE=Release \
    -D CMAKE_TOOLCHAIN_FILE=$PSPDEV/psp/share/pspdev.cmake \
    -D NCINE_CONTENT_DIR=$PWD/build/ConsoleContent
make -j $(nproc) -C ./build/psp/

Release compiles with -O2 for the same reasons as the other consoles — the Allegrex FPU is single-precision only.

The build produces "build/psp/jazz2.elf" and stages the complete memory-stick layout under "build/psp/ms0/":

build/psp/ms0/PSP/GAME/Jazz2/EBOOT.PBP
build/psp/ms0/PSP/GAME/Jazz2/Content/…

The EBOOT.PBP is packed by create_pbp_file() from the stripped ELF with MEMSIZE=1 in its SFO, which asks the firmware for the extra memory of the 2000/3000 models, a PSP-1000 keeps its 24 MB.

Deploying and running

  • Real hardware — copy "build/psp/ms0/PSP/GAME/Jazz2" onto the memory stick at exactly that path. Requires custom firmware or another way to start unsigned homebrew.
  • PPSSPP — copy the same directory into the emulator's configured memory stick, then start it from the game list.

The game's own trace reaches the emulator's log at the info level, prefixed with "I[PRINTF]: stdout:", so --loglevel=4 --log=FILE is the way to read it. The Jazz2.log written next to the EBOOT is only flushed on a clean exit and stays empty when the emulator is killed. For the flatpak, the log path has to be somewhere the sandbox can write — pass --filesystem=<dir> for it, or the option looks like it does nothing.

The application raises the CPU to 333 MHz itself and installs its HOME-button callback before anything else can fail, so the console always has a way out even if initialization goes wrong.

Limits and known issues

  • The FPU is configured not to trap on IEEE exceptions, and that is load-bearing. PspDisableFpuTraps() (MainApplication.cpp) clears the FCSR enable bits (bits 7-11, plus the cause and sticky-flag fields) at startup and again on every thread the engine starts, because FCSR is per-thread context. C and C++ default to non-trapping exceptions - 1.0f / 0.0f is infinity and execution continues - and nothing in the engine installs an FP handler or reads fetestexcept(), so nothing wants the traps. Leaving them armed made every division in the codebase somewhere this console could die, invisibly, for reasons no other target shows: see the entry below for the one that was found the hard way. Divisions whose divisor comes from level data are still guarded individually (Jazz2::Actors::Solid::Bridge, whose width and height factor are both event parameters that may be zero), because an infinity propagating into geometry is a bug everywhere, not just here.
  • A floating-point division by zero was fatal here, and is silently harmless everywhere else. The Allegrex raises it and the process is gone in the same instant, taking every thread with it; the firmware then shows a frozen screen for about ten seconds and reports C1-2858-3 on a Vita running Adrenaline. Nothing is written to the log, because the thread that would write it is already gone. What did this was one line of the HUD, which resolved an animation's current frame as animTime * FrameCount / AnimDuration — correct for an animation, a division by zero for a static image, which legitimately has no duration. The weapon wheel's dim backing sprite (UI/dim.aura, a single 16x16 frame) is one, so opening the wheel killed the game every time. On a desktop the division yields infinity, the % FrameCount that follows hides it, and the frame comes out as 0 anyway — which is exactly why no emulator and no other platform ever showed it. Jazz2::GraphicResource::GetFrameForTime() is now the only place that arithmetic lives, and it returns the first frame when there is nothing to animate; every caller (the HUD, the loading handler, the menu background, the multiplayer lobby) goes through it, and ActorBase::ActorRenderer::UpdateAnimation() guards the same division for a looping animation of no duration.

    The general lesson for this port: an expression that is merely sloppy on a desktop can be terminal here, and it will not leave a diagnosis behind. It was found by making the weapon wheel enable its parts in stages and logging each one, which is worth remembering as a technique — no amount of logging can localize a fault that stops the machine, because the log dies with it.

  • A GE store must not be freed while a display list still references it (a latent hazard found while chasing the above, not its cause). Commands are written into a list that is only kicked at sceGuFinish() in GuDevice::PresentFrame(), so between a draw being recorded and the GE reading it there is a whole frame's worth of window — and a level load creates, replaces and drops textures right in the middle of it, while the loading screen is being presented. GuTexture used to std::free() a store from three of those points (the reallocation in RefreshGeStore(), FreeGeStores() when Allocate() re-initializes a texture, and the destructor), handing the memory back to the allocator before the GE had ever fetched from it. When the list finally ran, the texture base was whatever had been allocated there since: garbage at best, an address the GE cannot fetch at worst, and then it never signals, sceGuSync() never returns, and the firmware kills the process about ten seconds later — C1-2858-3 on a Vita running Adrenaline. It presents as a freeze rather than a crash and writes nothing to the log, because the thread that would have written it is the one that is stuck. GuDevice::SyncBeforeStoreRelease() now closes the batch, kicks the open list and waits for the GE before any of those frees; the next draw opens a fresh list through EnsureList(). This was fixed on its own merits — it is a genuine use-after-free by the GE — but it was not what the weapon wheel was dying of. Note that building the store eagerly at load time — which is what ReleaseHostCopy() does to make the host texels redundant — is what made this frequent: it turns one lazy build on first draw into a hundred and eighty mid-frame allocations and frees per level load.
  • Main memory is the other thing this console runs out of. The executable takes the whole user partition as its heap (PSP_HEAP_SIZE_KB(-1)), so the heap is whatever is left after the binary and its .bss, and a level with every sprite resident leaves a few hundred kilobytes of it. That is thin enough for a single streaming buffer to be fatal: ~90 line-strip meshes in one frame push the shared vertex buffer past the 64 KB it holds, nCine::RenderBuffersManager allocates a second one, and there may be no 64 KB left to give it. Nothing said so — with -fno-exceptions, a failed operator new ends in std::__throw_bad_alloc(), which is a bare abort(), and the process vanished with an empty log. nCine::Application::InitCommon() now installs a std::set_new_handler that writes a fatal line first, which is the only reason such a failure is diagnosable at all — on any of these consoles, since they all build without exceptions. Two sizings were reduced to buy headroom back: the GE display list (below) and the four half-pixel shadow segments of the weapon wheel, which this backend does not need (see Jazz2::UI::HUD::DrawWeaponWheel()).
  • Palette-indexed fonts are not dropped when the palette changes. Jazz2::ContentResolver drops cached fonts on a palette change so a baked atlas can rebake, but with RHI_CAP_PALETTED_TEXTURES a font atlas holds indices and resolves its colors from the live palette texture at draw time, so there is nothing to rebake. Reloading one cost a second 128x529 texture (a 67 KB host copy plus a 69 KB GE store plus the decode buffer) during level load, with a whole level already resident. Jazz2::UI::Font::IsPaletteIndexed() is what that decision now consults.
  • The GE display list is 32 KB, and its sizing is measured rather than assumed. The busiest frame observed — the weapon wheel open over a level, 87 draw calls — fills 3.7 KB of it, so the 128 KB this used to reserve was 96 KB of a main memory the game has none of to spare. Nothing in sceGu bounds-checks the list, and an overflow would be a silent write past the array, so GuDevice::PresentFrame() reads the size sceGuFinish() returns and warns once if a frame ever passes three quarters of it.
  • A line strip is expanded into coverage quads, not drawn as GE lines. The GE does have a native single-pixel line primitive and the weapon wheel — the one thing in the game that draws a line at all — went out as one GU_LINE_STRIP for exactly that reason. It now goes out as a triangle pair per segment, half a pixel to each side of the line and widened by the slope's Manhattan factor, the same expansion the PowerVR uses (see Limits and known issues): it puts the wheel on the path every other draw already takes, batches into the frame's other triangles instead of taking a draw call of its own, and is why the half-pixel shadow copies above can be skipped here.
  • Module music plays through libxmp, not libopenmpt. The game's soundtrack is tracker modules, and while libopenmpt does cross-compile, play correctly and cost only ~0.2 FPS in steady state, the first module a process loads costs a fixed ~29 seconds inside the library on this CPU (every later load takes ~1.8 s) — most likely because the Allegrex has no double-precision unit. There is nowhere to hide a half-minute freeze on a handheld, so NCINE_WITH_OPENMPT is forced off here and NCINE_WITH_XMP is on by default instead: libxmp's mixer is integer throughout (its only double-precision arithmetic is the resonant-filter coefficient setup, which runs per filter change rather than per sample), and it reads the game's original .j2b modules directly. What it does not read is MO3, so the four .mo3 tracks are silent; everything else plays.
  • Everything is mixed at 22050 Hz here, not the hardware's 44100 — and the user can go lower. A module mixer's cost scales with its output rate, and this one is the most expensive thing the Allegrex is asked to do outside the renderer: measured in prince/02_castle1n, mixing at the device rate cost around 420 ms of every second — more CPU than the whole renderer. Half the rate is half of that, for music that comes out of a pair of centimetre-wide speakers, and the samples inside the original modules were recorded at around this rate to begin with. nCine::AudioLoaderMpt and nCine::AudioLoaderXmp both cap the module decoder, which matters because only the libxmp one is reachable on this console — the cap had been written for libopenmpt alone and was dead code here until it was noticed. The sound effects mixer runs at the same rate: nCine::PspAudioDevice mixes its sources at a mixing rate below the hardware's and upsamples each block to 44100 Hz with a linear interpolation on the way out, so the per-source loop — where the mixer's time goes — runs half or a quarter as often. The rate is the "Sample Rate" option in Options > Sounds (11025 or 22050 Hz here; offered only on this console and the Amiga, the two software-mixing consoles with a rate to trade), stored in Jazz2::PreferencesCache::AudioSampleRate and applied through nCine::IAudioDevice::setMixingFrequency(). Effects follow at once, the music when the next stream is opened (the menu reopens its own on leaving the section), because a stream keeps decoding at the rate it was opened with.
  • This platform's libm is startlingly slow — a sinf() costs about 13.5 us, some 4500 cycles. That is enough for a handful of light sources flickering to cost several milliseconds a frame on their own, so anything that only drives appearance — a flicker phase, a pulse, an orbit — goes through nCine::sinApprox() / nCine::cosApprox() instead, at 331 ns and within 0.0014 of full scale. The same applies to std::floor(), which is a real jal floorf on a CPU with no floor instruction. Note that this is a fact about this libm and not about approximations in general: on x86-64 the same polynomial is 1.37x slower than glibc's sinf(), so nCine::sinApprox() substitutes it only on the consoles and calls the library everywhere else.
  • Threads are on, over pspdev's pthread-embedded. Two of the things the engine's threading layer asks for are genuinely absent — pthread_setname_np and CPU affinity — and are compiled out here the way they already are on the libogc consoles; priorities, pthread_kill and the rest are all present. Two details are the console's own: pthread_t is a pointer, so a thread id needs a reinterpret_cast rather than a static_cast, and stack information comes from sceKernelReferThreadStatus() and not the Vita's sceKernelGetThreadInfo().
  • Thread stacks are 64 KB here, not the 256 KB the Vita uses. pthread-embedded defaults to 32 KB, which is thin for a thread that decodes audio or runs a TLS handshake, but the ceiling is low: the executable takes the whole user partition as its heap (PSP_HEAP_SIZE_KB(-1)) while sceKernelCreateThread allocates stacks out of what the firmware kept back, and that pool holds one 256 KB stack but not two. The second creation then fails with NO_MEMORY — silently losing whichever thread starts later, in practice the audio decode thread — so raising this is not free. The pool is tight even at 64 KB: with the audio decoder and the server list's discovery thread alive, the multiplayer client thread did not fit — pthread_create() came back with EAGAIN and the game sat on a black screen with nothing to time out. So the server list stops discovering (Jazz2::Multiplayer::ServerDiscovery::Stop()) before it connects, and a client whose thread cannot start now reports the failure instead of waiting for it.
  • A spinlock deadlocks this console, and the engine's now sleeps here. The kernel runs one core with strict priorities and no time-slicing: a running thread keeps the CPU until it waits, and nothing of equal or lower priority ever preempts it. Death::Threading::Spinlock is a pure busy-wait, and the multiplayer code guards its ENet host with one — so the moment the client thread was descheduled inside a socket call while holding it, the main thread's first send (the "level ready" packet) spun forever, the holder never ran again, and the game froze on a black screen with nothing left to time out. On this target the lock now sleeps (sceKernelDelayThread(50)) while contended, which hands the CPU to the holder. The same scheduler is why the multiplayer client thread raises itself above the main thread's priority 32 (sceKernelChangeThreadPriority(0, 24)): at the default it ran only while the main thread waited for the vblank, so packets went out a frame late and acknowledgements later still, which measured as a 200-300 ms ping where every other platform sees 20. It sleeps between polls and in every wait, so the game loses nothing to it. The console's own WLAN power saving is the other half of the latency and is a system setting, not something the game can turn off.
  • The minimap's track is rasterized once here, not drawn as a mesh. The HUD draws it as a textured triangle strip through a mesh-sprite program that the fixed-function tier does not have, so only the markers appeared. Drawing the up to a thousand segments as individual sprites instead cost this console two thirds of its frame rate, so on that tier Jazz2::UI::Multiplayer::MpHUD::RefreshMinimapTrackTexture() rasterizes the whole track on the CPU into an alpha mask the size of the minimap box — once, redone only when the track or the box changes — and the frame draws it as two sprites, the shadow being the same texture offset and darkened.
  • The C library's clock is broken, and the game replaces it. pspdev's libcglue implements _gettimeofday() — what time(), gettimeofday() and std::chrono::system_clock all rest on — as the time OF DAY: seconds since midnight, so the C library believes it is the 1st of January 1970. Nothing in the engine noticed (its timestamps come straight from sceRtcGetCurrentClock()) until HTTPS did: mbedTLS checks a certificate's validity period against that clock, found every certificate "not yet valid", and the libcurl in pspdev (7.64.1, on mbedTLS 2.28) reports that particular verdict without a word of detail. "Sources/nCine/Backends/Psp/PspLibcCompat.cpp" defines _gettimeofday() from the RTC tick; the executable's definition wins over libcglue's object, whose only symbol it is.
  • The C library knows no timezone either, so the RTC is asked for the offset. Newlib has no timezone database here and PSPSDK exposes neither timezone nor _timezone, so localtime() is gmtime() and every local time the game printed was UTC — most visibly in the log file, whose entries ran an hour behind the header next to them (the header is stored as UTC and read back as local, which is what made the two disagree). The firmware does know the offset and the RTC converts with it: GetLocalTimeZoneOffset() (DateTime.cpp) runs one tick through sceRtcConvertUtcToLocalTime() and keeps the difference, daylight saving included, which GetLocalTm() then applies by hand.
  • HTTPS needs a CA bundle of its own, and only that. mbedTLS has no trust store on a memory stick and pspdev's libcurl defaults to a Unix path for one, so the configure step downloads curl.se's cacert.pem and stages it under Content/, exactly as for the Vita — and "Sources/Jazz2/PlatformWebRequest.h" points CURLOPT_CAINFO at it while clearing CURLOPT_CAPATH: mbedTLS reads the default directory (/etc/ssl/certs) before the file and fails on the directory alone. Both of the above were found from a console log by temporarily routing libcurl's verbose handshake commentary (CURLOPT_DEBUGFUNCTION) into the trace and logging what time() returned - worth remembering, as this libcurl reports a failed verification with no detail.
  • Text is entered through the firmware's on-screen keyboard. The console has no keyboard that feeds keystrokes, so nCine::MainApplication::ShowScreenKeyboard() opens the sceUtility OSK — a modal dialog that collects a whole string, as the Vita's IME does — and nCine::MainApplication::UpdateScreenKeyboard() collects the result from the frame loop. Two details are the console's own: the dialog draws itself over the finished frame and the SDK wants that between the display list's completion and the flip, so nCine::RHI::GU::GuDevice::PresentFrame() refreshes it there; and the dialog reads the pad without taking it away from the application, so nCine::Backends::PspInputManager reports the pad idle for as long as the dialog exists — otherwise the Cross that confirms a key would fire the menu item behind it. The status call returns an error code rather than "none" when no keyboard was ever started, which is why every check matches the dialog states explicitly.
  • Online multiplayer works, over ENet only. WITH_WEBSOCKET is forced off: pspdev has neither "netinet/ip.h" nor a "poll.h" header, both of which IXWebSocket includes unconditionally. ENET_IPV6=0 selects ENet's IPv4 arm, as on the Switch, because sceNetInet has no IPv6 in it at all. pspdev's newlib covers most of the BSD sockets API through libcglue but leaves out poll(), getaddrinfo() and getnameinfo(), which the vendored "Sources/Dependencies/enet/enet.h" fills in for this target over select() and gethostbyname()/gethostbyaddr(). Two calls also work differently: non-blocking sockets are switched with setsockopt(SO_NONBLOCK) rather than fcntl(), which does not reach the firmware's sockets, and receiving goes through recvfrom() rather than recvmsg() (see Deploying and running for why).
  • Ad hoc (local wireless) multiplayer is available next to Wi-Fi. The console's ad hoc mode is not IP: peers are addressed by MAC address and port over PDP datagram sockets (sceNetAdhocPdp*), and a session is an ad hoc group that one console creates and the others find by scanning the air. ENet only needs datagrams, so the whole of it sits behind the vendored transport's socket functions as a second arm, switched at runtime with enet_psp_set_adhoc() — an ENetAddress stays a 32-bit host, which in this mode indexes a table of the MAC addresses seen, and the address functions parse and format AA:BB:CC:DD:EE:FF instead of dotted quads. Above the socket layer nothing changes: the same packets, channels and reliability. nCine::Backends::PspNetwork::AdhocBegin() drops the access point association (the WLAN is one or the other) and brings sceNetAdhoc / sceNetAdhocctl up; Jazz2::Multiplayer::NetworkManagerBase::SetAdhocMode() is the switch the menus use. Hosting creates a group named after the server (8 alphanumeric characters, see nCine::Backends::PspNetwork::MakeAdhocGroupName()) before the server binds its PDP socket in it — the Connection row of Create Server picks Wi-Fi or ad hoc — and Connect To Server toggles the mode with Left/Right, whereupon Jazz2::Multiplayer::ServerDiscovery lists the groups a scan finds instead of broadcasting and asking the public list; an endpoint is then GROUP/MAC, and the client thread joins the group where it would otherwise join an access point. Ad hoc groups are only visible to other PSPs (and Vitas running Adrenaline), and an ad hoc server publishes itself nowhere.
  • Nothing has joined an access point at boot, and nothing stays joined. PspNetwork::Initialize() ("Sources/nCine/Backends/Psp/PspNetwork.cpp") loads the network PRXes and brings the stack up during startup, which is cheap, but joining an access point and waiting out a DHCP lease is seconds of work and is left to PspNetwork::ScopedConnection — constructed by every consumer of the network on its own thread, so the wait overlaps with the rest of the application coming up instead of stalling in front of it. The association is what keeps the radio talking and it is reference counted, so it lasts about as long as something needs it: the update check holds one for its request, Jazz2::Multiplayer::ServerDiscovery while the server list is open, the transport while a game is connected or hosted. The last holder to go away only starts a 30 second grace period — PspNetwork::Update(), called once a frame, is what ends it — so leaving a game and opening the server list again reuses the association instead of paying for a second one. Only disassociating is available to a user-mode application — attaching and detaching the WLAN device is a kernel interface — and the infrastructure modules stay loaded either way, because unloading them under a live libcurl session is not safe.
  • The access point comes from the console's saved connections, and the PS Vita has none. sceNetApctlConnect() takes the index of a connection saved under Network Settings, so the slots are probed and the first one that exists is used — not necessarily the first slot, because deleting a connection leaves its slot empty. The Vita's PSP emulator (Adrenaline, ARK-4) saves nothing there: it routes whatever the emulated console asks for through the Vita's own network whichever profile is named, so the probe comes up empty and the firmware's connect automatically profile — configuration 0 — is used instead, which is what the emulator's CFW authors recommend. A real console with nothing saved refuses configuration 0 like any other and reports that no connection is configured, so the fallback costs it nothing.
  • Local discovery goes over the IPv4 broadcast address. The console's stack has no IPv6 at all (hence "ENET_IPV6=0"), so TryCreateLocalSocket() binds the discovery port and sends to 255.255.255.255 where a desktop build joins the ff02::1 multicast group. A build that has both runs both — a second AF_INET socket from TryCreateLocalBroadcastSocket(), because a dual-stack socket cannot be relied on to send to a broadcast address — so the console and the desktop do find each other. The desktop's multicast socket is pinned to IPV6_V6ONLY for that reason: the two sockets share a port and must not claim each other's addresses. A server reachable over both answers both, which is why Jazz2::UI::Menu::ServerSelectSection folds answers carrying one announced identifier into a single entry whose endpoints are |-joined in the order they were found. Ad hoc mode skips all of this: the group itself is the announcement.
  • The PSPSDK stub libraries must not be linked twice. psp-gcc's own *lib spec appends -lpsputility -lpsprtc -lpspnet_inet -lpspnet_resolver -lpspsdk -lpspmodinfo -lpspuser after everything CMake emits, and an archive scanned at two points of the link line has its stubs extracted in two non-contiguous chunks. psp-fixup-imports then cannot write the stub and NID counts into the module's import table — it says so, as "could not fixup imports, stubs out of order" — and the resulting counts make one module's table overlap the next one's stub slots, so the loader patches syscalls over each other and calls such as sceNetInetSocket() end up somewhere else entirely. Anything that spec already provides is therefore deliberately absent from target_link_libraries for this target.
  • The underwater and pause low-pass filters are absent — the mixer has no filter stage, like the other software-mixing consoles.
  • The dynamic lighting is splatted on the VFPU. Every fixed-function console builds the lightmap on the CPU (CombineRenderer::PrepareSoftwareLighting()), and on this one it was the largest item of a busy frame: a scalar loop bound by FPU latency on a single-issue core, with an unpipelined sqrt.s of about 58 cycles per lit texel. The Allegrex's vector unit does four texels per instruction, so the PSP arm is inline VFPU assembly — a branch-free clamp((1 - dist) * invDenom, 0, 1)^3 per lane, which is exactly 1 in a light's flat core and exactly 0 outside it, validated against the scalar algorithm to 1.4e-6 over thousands of frames. Only the main thread has the VFPU (PSP_MAIN_THREAD_ATTR), which is where this runs. The other consoles take the row-segmented scalar path of "Rendering/SoftwareLightSplat.h", which cuts each row analytically into outside, flat core and falloff segments and uses each platform's cheapest square root — fsrra on the Dreamcast, frsqrte with two Newton steps on the Wii and GameCube, whose CPU has no square-root instruction at all.
  • The Media Engine is not a second core for this port. The console does have one — a second Allegrex normally reserved to the firmware's codecs — and pspdev ships a library that runs user code on it (libme-core, which embeds and loads a kernel bridge, so it needs a custom firmware). It was probed as a home for the module decoder, which is the last large audio cost. Under Adrenaline the bridge loads and runs but the ME's firmware image is absent (the witness word at 0x88300018 reads zero), so the library reports the core unavailable, and PPSSPP fakes the kernel module load and then hangs inside the library's kinit() — anything touching the ME has to test for the emulator first, through sceIoDevctl("kemulator:", 0x03, ...), which succeeds only there. The firmware's own sceAudiocodec path does reach the ME on every platform, but it decodes MP3/ATRAC, not modules.
  • Textures are limited to 512 pixels per axis, so larger atlases are split into pages with per-primitive page selection, palettized textures use the hardware CLUT, of which only one is resident at a time, so the palette is part of the batch key.
  • The GE has no post-texture additive term and no combiner output scale, which is why an offset_color pass expands into a modulated draw plus an additive silhouette, and why the MODULATE_X2/MODULATE_X4 presets are rejected for this target.

PlayStation 2

A 294 MHz MIPS R5900 "Emotion Engine" with 32 MB of main memory and a Graphics Synthesizer holding 4 MB of local memory that is at once the frame buffer, the texture cache and the palette store. The port renders through the GS backend ("Sources/nCine/Graphics/RHI/GS"), presented by the Ps2 window backend in NTSC 640x448.

Toolchain

ps2dev is published as the ps2dev/ps2dev container image, which is the easiest way to get it and what the build recipe below assumes. The image ships no CMake, so derive one that has it:

FROM docker.io/ps2dev/ps2dev:latest
RUN apk add --no-cache cmake make git python3 bash xorriso

Preparing the content tree

This console boots from a read-only disc and has no working directory of any kind: there is no first-run conversion compiled in and nothing is staged into Cache/, so the tree has to be complete before the image is authored. It is prepared exactly as in Preparing the game content, with the ps2 profile rather than the plain console one:

# 1. The tool (any desktop build directory already has it)
cmake -B ./build/ -D CMAKE_BUILD_TYPE=Release
cmake --build ./build/ --target AssetPacker --parallel $(nproc)

# 2. Convert - the original game data and the game's own content are named separately
./build/Utilities/AssetPacker/AssetPacker ./build/ConsoleContentPs2 \
    --source=<path to the original Jazz Jackrabbit 2> --content=./Content --target=ps2

# 3. Prebaked.pak is what makes it a prepared tree
ls ./build/ConsoleContentPs2

Pass the result as NCINE_CONTENT_DIR on the first configuration of the build directory, as below. The packaging step then assembles the whole disc under "build/ps2/cd/" and authors it:

build/ps2/cd/SYSTEM.CNF     # BOOT2 = cdrom0:\JAZZ2.ELF;1, VER, VMODE = NTSC
build/ps2/cd/CDFS.IRX       # copied from the SDK; loaded at startup so newlib can see the disc
build/ps2/cd/JAZZ2.ELF      # the stripped executable
build/ps2/cd/Content/…      # NCINE_CONTENT_DIR, with "Source.pak" renamed if it has one

Turning that into the image is one command, run for you by the same step:

xorrisofs -quiet -iso-level 2 -l -V JAZZ2 -o ./build/ps2/jazz2.iso ./build/ps2/cd

mkisofs and genisoimage are accepted in its place and take the same options. Because this is ISO 9660 level 2 with -l, a file name on the disc may be 31 characters at most — irrelevant for the converted original data, worth knowing if custom levels or tilesets with longer names are put into the tree.

"Music" belongs on the disc: this console plays the soundtrack through libxmp (see Limits and known issues).

Running from a USB stick or an MX4SIO SD adapter

A USB stick in one of the front ports, and an MX4SIO adapter putting an SD card on a controller port, are how most of these consoles are loaded today. The game supports both as first-class alternatives to the disc: it looks for a content tree on them at startup and, finding one, reads everything from there — and writes there too, which a disc cannot offer and an 8 MB memory card barely can (the level cache alone is larger than one).

Nothing has to be built differently, and there are two layouts, looked for in this order:

  • A Content directory next to the executable, wherever that happens to be. This is what the loader's own argument vector says (argv[0], which is the only thing that knows where the executable was read from — see nCine::Backends::Ps2Storage::SetBootPath()), so it works on any device the loader can reach and in any directory the files were copied into.
  • Games/Jazz2/ on a mounted unit. A card or a stick is shared storage, alongside a loader's own APPS, DVD and CFG, and a tree at its root would claim names that belong to whatever else is on it. It is the same layout the game uses on the one other machine where it is a guest on shared storage (the Switch, "sdmc:/Games/Jazz2/").
mass0:/Games/Jazz2/JAZZ2.ELF      # wherever the loader is pointed at; this is the tidy place for it
mass0:/Games/Jazz2/Content/…      # what the probe looks for
mass0:/Games/Jazz2/Cache/…        # created by the game, unlike on a disc
mass0:/Games/Jazz2/Jazz2.config   # likewise

Units mass0: to mass3: are probed in order, so a device partitioned more than once still works. The IOP modules this needs — bdm, bdmfs_fatfs (FAT12/16/32 and exFAT) and the block drivers mx4sio_bd and usbd + usbmass_bd — are linked into the executable, because a driver that is the only way to reach a device obviously cannot be read from it (see nCine::Backends::Ps2Modules; audsrv is linked in as well, for the same reason).

Loading a module out of EE memory needs a patch written into a running ROM service, on a console whose IOP this port deliberately never resets, and it is the one mechanism here with no track record on hardware. So it is confined to the boot that cannot do without it: a disc boot never goes near any of it. audsrv is staged on the disc next to CDFS.IRX and loaded from there, and the block-device stack is not loaded at all — see nCine::Backends::Ps2Modules::Load(), which prefers the disc wherever there is one.

The split between the two halves is worth knowing if this is ever ported to another application. nCine::Backends::Ps2Storage brings the modules up and reports which units mounted, and that is all it does — it does not know what the game is called or how its files are laid out. The directory name and the Content/Cache/Source layout live in Jazz2::ContentResolver::InitializePaths().

A build running from removable storage also writes a log file next to its content, Jazz2.log, which is the only practical way to read a trace from this console — the alternative is the EE's SIO register and a serial cable. It is deliberately not written when the game runs from a disc: the writable storage there is a memory card, which is 8 MB shared with every game on the console and no place for a log.

Building

cmake -S . -B build/ps2 -DCMAKE_TOOLCHAIN_FILE=$PS2SDK/ps2dev.cmake \
    -DCMAKE_BUILD_TYPE=Release -DNCINE_STRIP_BINARIES=ON \
    -DNCINE_CONTENT_DIR=$PWD/build/ConsoleContent
cmake --build build/ps2 -j$(nproc)

The toolchain file defines PLATFORM_PS2, from which NCINE_PREFERRED_RHI is pinned to GS. NCINE_CONTENT_DIR is cached, so it belongs on the first configuration of the build directory (see Preparing the content tree for how that tree is produced). The build produces a stripped jazz2.elf and stages a bootable jazz2.iso around it: SYSTEM.CNF (BOOT2 = cdrom0:\JAZZ2.ELF;1, VMODE = NTSC), the ELF as JAZZ2.ELF, CDFS.IRX from the SDK, and the content tree as Content/, whose Source.pak is renamed to Prebaked.pak on the way in so the game recognizes it as a prepared tree. Nothing is staged into Cache/: a prepared tree needs none, and the disc is read-only anyway.

Without an ISO author on PATH at configure time the build prints xorrisofs/mkisofs not found, bootable ISO image will not be created and produces only the ELF. That is enough to bring the engine up in PCSX2 with -elf, but such a run has no disc and therefore no content at all — the game reads everything from "cdfs:/" here.

Changing the content of a finished image

As on the Dreamcast, the content is authored into the image, and replacing it does not need the image to be built again:

./build/Utilities/AssetPacker/AssetPacker swap-content ./build/ps2/jazz2.iso \
    --content=./build/ConsoleContent

swap-content keeps SYSTEM.CNF, the executable it names and the .IRX modules beside it exactly as they are, and writes the file system again around a different Content. A PlayStation 2 disc is a plain ISO with no container around it, so there is less to preserve than on the Dreamcast and the image comes back sized to what is now on it. It always writes a Joliet hierarchy, so an image that went through it is readable by cdfs.irx whether or not the build that produced it passed -J. See Replacing the content of a disc image.

Deploying and running

Burn jazz2.iso to a disc, or load it in PCSX2. A headless run needs the emulator's setup wizard marked complete and a BIOS filename set in its INI, EnableEEConsole turned on to see any output at all, and a renderer the host GPU can actually create:

pcsx2-qt -batch -nogui -earlyconsolelog -logfile run.log jazz2.iso

A bare ELF has no connected stdout, so the trace goes to the Emotion Engine's SIO transmit register (0x1000F180) instead of printf. PCSX2 captures that as EE console output and a serial cable picks it up on real hardware. The same channel carries the early messages the CDVD bring-up in MainApplication::Run() emits, before the trace system exists.

On hardware without a cable the same lines are on the television: MainApplication::Run() opens PS2SDK's libdebug text console before it touches the IOP, and every trace line is drawn there until Ps2GfxDevice hands the Graphics Synthesizer to the renderer, which it announces with "Handing the display over to the renderer". Everything that can stop a boot on the IOP side — the disc wait, CDFS.IRX, the memory card, LIBSD and audsrv, the MX4SIO probe — runs before that line, so a black screen with the console still showing says exactly which step did not come back, and a black screen after that line points at the renderer's own setup or the content loading that follows. Report the last lines shown when a boot stops.

Before the console can draw anything, three solid colours mark the stages that come earlier (Ps2BootMarker() in "MainApplication.cpp" writes PMODE = 0 and BGCOLOR, which needs no framebuffer): red when static initialization begins, green when main() reaches MainApplication::Run(), blue once the trace system is up and the boot console is about to open. A screen that stays on one of them names the stage that did not come back; a screen that stays black shows none of the executable ran — the loader, the file or the video signal, not the game.

Limits and known issues

  • Local memory is the binding constraint. 4 MB holds the display buffer, the render-target reserve and the palette slab before any texture: a 640x448 PSMCT16 buffer plus the reserve and the CLUT slab leave about 2816 KB of texture cache. A frame's working set fits comfortably (~856 KB measured) but a whole level's sheets do not, so textures stream in and out on transitions and a busy level still reports allocation failures. The allocator hands out whole 8 KB pages, because within a page the texel-to-block swizzle depends on the pixel storage mode and two buffers of different modes sharing a page would corrupt each other.
  • Sound goes through audsrv, the IRX that owns the SPU2 — the chip is on the I/O Processor, so the Emotion Engine can only hand it finished PCM (see nCine::Ps2AudioDevice). Two consequences are worth knowing. The module's ring buffer is small (about 50 ms at the default 22050 Hz, measured at startup and reported in the trace) and there is no mixer thread to keep it full, so a frame that runs much longer than that is audible — and audible as a repeat rather than a gap, because audsrv plays its last buffer over again for as long as nothing replaces it (measured; fed silence, by contrast, comes out as silence). The backend submits in blocks of a quarter of the ring so that ordinary frames keep it full, and anything that blocks the main thread for longer than the ring says so first through nCine::IAudioDevice::beginBlockingOperation() — a level load, and the episode list, which reads twenty-odd episodes off the disc — which stops the stream, so those are silent instead of a loop. Gameplay running sustained below about 19 fps still repeats; that is a frame rate problem rather than an audio one. NCINE_WITH_OPENMPT is forced off (libopenmpt's to_chars(char*, char*, const double&) is ambiguous against PS2SDK's newlib), so the soundtrack is libxmp's, which is on by default here — and which cannot read the 4 .mo3 tracks of the 56 shipped, exactly as on every other libxmp target.
  • The picture is composed for 4:3, not for 640x448. A television spreads an NTSC field pair over the whole 4:3 screen, so a pixel of this drawable is a seventh taller than it is wide; Ps2GfxDevice::displayAspect() reports that and the logical view is fitted to it rather than to the framebuffer's own ratio, without which everything in the game was 7% too tall. The view is 597x448 at the default rendering resolution — 4:3, and as many lines as the framebuffer has, so nothing is resampled vertically (see Jazz2::Rendering::UpscaleRenderPass::DefaultViewWidth, which is what raises the bound from the 16:9 720x405 the other platforms use).
  • Threads are off, so multiplayer (including local splitscreen) is compiled out and asset loading is synchronous.
  • Two pipeline stages are still unimplemented: the weapon wheel's line strip (line_strip_mesh) and the CPU-lightmap compositor (lighting_combine, so a level renders unlit).
  • Saving needs writable storage that a disc is not. Preferences and progress go to a memory card (mc0:/Jazz2/, the first usable slot, with the icon.sys the console's own browser needs or it calls the save "Corrupted Data") — or, when the game was booted from an SD card in an MX4SIO adapter, to the card it is already reading from (see Preparing the content tree), which is the only configuration in which the level cache has somewhere to live.
  • The GS has no post-texture additive term for a colour and no combiner output scale, so an offset_color pass expands into a modulated draw plus an additive silhouette (which the GS reaches through a white-RGB CLUT — see GsDevice::AcquireCoverageClutForOffset()) and the MODULATE_X2/MODULATE_X4 presets are rejected for this target.

PlayStation 3

A 3.2 GHz Cell "PPE" with 256 MB of XDR main memory, and an RSX — an NVIDIA NV47 — with 256 MB of GDDR3 of its own. The port renders through the RSX backend ("Sources/nCine/Graphics/RHI/RSX"), presented by the Ps3 window backend at whatever mode the attached display accepts.

This is the odd one out among the console ports. The RSX is a fully programmable part, so despite being a console backend it belongs with GXM and the desktop backends rather than with the fixed-function PVR/GX/GU/GS tier: it advertises shaders and off-screen render targets, runs the whole bloom / lighting / combine / rescale chain, and consumes none of the transpiled fixed_function effect tables. What separates it from the PS Vita — which wants the same Cg — is only when the shaders are compiled. The Vita has SceShaccCg on the console, the PS3 has no runtime shader compiler at all, so the same Cg is compiled to NV40 microcode offline and embedded in the executable, the arrangement the Vulkan backend uses for its SPIR-V.

Toolchain

Two pieces are needed, plus one that is not part of either.

  • ps3toolchain provides the powerpc64-ps3-elf compilers. Building them from source takes hours, the project's nightly releases ship a prebuilt ps3dev-linux-X64.tar.gz that only has to be unpacked.
  • PSL1GHT is the SDK proper — librsx, libgcm_sys, libio, libaudio, libsysutil — and the host tools the packaging step runs (fself, make_self_npdrm, sfo, pkg, sprxlinker, and cgcomp). It is built from source by step 8 of ps3toolchain, which takes a few minutes.
  • NVIDIA's Cg Toolkit is needed only to regenerate the shader table, not to build the game: cgcomp is a thin front end over libCg.so and does nothing without it. See Shaders are compiled offline.
export PS3DEV=<prefix>          # e.g. ~/sdk/ps3dev
export PSL1GHT=$PS3DEV/psl1ght
export PATH=$PS3DEV/bin:$PS3DEV/ppu/bin:$PS3DEV/spu/bin:$PATH

# Prebuilt compilers
curl -L -o ps3dev.tar.gz https://github.com/ps3dev/ps3toolchain/releases/download/<nightly>/ps3dev-linux-X64.tar.gz
tar xzf ps3dev.tar.gz -C $(dirname $PS3DEV)

# PSL1GHT and the host tools (step 8), then the portlibs (step 9)
git clone --depth 1 https://github.com/ps3dev/ps3toolchain
cd ps3toolchain && ./toolchain.sh 8 9

Building

With the environment described above exported, it is three lines:

cmake -S . -B build/ps3 -DCMAKE_TOOLCHAIN_FILE=$PWD/cmake/toolchains/ps3dev.cmake \
    -DCMAKE_BUILD_TYPE=Release \
    -DNCINE_CONTENT_DIR=<path to the console content tree>
cmake --build build/ps3 -j$(nproc)

The toolchain file defines PLATFORM_PS3, from which NCINE_PREFERRED_RHI is pinned to RSX. The build produces jazz2.elf, then stages a package tree around a stripped copy of it: sprxlinker rewrites the PRX import stubs, make_self_npdrm wraps the result as the EBOOT.BIN a package boots from, fself produces an unsigned jazz2.self that RPCS3 and a CFW console will also run straight from disk, and sfo writes the PARAM.SFO carrying the title and the application id (JAZZ20000). The content tree is copied to USRDIR/Content with its Source.pak renamed to Prebaked.pak, and pkg wraps the whole tree into jazz2.pkg.

The application id is not written down anywhere — it is derived from the application name, padded with zeroes to the nine characters the firmware demands and uppercased, exactly as the PS Vita title id is derived (see PlayStation Vita). The content id, PARAM.SFO and the writable path the game uses at runtime all follow from that one value, so they cannot drift apart. Configure with -DPS3_APPID=<id> to override it, a value that is not four letters followed by five digits fails configuration rather than producing a package the firmware would reject.

Shaders are compiled offline

There is no shader compiler on the console, so the microcode is generated ahead of time and committed, exactly as the other generated shader headers are:

PS3DEV=<prefix> LD_LIBRARY_PATH=<Cg toolkit>/usr/lib64 \
    ./build/Utilities/ShaderCompiler/ShaderCompiler --generate-all

It is a mode of ShaderCompiler like every other generated artifact — --emit-rsx, which --generate-all runs when it can find cgcomp. The tool emits the very same Cg it produces for the PS Vita, because the two consoles want the same language, and only caps BATCH_SIZE to what the RSX's constant registers hold before compiling each stage with the vp40/fp40 profiles. cgcomp is found through $PS3DEV/bin or PATH, or named with --cgcomp, it needs NVIDIA's Cg Toolkit on the library path, which is what LD_LIBRARY_PATH above supplies.

On a machine without cgcomp, --generate-all reports "skipped: RsxGeneratedShaders.h" and leaves the committed header alone rather than emptying it — so a regeneration elsewhere cannot silently strip the console of its shaders. Commit the result, the console build never invokes cgcomp, so neither the PS3 toolchain nor the Cg Toolkit is a build dependency.

A stage the profiles reject is reported and left out of the table. The backend then fails that one program at load time with a message naming the variant, rather than mis-rendering silently, see Limits and known issues for which ones those currently are.

Deploying and running

Install jazz2.pkg on a console with custom firmware, or run the staged tree directly:

rpcs3 --no-gui build/ps3/pkg/USRDIR/EBOOT.BIN

RPCS3 needs the PlayStation 3 firmware installed once (File → Install Firmware, or --installfw with the PS3UPDAT.PUP Sony publishes), without it the emulator stops at "Missing Firmware" before the title starts. Booting the EBOOT.BIN in place is what makes /app_home resolve to USRDIR, so the staged content is found with no install step.

Limits and known issues

  • Threads are off. Unlike the PSP, whose pthread-embedded the engine does use, and the PS2, whose shim at least links, PSL1GHT ships none at all: the newlib installs a <pthread.h> that declares symbols nothing implements, so pthread_create does not even link. Threading here means lv2's own PPU threads (sysThreadCreate, sys/mutex.h, sys/cond.h), which would be a new backend in "Thread.cpp" rather than a configuration change. Multiplayer (including local splitscreen) is therefore compiled out and asset loading is synchronous.
  • Three rescale filters have no microcode: ResizeCleanEdge needs 92 temporary registers where fp40 has 64, ResizeSabr needs a ninth TEXCOORD output, and ResizeMonochrome indexes an array the profile only allows indexing for texture coordinates. The other 58 program variants compile. The backend leaves RHI_CAP_HEAVY_RESCALE_SHADERS undefined, which drops those three modes from *Options > Graphics > Rescale Mode* and skips compiling them at all; a value stored by another backend needs no special handling, because UpscaleRenderPass already falls back to the plain sprite shader when a mode resolves to no program, which is exactly the pixel-perfect mode.
  • Sprite batching is turned off. The backend leaves RHI_CAP_BATCHING undefined, so RenderResources::GetBatchedShader() reports no batched counterpart — which is how the batcher is told a run cannot be combined — and every sprite is drawn individually. That one switch also skips linking the batched programs, keeping a dozen of them out of memory. The cause is the microcode cgcomp emits for the instance indexing: a batched vertex program reaches the instance array through the address register, and the emulator's decompiler demands a register type in all three source slots of every instruction, abandoning the whole program when one is zero (Src check failed. Aborting) before the position output is ever written — so a batched draw renders nothing while unbatched ones are correct. Whether real hardware would accept it is untested. Patching the emitted microcode to fill those slots was tried and reverted: it silenced the complaint but turned real constant fetches into temporary reads, so a type of zero is not the "unused slot" that attempt assumed.
  • The batch would be 32 sprites when it is re-enabled, against 585 on a platform with uniform buffers, because a batched instance array reaches the vertex program through its constant registers rather than a buffer. Three places have to agree on that number and none can derive it from the others: the microcode bakes it in offline (ShaderCompiler's RsxMaxBatchSize), RsxDevice::MaxBatchSize sizes the batched corner stream that supplies the instance index, and the backend publishes it as IRhiCapabilities::IntValues::MaxBatchSize so that both the shader compilation paths and RenderBatcher bound themselves by it. Left to derive itself from the uniform block budget the engine picks 65, which overruns both the corner stream and the instance array; the backend says so at load time rather than clamping quietly.
  • Stage attributes carry the Cg entry point's struct qualifier. cgcomp records a generated stage's inputs as _input.aQuadCorner rather than aQuadCorner, because the emitter passes them in a struct, while the hand-written present shader's are unqualified. rsxVertexProgramGetAttrib() only matches whole names, so the backend matches the qualifier as a suffix instead (RsxDevice::FindVertexAttribute()). Asking for the bare name silently finds nothing, the stream is never bound, and every sprite reads whatever the previous draw left in that register — which renders an entirely black frame with no error anywhere.
  • The three resize filters and batching aside, nothing else is knowingly unimplemented. Under the emulator the game runs at 60 FPS with tilemaps, sprites, HUD, menus and the textured backgrounds all correct, and the command queue stays healthy (no FIFO desync, no aborted vertex programs).

PlayStation Vita

PlayStation Vita is a shader platform, and the only console here with a choice of two hardware backends. Its GPU is a PowerVR SGX543MP4+ driven by sceGxm, the console's own graphics API, the window and input come from SDL2, so the build differs from a desktop one mostly in its packaging.

NCINE_PREFERRED_RHIWhat it drives
GXM (the default)sceGxm directly. A full-pipeline backend — unlike the fixed-function consoles above, the SGX is a unified-shader part, so the whole bloom / lighting / combine chain runs. What it removes compared to the OpenGL path on the same console is the translation layer between the engine and sceGxm, nothing else
OpenGLvitaGL, an OpenGL|ES 2.0 implementation layered on that very same sceGxm. Force-enables the OpenGL|ES path and the strict ES 2.0 profile
SoftwareThe CPU rasterizer, for comparison and bring-up

Toolchain

Install VitaSDK with vdpm (see vitasdk.org) and export VITASDK environment variable, then use "$VITASDK/share/vita.toolchain.cmake".

Building

export VITASDK=/usr/local/vitasdk
cmake -B ./build/vita/ -D CMAKE_BUILD_TYPE=Release \
    -D CMAKE_TOOLCHAIN_FILE=$VITASDK/share/vita.toolchain.cmake
make -j $(nproc) -C ./build/vita/

The native GXM backend is what this builds, add "-D NCINE_PREFERRED_RHI=OpenGL" for the vitaGL one instead. Either way the console needs "libshacccg.suprx", see The console needs a firmware module. The result is "build/vita/jazz2.vpk".

Deploying and running

Install the VPK with VitaShell (or over FTP). It carries the "Content" directory with it, which the firmware unpacks into the application's own read-only directory ("ux0:/app/jazz20000/", mounted as "app0:"), so nothing else has to be copied for the game to start.

Unlike the six consoles above, the Vita converts the original game data itself on first run, so copy an original installation to "ux0:/data/jazz2/Source/" and let it build "ux0:/data/jazz2/Cache/" — or prepare the tree with AssetPacker anyway to skip the wait. Both of those live on "ux0:" because "app0:" cannot be written to, reinstalling the VPK replaces the content but leaves the cache, the converted source data and the settings alone.

The console needs a firmware module

Shaders are compiled on the console, whichever backend is selected, because the VitaSDK ships no offline shader compiler for the platform: the only one is "libshacccg.suprx", part of the console's own firmware. Extract it with VitaShaRK's instructions and place it in "ur0:/data/".

  • GXM consumes GXP binaries, so it ships its shaders as Cg source (the generated "CgGeneratedShaders.h", see The Cg dialect (PS Vita and PlayStation 3)) and compiles them through vitaShaRK when a program links. Without the module the backend refuses to start and says exactly that in the log.
  • OpenGL hands GLSL to glCompileShader(), and vitaGL compiles it through the very same SceShaccCg. That is also what lets external ".shader" files and the rescale filters work on this platform, unlike the fixed-function consoles above.

The compiled shaders are cached, and shipped

Compiling on the console is not cheap: the ~50 Cg stages this game links cost 3.27 s of a 6.8 s cold start, measured on hardware. nCine::RHI::GXM::GxmShaderCache removes that by keeping the GXP binaries SceShaccCg produces.

  • Every entry is keyed by a 64-bit hash of the exact Cg source string it was compiled from, so editing a ".shader" and regenerating invalidates only the stages that actually changed — a stale entry cannot be found, only orphaned. The file header adds the two invalidations a per-entry hash cannot express: a format revision, and a fingerprint of the "libshacccg.suprx" the entries came from, so one compiler's output is never handed to a different driver. Either mismatch rejects the whole pack and the console simply recompiles.
  • Two packs are loaded. A read-only one shipped inside the VPK at "app0:/Content/Shaders/ShadersGxm.bin" first, then the writable one under "ux0:/data/jazz2/Cache/Shaders/" over it. Writes only ever go to the writable one.
  • That is what makes "precompiled offline" work without an offline compiler: run once on a console, pull the written pack back, commit it. The committed copy lives at Sources/Shaders/Prebaked/Vita/ShadersGxm.bin and is packaged for this platform only — a GXP is Vita machine code. Its README.md covers the refresh procedure and, importantly, why the pack must be committed exactly as the console wrote it: some keys belong to sources that never appear in "CgGeneratedShaders.h" at all, either because the batched shaders are patched at runtime (see below) or because the clear and present shaders are string literals in "GxmDevice.cpp". Pruning against the generated header deletes precisely those.

With the shipped pack a cold start loads 51 binaries and compiles nothing; startup from the clock line to sceGxmInitialize() completing is then well under half a second.

Sprite batching

The backend publishes IRhiCapabilities::IntValues::MaxBatchSize as 32, which bounds both the BATCH_SIZE baked into the batched Cg programs and nCine::RenderBatcher itself. The value is measured rather than assumed: it was 10 (mirroring the fixed batch the engine forces on the newer PowerVR Rogue parts, see nCine::Application::InitCommon()), and instrumenting the batcher on the credits screen — the most text-heavy view in the game — showed runs of ~18 same-state commands being split by the ceiling alone. Raising it does not change how many instances are uploaded per frame, only how few draws carry them, and a draw call costs this part far more than the extra instance-block bytes do.

A batch smaller than the ceiling is legitimate here, and that matters more than the ceiling does: a batched draw submits 6 * its own size in vertices and its instance block is allocated from the sizes actually accumulated, so a short batch draws and costs only what it holds. Only the ceiling has to match what the shader was compiled for, because a batch may not index past its instance array. The batcher's floor is therefore independent of it (minBatchSize is 2 whenever a device publishes a ceiling); forcing the floor up to the ceiling instead left every run shorter than it, and every remainder past a multiple of it, drawn one command at a time.

Together the two took the credits screen from 31.5 fps to 41–43 fps on hardware, most of it now vsync-locked. What remains is Jazz2::UI::Font::DrawString() switching between the plain sprite shader and Colorized on every "\f[c:...]" run, which splits a batch every few glyphs; the two shaders are not interchangeable (Colorized desaturates and then dyes, so it emits 4.5 * the texel at white), so unifying them would change how all coloured text looks.

Limits and known issues

  • The ES 2.0 profile has no uniform buffer objects and no gl_VertexID, so its shaders come from the ESSL 100 lowering that ShaderCompiler bakes into the same generated headers, see Platform notes. The GXM backend has the same missing vertex-ID input and reuses the very same rewrite, so both read the quad corner from a vertex attribute instead.
  • sceGxm's polygon mode is not implied by the primitive type, and it decides whether a primitive rasterizes at all: under the default SCE_GXM_POLYGON_MODE_TRIANGLE_FILL a SCE_GXM_PRIMITIVE_LINES draw has no interior to fill and produces nothing, silently. That is how the weapon wheel's segment arcs (a textured line strip through MeshSprite, which this backend already draws as the independent segments it means — see GxmDevice::EnsureLineStripIndices()) came out missing while every triangle in the frame was correct. GxmDevice::DrawCommon() now selects the mode from the primitive (LINE for lines, POINT for points, TRIANGLE_FILL otherwise). It is sticky context state rather than part of a draw, so the three paths that bypass that function — the stencil band, Clear() and the present blit — assert TRIANGLE_FILL for themselves the same way they already re-assert the depth and fragment-program state.
  • Under Vita3K the GXM backend draws a one-pixel seam where a repeating texture wraps — most visibly along the scrolling textured background. The wrap is set up correctly for the hardware (a power-of-two, tiled colour-surface texture whose control words read back with SCE_GXM_TEXTURE_ADDR_REPEAT on both axes) and the emulator translates sceGxm onto desktop OpenGL, where such a texture repeats without any restriction, so the join is expected to be absent on a console. No workaround is applied for it: emulating one in the shared shader would cost every other backend and could not blend across the join anyway.
  • Online multiplayer works, over ENet only. WITH_WEBSOCKET is forced off, because IXWebSocket includes "netinet/ip.h" and VitaSDK does not ship it. ENET_IPV6=0 selects ENet's IPv4 arm, as on the PSP and the Switch: there is no IPv6 anywhere in "psp2/net/net.h", so the "in6_addr" the SDK's own headers declare has nothing behind it. What the "sys/ioctl.h" ENet includes was once given as the blocker here turned out to be an unused include — the only "ioctl()" in the library is "ioctlsocket(FIONBIO)" in its _WIN32 arm, and every POSIX target uses "fcntl()" instead — so it is simply not included on this target.
  • VitaSDK's "inet_pton()" does not follow POSIX, and it takes HTTPS down with it. Its AF_INET arm forwards to "sceNetInetPton()" and collapses any negative result to -1, so a host name comes back as -1 where POSIX requires 0 (-1 is reserved for an unsupported address family). Both callers here test the result for truth rather than for a positive value, which is what the libraries themselves do: libcurl's OpenSSL back end concludes that the host it is verifying is a literal address and matches the certificate's iPAddress entries against four bytes of nothing, failing every request with

    "no alternative certificate subject
       name matches target ipv4 address" 

    ; and ENet's "enet_address_set_host_ip()", written as "if (!inet_pton(...)) return -1;", accepts an address it never parsed. Replacing the function fixes both — see "Sources/nCine/Backends/Vita/VitaLibcCompat.cpp". Until it was, the update check and the public server list had been failing here for the same reason.

  • "sceNetSendmsg()" caps the number of iovecs, well below the 65 buffers ENet may hand over for one datagram (the protocol header plus two per command). A send of 33 buffers came back EMSGSIZE (SCE error 0x28), which happens exactly when a burst of reliable commands is acknowledged at once — right after a level reports itself ready — and ENet treats a failed send as a dead connection, so the client dropped out of every match a second or two after joining, with nothing in the log but "enet_host_service() returned -1". The Vita therefore takes the same gather-then- "sendto()" arm as the 3DS and the PSP; the copy is bounded by the MTU. The Vita arms also log errno together with "*sceNetErrnoLoc()" when a send, receive or socket option fails, since the trace log is the only debugging channel on the console. Measured on hardware, 2026-09-05.
  • The rest of the sockets API needs nothing: VitaSDK provides "poll()", "getaddrinfo()", "setsockopt()" and "recvmsg()" natively, its "SceNetMsghdr" and "sockaddr_in" are byte-identical to newlib's, and the network stack needs no explicit bring-up — newlib calls "_vita_net_init()" lazily from "socket()" and the resolver entry points, which is why there is no counterpart here to the PSP's PspNetwork::Initialize(). That pool is small, though — "malloc(0x23114)", about 140 KB, against the 256 KB send and receive buffers ENet asks for — and while the option calls do not fail today, it is the first thing to suspect if the transport ever runs out of buffers.
  • The frame is rendered smaller than the panel, and the user picks how much smaller. The GXM backend renders the whole frame — scene, upscale pass and UI — into an intermediate screen surface that nCine::RHI::GXM::GxmDevice::PresentFrame() stretches onto the 960x544 panel, so a smaller surface takes fragments off every full-screen pass for free (the present blit has to resample anyway). The default is half the panel, 480x272 (a clean 2x point doubling, see nCine::RHI::GXM::GxmDevice::ScreenWidth), and the "Rendering Resolution" option in Options > Graphics offers 60% (576x326) and 75% (720x408, the logical view's own size) as well — nothing above that, because the full panel would only upscale the same 720x408 scene fractionally at the cost of another full-panel pass. The surface is recreated live through nCine::IGfxDevice::setDrawableSize() / nCine::RHI::GXM::GxmDevice::ResizeScreenSurface(), the drawable size follows it and the logical view follows the drawable (Jazz2::Rendering::UpscaleRenderPass::CalculateViewSize()). On every other shader-tier platform the same option scales the 720x405 bound of the logical view instead, so it is the most the scene is rendered at; the direct-tier consoles render at their own small panel size already and do not offer it. The menu blends its header, footer and logo metrics between the compact (272-row) and full (400-row) layouts by view height (Jazz2::UI::Menu::MenuLayout), so the sizes in between look right, and a change lays every open section out again in place rather than rebuilding the menu.
  • Blur effects run over a shorter chain. The blur that darkness mixes in is five off-screen passes at full lighting resolution — a downsample to half size, two blur passes there and two more at a quarter — and on this backend every pass is a scene the CPU waits out before the next one may sample it (see nCine::RHI::GXM::GxmDevice::FinishScene()), so the number of passes costs more than their pixels. Below 100% the first blur pass reads the view directly instead of a downsampled copy, which leaves four passes at the 50% this console defaults to; at 25% and below the quarter-size level is dropped as well and the half-size one stands in for it, which leaves two (Jazz2::Rendering::PlayerViewport::Initialize(), on every platform).
  • Asynchronous tracing is disabled, threads are available.
  • Sound depends on the SDK providing an OpenAL implementation — without one, audio is compiled out exactly as on the fixed-function consoles.
  • Module music is libopenmpt, built from source by the configure step. VitaSDK packages both that and libxmp, and this is the one console where the choice is a genuine trade rather than a constraint: NCINE_WITH_XMP=ON picks up the SDK's prebuilt libxmp (nothing is downloaded, and the binary loses several megabytes of decoder) at the cost of the four .mo3 tracks and some playback accuracy. libopenmpt stays the default because a 444 MHz quad-core Cortex-A9 with NEON and 512 MB has no trouble with it.

Nintendo Switch

Also a shader platform, built with devkitA64 and using SDL2 and desktop OpenGL. Install the switch-dev group and use "$DEVKITPRO/cmake/Switch.cmake":

sudo dkp-pacman -S --needed switch-dev
cmake -B ./build/switch/ -D CMAKE_BUILD_TYPE=Release \
    -D CMAKE_TOOLCHAIN_FILE=${DEVKITPRO}/cmake/Switch.cmake \
    -D DEATH_TRACE_ASYNC=OFF
make -j $(nproc) -C ./build/switch/

DEATH_TRACE_ASYNC has to be off — it crashes on startup on this platform. The build produces "build/switch/jazz2.nro" with the shipped game content embedded in its RomFS, so the application is self-contained: copy the .nro to "sd:/switch/" and start it from hbmenu. Like the Vita, the Switch converts the original game data itself — put an original installation into "sdmc:/Games/Jazz2/Source/" and the converted cache and save data appear next to it in "sdmc:/Games/Jazz2/".


Libretro core

Not a console target in itself, but the other way to reach a console-like device using libretro interface: with NCINE_BUILD_LIBRETRO=ON the game builds as a libretro core ("jazz2_libretro.so", sources in "Sources/nCine/Backends/Libretro") that RetroArch drives, with no window backend of its own. NCINE_PREFERRED_RHI accepts Software (the CPU rasterizer's framebuffer is handed to retro_video_refresh, which works on every frontend) or OpenGL (rendering into the frontend's FBO through SET_HW_RENDER, targeting OpenGL|ES 3.0 as the common denominator of RetroArch's GPU platforms).

cmake -B ./build/libretro/ -D CMAKE_BUILD_TYPE=Release \
    -D NCINE_BUILD_LIBRETRO=ON -D NCINE_PREFERRED_RHI=Software
make -j $(nproc) -C ./build/libretro/

Continuous integration

Every console is built on each push by its own workflow in ".github/workflows", all of them inside the SDK's container image, and each uploads a ready-to-deploy package as an artifact:

WorkflowContainer imageArtifact
dreamcast.ymlpcercuei/dreamcast-toolchain (compilers only — KOS, kos-ports and mkdcdisc are built in the job and cached)Jazz2.cdi + Jazz2.elf
wii.ymldevkitpro/devkitppcthe sd/ card tree with apps/Jazz2/boot.dol
gamecube.ymldevkitpro/devkitppcthe sd/ card tree with Jazz2/Jazz2.dol
3ds.ymldevkitpro/devkitarm (the 3ds-zlib, 3ds-curl and 3ds-mbedtls ports are installed in the job)the sdmc/ card tree with 3ds/Jazz2/Jazz2.3dsx
psp.ymlpspdev/pspdevthe ms0/ memory-stick tree with EBOOT.PBP
ps2.ymlps2dev/ps2devJazz2.iso + Jazz2.elf (the bare ELF because PCSX2 boots one with -elf)
vita.ymlvitasdk/vitasdkJazz2.vpk
switch.ymldevkitpro/devkita64Jazz2.nro

The PlayStation 3 has no workflow yet. Unlike every other console here it has no official SDK container image to build inside, and the prebuilt compilers plus a PSL1GHT built from source are a larger setup step than the others need, Toolchain describes doing it by hand.

The KallistiOS, kos-ports and mkdcdisc revisions in dreamcast.yml are pinned to specific commits and their build output is cached against those commits: an unpinned clone of KOS master can otherwise break the workflow — or silently change the runtime — without any change in this repository. The workflows build against the repository's own "Content" (they cannot ship converted original game data), so their artifacts boot into the main menu and need a content tree prepared as described in Preparing the game content to actually play a level.


Troubleshooting

  • The game starts, but no episode can be played. The content tree is missing, incomplete or not marked as prepared. The Nintendo 64, the Dreamcast, the GameCube and the PlayStation 2 mark their content as verified without checking it, and the others find no "Prebaked.pak" and no original files to convert, so all three faults present identically. In order of likelihood: the tree was never passed as NCINE_CONTENT_DIR (it is a cached variable — passing it to a build directory that was already configured without it does nothing), the tree carries "Source.pak" instead of "Prebaked.pak" (a desktop profile conversion), or it ended up on the medium under a name other than "Content". See Preparing the game content for the procedure and the per-console path.
  • The menu itself is unreadable or empty, not just the episode list. The conversion never got the game's own content, so the tree carries the converted original data and none of the rest (the fonts, the UI graphics, the whole of "Metadata", the translations). The conversion warns about this as it happens — "No game content directory was found beside the original files" — and "--content=<dir>", pointed at the repository's "Content", is the fix. See step 2 of Preparing the game content.
  • No "Source.pak" in …, the disc image will not be marked as prebaked at configure time (PlayStation 2 and Nintendo 64) is expected for a tree built with the console profile, which names that package "Prebaked.pak" already and has nothing to rename, see Preparing the content tree. It is only a real problem when neither package is in the tree.
  • A file removed from the content tree is still on the disc. The staging directory the image is authored from is added to and never cleared on the Dreamcast and the PlayStation 2 (the Nintendo 64 rebuilds its own), so delete "<build dir>/cd/" and build again, see Building.
  • The intro cinematic crawls on the Dreamcast. The tree was built with --target=console instead of --target=dreamcast, so the cinematics are the original zlib-compressed files and the SH-4 is inflating 640x480 pixels per frame, see Preparing the content tree.
  • Text is missing, tilesets look wrong, or a level refuses to load. Almost always a stale content tree — regenerate it with an AssetPacker built from the current sources. On the Wii and GameCube also make sure the card really was rewritten, an emulator's SD image and a physical card both keep an old copy convincingly.
  • No CDI image after a Dreamcast build. mkdcdisc was not on PATH when CMake configured the build directory, see Building.
  • An empty log. Each console needs its receiver enabled — the serial console in Flycast's configuration, a USB Gecko in slot B on the PowerPC consoles, PPSSPP's stdout. On the Dreamcast and the PSP the startup messages go to the screen rather than to the log, which is exactly the window in which a crash would otherwise be invisible.
  • std::bad_alloc, an sbrk message, or a CPU exception with no explanation on the Dreamcast or GameCube is main memory running out, Out of PVR memory allocating … on the Dreamcast is video memory, see Limits and known issues.
  • PPSSPP runs an old build. It boots from its own configured memory stick, see Deploying and running.
  • CMake cannot find the compiler on the Dreamcast. "environ.sh" was not sourced in that shell, see Toolchain.
  • A fully loaded sound is cut short on the Dreamcast. An AICA channel addresses 65534 samples at most, see Limits and known issues.
  • Sound effects are noise on the Wii or GameCube. The buffer formats are native-endian, so a reader that emits little-endian samples on a big-endian console produces exactly this, see Audio backends.
  • "Dead FIFO commands queue state" in RPCS3. Most often the emulator rather than the game — a headless run, a host with no GPU or one that is swapping all provoke it, see Deploying and running. When it is genuine it usually means memory the GPU is still reading was reused, or the command ring wrapped without being flushed.
  • A shader reports "has no compiled RSX microcode" on the PlayStation 3. That variant was rejected by the vp40/fp40 profiles when the table was generated, which is a build-time fact and not something that can change on the console, see Limits and known issues.
  • ppu-gcc refuses to run, with "environment variable 'PSL1GHT' not defined". It reads that variable itself, so it has to be exported in the shell that runs the build, see Toolchain.
  • The PlayStation 3 build does not link, with DeflateStream unresolved. The portlibs were not built — zlib is a hard requirement, see Toolchain.