OpenSNES and PVSnesLib target the same machine and share ancestry, so most of a port is mechanical. What bites is the handful of places where the two differ silently — code that compiles, links, boots, and then draws the wrong thing. This page is those places, in the order they usually hurt.
If you are reading this with a PVSnesLib project open, work top to bottom: the build system first, then the five traps, then the API table.
Almost all of the surface. Both SDKs give you consoleInit, setMode, bgSetGfxPtr, bgSetMapPtr, oamSet, WaitForVBlank, the same PPU register model, the same VRAM layout decisions and the same .pic / .pal / .map asset trio produced by a gfx4snes-shaped converter. A simple sprite demo often ports by changing the Makefile and nothing else.
WaitForVBlank keeps its capital W for that reason: it is the only function of the library not written in camelCase, and it will not be renamed.
| Area | PVSnesLib | OpenSNES | Where it bites |
|---|---|---|---|
| Compiler | tcc816 | cc65816 (cproc + QBE) | Argument push order, refused constructs |
| Argument push | right to left | left to right | Hand-written ASM callees read the wrong slots |
| int | 2 bytes | 2 bytes | Same |
| long | 4 bytes | 4 bytes | Same |
| Modules | linked wholesale | opt-in LIB_MODULES | Link errors until you list what you use |
| Text output | consoleDrawText | textPrintAt + textFlush | Nothing appears without the flush |
| Vertical scroll | raw value to the register | lib subtracts 1 for you | Everything is one line off |
| Const data | wherever it lands | asset banks, far reads | A cast away from const reads garbage |
| Bulk RAM | plain globals | plain under $2000, else FAR | A big buffer silently wrong-banked |
An OpenSNES example Makefile is short, and the module list is the part that is new:
LIB_MODULES is the one real change of habit. OpenSNES links only the modules you name, so an undefined reference at link time means "add the module", not "the function is missing". Dependencies resolve themselves: listing text pulls in dma, background and console.
Set USE_SRAM := 1 for battery saves, USE_SA1 := 1 or USE_SUPERFX := 1 for the coprocessors, USE_HIROM := 1 for a HiROM map.
This is the one that breaks ported assembly. cc65816 pushes the first argument first, so after the usual php / phb prologue the first argument is at 6,s, the second at 8,s, and so on — the mirror image of PVSnesLib.
Pure C ports never notice. Any .asm file you bring over that reads its arguments off the stack must have its offsets rewritten. compiler/ABI.md is the reference, and make lint-asm-abi checks every hand-written library routine against its C prototype, so a mistake fails the build instead of corrupting a register at runtime.
PVSnesLib code often turns the screen on early and loads VRAM afterwards. OpenSNES expects the whole setup to happen during forced blank:
VRAM writes during active display are silently dropped by the PPU, so an early setScreenOn() shows up as missing or garbage tiles on the first frames, with no error anywhere.
Background palettes live at CGRAM 0-127, sprite palettes at 128-255. A sprite palette uploaded to offset 0 gives you a sprite drawn in the background's colours — a bug that looks like an art problem.
The PPU never outputs scanline 0, so a raw y written to BGnVOFS shows row y+1. PVSnesLib writes the raw value and leaves the correction to you; OpenSNES does it for you in bgSetScroll, the map module and mode7SetScroll.
So a port that carefully passed y - 1 now scrolls one line too far. Pass the row you actually want. The exception is data that reaches the register without the library — an HDMA table of BGnVOFS values still needs its own -1.
Since the bank-$00 flip, C const data (arrays, string literals, const structs) is placed in the asset banks and every C read of it is a far read. That is invisible and free — until you cast the const away and read through a plain pointer, which reads bank $00 and returns garbage. The link fails on it (devtools/check_bank_reads.py), so you get told; the fix is to keep the const, or to pass the data to a library function as a far pointer.
The RAM twin: plain C globals must sit below $2000. Anything bigger — a tilemap buffer, an entity pool, an HDMA table — is declared FAR and lives in $7E:2000-$FFFF. See Far RAM Tutorial — FAR objects beyond the 8 KB band.
These stop the build with a message naming the feature. None of them is miscompiled silently, and each has a mechanical workaround:
| Refused | Workaround |
|---|---|
| Struct passed or returned by value | Pass a pointer to it |
| Struct assignment by value (a = b) | Copy the fields, or memcpy (<snes/string.h>, module string) |
| Variadic functions (...) | Fixed-arity wrappers |
| Inline assembly inside C | A separate .asm file with a C prototype |
printf is not in the core library by design. Use textPrint, textPrintU16 and textPrintHex, or the debug channel (Interactive Debugging with luna) when you need output to the host rather than the screen.
| PVSnesLib | OpenSNES | Note |
|---|---|---|
| consoleInit() | consoleInit() | Same |
| consoleDrawText(x, y, s) | textPrintAt(x, y, s) then textFlush() | The flush is what uploads the tilemap |
| consoleSetTextVramBGAdr etc. | textInit, textLoadFont, textModeInit | Split into explicit steps |
| oamSet(...) | oamSet(id, x, y, tile, palette, priority, flags) | Seven arguments; check the order |
| oamSetEx, oamSetVisible | oamSetSize, oamHide, oamSetXY | Split by concern |
| oamInitGfxSet(...) | oamInitGfxSet(...) | Same shape |
| bgSetScroll(bg, x, y) | bgSetScroll(bg, x, y) | The -1 is applied for you (trap 4) |
| spcBoot, spcLoad, spcPlay | snesmodInit, snesmodLoadModule, snesmodPlay | Module snesmod; the driver is the same SNESMOD |
| spcProcess() | snesmodProcess() | Call once per frame |
| padsCurrent(pad) | padHeld(pad) | Also padPressed, padReleased |
| rand(), srand(s) | rngNext(), rngSeed(s) | Not libc's: 1-65535, a 16-bit LFSR. The libc names went at 0.49 |
| LzssDecodeVram(s, a) | lzssDecodeVram(s, a) | Lower-case l; the old spelling is deprecated |
| hdmaEnable(1 << ch) | hdmaEnable(ch), or hdmaEnableMask(1 << ch) for several | A channel number since 0.49, like the other hdma* calls; a mask above 7 is refused — see Upgrading from 0.48 to 0.49 |
| oamDrawMeta(id, x, y, m, tile, pal, size) | oamDrawMetasprite(id, x, y, m, &style, 0) | tile, pal, size are fields of a static const MetaspriteStyle |
| pvsneslibfont | textLoadFont() with your own font | No implicit font |
When a name is not in this table, search API index — by what you are trying to do — it is organised by what you are trying to do rather than by module.
Every PVSnesLib header was compared name by name against lib/include/snes/ on 2026-10-05. Most absent names are renamings covered above or by the module tables below; the rest are listed here so that a port knows what it has to write itself, and whether that is a decision or a gap.
| PVSnesLib | OpenSNES | Status |
|---|---|---|
| consoleDrawText(x, y, "d", v), sprintf | textPrintAt, textPrintU16, textPrintHex | Decided. No printf family in the library (PHILOSOPHY, non-goals); formatted output is three small printers |
| scoreAdd, scoreClear, scoreCmp, scoreCpy (BCD score strings) | — | Decided. Game-side code; examples/games/tetris keeps its score in plain integers and prints it with textPrintU16 |
| pixSetMode, pixSetPixel (pixel plotting on a BG) | — | Not provided. No framebuffer on the 65816 side; the Super FX module draws bitmaps (gsuPresent) |
| WaitNVBlank(n) | a loop over WaitForVBlank() | Decided. One primitive |
| getFPScounter, showFPScounter | profileGetLagFrames, getFrameCount | Decided. The library counts, the game displays |
| consoleCopySram*, consoleLoadSram* | sramSave, sramLoad, *Offset, sramChecksum | Renamed, module sram |
| consoleRegionIsOK | getRegion(), isPAL() | Renamed; make test-pal runs the corpus under PAL |
| consoleUpdate | textFlush() | Renamed: the text module uploads on request, the NMI does not scan |
| bgInitMapSet | bgSetMapPtr + dmaCopyVram of the map | Split: VRAM address and upload are two steps |
| bgInitTileSetLz | lzssDecodeVram + bgSetGfxPtr | Split |
| bgInitMapTileSet7, setMode7*, dmaCopyVram7 | module mode7: mode7Init, mode7Rotate, mode7SetScale, mode7Transform, mode7SetScroll | Renamed, own module |
| bgSetEnable, bgSetDisable, *Sub | setMainScreen(mask), setSubScreen(mask) | One call per screen instead of one per layer |
| bgSetWindowsRegions, COLWIN_*, MSWIN_* | module window | Own module |
| setModeHdma* (gradient, shading, waves, window) | module hdma: hdmaGradient, hdmaColorGradient, hdmaBrightnessGradient, hdmaWaveH, hdmaWindowShape, hdmaIrisWipe | Own module; each helper takes a channel |
| setParallaxScrolling | hdmaParallax | Renamed |
| setFadeEffect, setFadeEffectEx | fadeIn, fadeOut, setBrightness | Renamed |
| setColorEffect, setColorIntensity | module colormath: colorMathInit, colorMathTint, colorMathShadow, colorMathSetFixedColor | Own module |
| setMosaicEffect | module mosaic: mosaicSetSize, mosaicFadeIn, mosaicFadeOut | Own module |
| setPalette, setPaletteColor, getPalette* | dmaCopyCGram for a block, setColor(index, color) for one colour | Renamed. Reading CGRAM back (getPalette*) is not provided: keep the palette you uploaded |
| dmaClearVram, dmaFillVram8/16 | dmaClearVRAM, dmaFillVRAM | Renamed |
| dmaCopyOAram | oamUpdate() (the NMI uploads the shadow) | The OAM upload is the sprite module's, not a DMA call |
| dmaCopySpr16Vram, dmaCopySpr32Vram | oamInitGfxSet | One call for the sprite sheet |
| oamMetaDraw8/16/32, oamFix8/16/32Draw | oamDrawMetasprite(id, x, y, m, &style, 0) | One call; the size is a field of the MetaspriteStyle |
| oamMetaDrawDyn8/16/32, oamDynamicMetaDraw, oamInitDynamicSprite* | module sprite_dynamic: oamDynamicInit, oamDynamicDraw, oamMetaDrawDyn, oamDynamicSetSize | Own module |
| oamSetEx, oamSetAttr, oamFlip, oamSetGfxOffset | oamSetSize, oamHide, oamSetTile, oamSetXY, the flags argument of oamSet | Split by concern |
| oamGetX, oamGetY | oamGetX(id), oamGetY(id) | Same names (since 2026-10-09); they return what was last set, the 9-bit X and the y given to oamSetY, not the raw OAM byte |
| padsCurrent/Down/Up/Clear | padHeld, padPressed, padReleased | Renamed |
| detectMouse, initMouse, mouseCycleSensitivity* | mouseInit, mouseIsConnected, mouseSetSensitivity | Renamed |
| detectSuperScope | scopeInit, scopeIsConnected | Renamed |
| detectMPlay5 | padIsConnected(2..4) | Renamed |
| spcPlaySound, spcPlaySoundV, spcLoadEffect, spcEffect, spcSetSound* | snesmodLoadEffect, snesmodPlayEffect | Renamed; the sound table API is snesmodSetSoundTable |
| spcGetMusicPosition, spcFadeModuleVolume, spcPauseMusic, spcResumeMusic, spcStop, spcSetModuleVolume, spcSetBank | the snesmod* names of the same words (snesmodGetPosition, snesmodFadeVolume, snesmodPause, snesmodResume, snesmodStop, snesmodSetModuleVolume, snesmodSetSoundbank) | Renamed |
| REG_* aliases (REG_A1B0, REG_APU00, REG_VMADDLH, ...) | snes/registers.h: one name per register, the official mnemonics | Renamed |
"Decided" means the library will not grow the function; "assumed" means it could, nobody asked yet — open an issue with the game that needs it.
The converters take the same shapes of input and, for graphics, the same flags PVSnesLib users know:
Maps exported from Tiled go through tmx2snes, Aseprite sheets through aseprite2snes, audio through smconv and wav2brr. Each has a page under The OpenSNES toolbox, and make/common.mk runs them for you when the source file is listed in the example's Makefile.
Port one screen, then compare it with the original before going further. The project's own debugging rule applies to ports especially: the bug is almost never in the example. Work down the layers — build system, library call, asset conversion, compiler — instead of patching the symptom in game code.
Common questions after a port are answered in Frequently asked questions.