Odin + 3DS + .3dx — Nintendo 3DS homebrew development in Odin. Works by creating bindings for Ctru, Citro2D and Citro3D. Includes, examples, tools, and a project template to get started fast.
Special thanks to Ske (https://codeberg.org/ske/3ds-odin) for creating the original Codeberg repository and proof-of-concept that this project builds on.
(Developer Note) This is still very much so a work in progress.
(Developer Note) These .md files were written by Claude as part of the AI testing experiment I was doing. Information in here is based on the assumptions and informaiton it uncovered.
Not everything may be correct, and something may be missing nuances or are simply wrong entirely. As you should with all AI explinations, take it with a grain of salt
until you are able to verify everything you need yourself directly. It is better to use it as a guide rather than a source of truth.
O3DX explores how far Odin can be pushed for 3DS homebrew development.
This project is an experiment in using AI (Claude) as a long-term collaborator on a non-trivial engineering project — writing bindings, debugging ABI issues, designing tools, building a full game, and maintaining documentation across many sessions. Claude acted as a pair programmer: researching the 3DS hardware and library internals, diagnosing bugs through binary analysis, writing and refactoring code, and authoring docs — while the human drove direction, tested on real hardware, and made final calls.
Unless not possible, all examples use a build.bat file instead of a Makefile.
Note:
main.cfiles may show import errors for 3DS headers depending on your IDE. If DevKitPro is installed correctly you can ignore these — the editor cannot locate the headers but GCC can.
- Odin Language
- DevKitPro — with
3ds-devinstalled - GCC (arm-none-eabi from DevKitPro — MSVC is not tested)
3ds-odin/
├── lib/ ← Shared Odin package library (use these in your own projects)
│ ├── bridge_utils.h ← Shared u2f / f2u helpers for ABI bridge .c files
│ ├── ctru/
│ │ ├── ctru.odin ← libctru bindings (gfx, input, console, romfs, etc.)
│ │ └── bridge.c ← Wrappers for static-inline libctru functions
│ ├── c2d/
│ │ ├── types.odin ← Citro2D types (C2D_Image, C2D_ImageTint, C2D_Sprite, …)
│ │ ├── base.odin ← Core drawing, tinting, view transforms ✅
│ │ │ ├── text.odin ← Text rendering ✅ (Phase 3)
│ │ ├── font.odin ← Font loading ✅ (Phase 4)
│ │ ├── sprite.odin ← Sprite helpers ✅ (Phase 4)
│ │ ├── spritesheet.odin ← SpriteSheet loading ✅ (Phase 4)
│ │ └── bridge.c ← ABI bridge for all float-param Citro2D functions
│ └── c3d/
│ ├── types.odin ← Citro3D types + all GPU_* enums ✅
│ ├── base.odin ← Full C3D foreign bindings (declared) ✅
│ ├── math.odin ← Full FVec/Mtx/Quat math library ✅ (Phase 8)
│ └── bridge.c ← ABI bridge for all float-param Citro3D functions
│
├── Examples/
│ ├── Shapes-Example/ ← Basic shapes via C2D ✅
│ ├── Image-Example/ ← Loading and displaying a .t3x sprite ✅
│ ├── Input-Example/ ← Button, circle pad, touch input ✅
│ ├── DrawLine-Example/ ← C2D_DrawLine ✅
│ ├── DrawRectSolid-Example/ ← C2D_DrawRectSolid ✅
│ ├── DrawEllipseSolid-Example/ ← C2D_DrawEllipseSolid ✅
│ ├── DrawImageAtRotated-Example/ ← C2D_DrawImageAtRotated ✅
│ ├── ViewTranslate-Example/ ← C2D_ViewTranslate ✅
│ ├── ViewScale-Example/ ← C2D_ViewScale ✅
│ ├── ViewRotate-Example/ ← C2D_ViewRotate ✅
│ ├── ViewShear-Example/ ← C2D_ViewShear ✅
│ ├── PlainImageTint-Example/ ← C2D_PlainImageTint ✅
│ ├── AlphaImageTint-Example/ ← C2D_AlphaImageTint ✅
│ ├── SetImageTint-Example/ ← C2D_SetImageTint ✅
│ ├── Color32f-Example/ ← C2D_Color32f ✅
│ ├── Fade-Example/ ← C2D_Fade ✅
│ ├── SetTintMode-Example/ ← C2D_SetTintMode ✅
│ ├── DrawText-Example/ ← C2D_DrawText (3 scales) ✅
│ ├── DrawTextColor-Example/ ← C2D_DrawTextColor ✅
│ ├── DrawTextAlign-Example/ ← C2D_AlignLeft/Center/Right ✅
│ ├── DrawTextWrap-Example/ ← C2D_DrawTextColorWrap ✅
│ ├── DrawTextDynamic-Example/ ← TextBufClear + snprintf counter ✅
│ ├── SpriteSheet-Example/ ← Full sprite-sheet pipeline: C2D_SpriteSheetLoad → C2D_SpriteFromSheet + all sprite helpers ✅
│ ├── CustomFont-Example/ ← C2D_FontLoadSystem + C2D_TextFontParse ✅
│ ├── Triangle-Example/ ← Citro3D: vertex buffers, PICA200 shader, per-vertex colour ✅
│ ├── Texture-Example/ ← Citro3D: procedural texture, Morton tiling, C3D_TexBind ✅
│ ├── DepthTest-Example/ ← Citro3D: depth test, PICA200 reverse-Z, GEQUAL convention ✅
│ ├── AlphaBlend-Example/ ← Citro3D: src-alpha blending, back-to-front draw order ✅
│ ├── SpinningCube-Example/ ← Citro3D: 3D perspective, model matrix, spinning cube ✅ (Phase 8)
│ ├── Lighting-Example/ ← Citro3D: PICA200 hardware lighting, normalquat, LightEnv/Light/LUT ✅ (Phase 9)
│ ├── Audio-Example/ ← NDSP audio: WAV from romfs, looping playback, volume control ✅ (Phase 11)
│ ├── SaveData-Example/ ← FS service: read/write save struct to SD card ✅ (Phase 11)
│ ├── Camera-Example/ ← CAM service: dual-camera live feed, BGR8 blit, 3D slider stereo ✅ (Phase 12)
│ ├── Microphone-Example/ ← MIC service: PCM16 ring buffer, amplitude VU meter ✅ (Phase 12)
│ ├── NFC-Example/ ← NFC service: amiibo/NFC tag detection ✅ (Phase 12)
│ ├── UDS-Example/ ← UDS service: local wireless network scan/join ⚠️ (Phase 12 — untested, requires two devices)
│ └── Games/
│ └── FlappyClone/ ← Full 2D game: sprites, animation, physics, audio, parallax, save data ✅
│
├── tools/
│ ├── tritex.odin ← PNG/JPEG → .t3x converter (renamed from png2t3x); atlas + .t3s support
│ ├── tritex.exe ← Compiled binary (build with: odin build tools/tritex.odin -file -out:tools/tritex.exe)
│ ├── howto.md ← tritex usage reference
│ └── mp3towav/ ← MP3 → WAV converter (uses vendor:miniaudio)
│ └── mp3towav.odin
│
└── docs.md ← Full developer reference: bindings, nuances, gotchas
odin build . and odin run . alone cannot produce a 3DS executable.
The build.bat in each example drives the full pipeline:
Odin source
│ odin build . -target:freestanding_arm32 -o:speed -build-mode:asm -no-entry-point -min-link-libs -no-thread-local
▼
ARM assembly (.S)
│ arm-none-eabi-gcc -march=armv6k -mfloat-abi=hard
▼
Object files (.o) ←── also: main.c + lib/*/bridge.c
│ arm-none-eabi-gcc -specs=3dsx.specs -lcitro2d -lcitro3d -lctru
▼
ELF executable
│ 3dsxtool + smdhtool
▼
app.3dsx (run on hardware or in Citra)
Odin's freestanding_arm32 target generates soft-float ABI code: float arguments travel in general-purpose registers (r0, r1, …).
DevKitARM's libctru / citro2d / citro3d are compiled with hard-float ABI: float arguments are expected in VFP registers (s0, s1, …).
These two conventions are completely incompatible. Calling a hard-float function directly from soft-float Odin corrupts every floating-point argument.
The fix is a set of wrapper functions in each bridge.c file, compiled by GCC with -mfloat-abi=hard, that:
- Accept float arguments as
uint32_t(integer registers — both ABIs agree on those) - Reinterpret the bit-pattern as
floatusingmemcpy(theu2f()helper inbridge_utils.h) - Call the real library function with proper hard-float arguments
Odin (soft-float) bridge.c (hard-float GCC) libctru (hard-float)
───────────────── ───────────────────────── ────────────────────
f32 bits → r0 → uint32_t x → u2f() → float → float in s0 ✓
f32 bits → r1 → uint32_t y → u2f() → float → float in s1 ✓
u32 → r2 → uint32_t clr (pass-through) → u32 in r2 ✓
Functions that only take integer or pointer arguments (the majority of libctru) can be called directly with a plain foreign declaration — no bridge needed.
Static-inline C functions (e.g. romfsInit, C2D_SceneBegin, all sprite helpers) have no exported linker symbol, so they also require a bridge wrapper.
The ARM32 AAPCS requires u64 arguments to start at an even-numbered register (r0, r2, …).
When a u64 argument is preceded by an odd number of register-sized arguments, one register must
be skipped as padding so the u64 lands on an even boundary.
Odin's freestanding_arm32 target does not insert this padding, so any libctru function with
a u64 parameter in an "odd" position receives corrupted arguments at runtime — no compile-time
warning is produced.
Rule of thumb: a u64 parameter is safe when it is either the first argument or when it is
preceded by exactly two register-sized arguments (pointers or u32s). It is unsafe when it is
preceded by exactly one register-sized argument.
Known affected functions in lib/ctru/fs.odin: FSUSER_OpenFile and FSFILE_SetSize.
See the SaveData-Example implementation notes in Phase 11 for the full analysis and workaround.
Rather than duplicating bindings in every example, all declarations live in lib/:
| Package | Import path | Contains |
|---|---|---|
ctru |
../../lib/ctru |
gfx, input, APT (full), console, romfs, irrst, SVC (full), NDSP audio, FS filesystem |
c2d |
../../lib/c2d |
All Citro2D types, drawing, tinting, view transforms |
c3d |
../../lib/c3d |
All Citro3D types, GPU enums, frame/texture/effect functions |
audio |
../../lib/audio |
WAV loading (linearAlloc-backed) and NDSP clip playback helpers |
Example usage:
import ctru "../../lib/ctru"
import c2d "../../lib/c2d"
import c3d "../../lib/c3d"
import audio "../../lib/audio"
top := c2d.C2D_CreateScreenTarget(ctru.GFX_TOP, ctru.GFX_LEFT)
c2d.C2D_DrawRectSolid(10, 10, 0, 80, 60, c2d.C2D_Color32(0xFF, 0, 0, 0xFF))Short-form aliases: Every
C2D_- andC3D_-prefixed function, type, and constant has a shorter alias in its package that drops the prefix — soc2d.C2D_DrawSpritecan be written asc2d.DrawSprite, andc3d.C3D_Initasc3d.Init. The examples in this repository were written before the aliases existed and use the full prefixed names throughout. Both styles compile identically; new code should prefer the short form. Seedocs.mdfor the full list.
Each example's build.bat compiles the three bridge objects (ctru_bridge.o, c2d_bridge.o, c3d_bridge.o) from the shared lib/ directory and links them alongside the Odin assembly.
Migrated all bindings out of per-example files into a shared lib/ directory.
All three original examples (Shapes, Image, Input) updated and verified.
Every tested function has its own self-contained example in Examples/.
Implemented and tested:
C2D_DrawLine,C2D_DrawRectSolid,C2D_DrawEllipseSolidC2D_DrawImageAtRotatedC2D_ViewTranslate,C2D_ViewScale,C2D_ViewRotate,C2D_ViewShearC2D_PlainImageTint,C2D_AlphaImageTint,C2D_SetImageTintC2D_Color32f,C2D_Fade,C2D_SetTintMode
Also implemented (tested via Shapes/Image examples):
C2D_DrawRectangle,C2D_DrawTriangle,C2D_DrawCircle,C2D_DrawEllipseC2D_DrawImage,C2D_DrawImageAtC2D_DrawSprite,C2D_DrawSpriteTintedC2D_TopImageTint,C2D_BottomImageTint,C2D_LeftImageTint,C2D_RightImageTintC2D_ViewReset,C2D_ViewSave,C2D_ViewRestore,C2D_ViewRotateDegreesC2D_Color32,C2D_SceneSize,C2D_SceneTarget
Bindings in lib/c2d/text.odin, bridge wrappers in lib/c2d/bridge.c.
Key implementation note: C2D_DrawText is a C variadic function. The bridge provides four
concrete wrappers covering every call signature (plain, +colour, +wrap, +colour+wrap) so Odin
never has to touch the variadic ABI directly. wrapWidth is promoted to double inside the
bridge because that is how va_arg reads it on ARM32.
| Function | Bridge needed? | Notes |
|---|---|---|
C2D_TextBufNew |
No | |
C2D_TextBufResize |
No | |
C2D_TextBufDelete |
No | |
C2D_TextBufClear |
No | |
C2D_TextBufGetNumGlyphs |
No | |
C2D_TextParseLine |
No | |
C2D_TextFontParseLine |
No | |
C2D_TextParse |
No | |
C2D_TextFontParse |
No | |
C2D_TextOptimize |
No | |
C2D_TextGetDimensions |
Yes | float scaleX, scaleY, outW, outH |
C2D_DrawText |
Yes | c2d_draw_text — no varargs |
C2D_DrawText + colour |
Yes | c2d_draw_text_color |
C2D_DrawText + wrap |
Yes | c2d_draw_text_wrap |
C2D_DrawText + colour + wrap |
Yes | c2d_draw_text_color_wrap |
Examples created: DrawText-Example, DrawTextColor-Example, DrawTextAlign-Example,
DrawTextWrap-Example, DrawTextDynamic-Example
Font (lib/c2d/font.odin):
| Function | Bridge needed? | Notes |
|---|---|---|
C2D_FontLoad, FontLoadFromMem, FontLoadFromFD, FontLoadFromHandle, FontLoadSystem |
No | |
C2D_FontFree, FontSetFilter |
No | |
C2D_FontGlyphIndexFromCodePoint, FontGetCharWidthInfo, FontGetInfo |
No | |
C2D_FontCalcGlyphPos |
Yes | float scaleX, scaleY |
SpriteSheet (lib/c2d/spritesheet.odin):
| Function | Bridge needed? | Notes |
|---|---|---|
C2D_SpriteSheetLoad, SpriteSheetLoadFromMem, SpriteSheetFromFD, SpriteSheetLoadFromHandle, SpriteSheetFree |
No | exported symbols |
C2D_SpriteSheetCount |
Yes | static inline |
C2D_SpriteSheetGetImage |
Yes | static inline; returns via out-pointer to avoid struct-return ABI ambiguity |
Sprite helpers (lib/c2d/sprite.odin) — all static inline, all need bridge wrappers:
| Function | Notes |
|---|---|
C2D_SpriteFromImage |
passes C2D_Image struct by value through bridge |
C2D_SpriteFromSheet |
bridge calls static inline SpriteFromImage internally |
C2D_SpriteSetPos, SpriteSetScale, SpriteSetCenter, SpriteSetCenterRaw |
float x, y |
C2D_SpriteSetRotation, SpriteSetRotationDegrees |
float angle |
C2D_SpriteSetDepth |
float depth |
C2D_SpriteMove, SpriteScale, SpriteRotate, SpriteRotateDegrees |
delta variants |
Examples created: SpriteSheet-Example, CustomFont-Example
Note on
.t3xfiles:C2D_SpriteSheetLoadcallsTex3DS_TextureImportStdiointernally and requires a.t3xfile in the standard Tex3DS binary format. The DevKitProtex3dstool has a known bug on Windows that produces corrupt output.tools/tritex.exe(formerlypng2t3x) is a drop-in replacement that writes the correct format and supports single images, texture atlases (-a), and.t3sinclude files (-i). All image-loading examples useTex3DS_TextureImportStdioin theirmain.c, andSpriteSheet-Exampleuses the fullC2D_SpriteSheetLoad→C2D_SpriteFromSheetpipeline end-to-end.
- ✅ Render triangle to screen (vertex buffers, PICA200 shader, per-vertex colour)
- ✅ Texture mapping (C3D_Tex, TexEnv) — validated in Phase 6
- ✅ Depth testing and alpha blending — validated in Phase 7
Getting a triangle rendering in Citro3D from Odin required working through several non-obvious issues in the Odin → C ABI bridge. These are documented here so future phases don't have to rediscover them.
C3D_FVec field layout is reversed
The C struct is declared as { float w; float z; float y; float x; } (offsets 0, 4, 8, 12).
Odin's binding declares it as { x, y, z, w: f32 } with the same offsets, so the field names are
swapped: Odin .x = C .w at offset 0. When writing struct literals the positional order is
{x, y, z, w} — remember that .x is actually the W component in memory.
Mtx_OrthoTilt bridge does not write to the matrix at runtime
The bridge function assembles correct arguments (verified by disassembly and nm) but the
projection matrix remains all-zero after the call when running in Citra. The root cause is a
soft-float / hard-float ABI edge case that manifests only at runtime. Workaround: set the matrix
manually in Odin using C3D_FVec struct literals, computing the values by hand from the
Mtx_OrthoTilt source. The full derivation is in Examples/Triangle-Example/main.odin.
PICA200 framebuffer is portrait (240 × 400)
The GPU renders into a 240 × 400 portrait buffer. Mtx_OrthoTilt (and the manual equivalent)
applies a 90° clockwise rotation so that the app can work in 400 × 240 landscape coordinates.
After the tilt, triangle winding appears reversed relative to the landscape coordinate system —
disable face culling with C3D_CullFace(.NONE) or re-wind your vertices accordingly.
BufInfo permutation encoding
BufInfo_Add takes a permutation nibble-field. For two buffer-loaded attributes (position → v0,
colour → v1) the value is 0x10: nibble 0 = slot 0 → register 0 (v0), nibble 1 = slot 1 →
register 1 (v1).
All texture management bindings validated with Texture-Example.
Implemented and tested:
C3D_TexInit(bridge — static inline)C3D_TexLoadImage,C3D_TexFlush,C3D_TexDeleteC3D_TexBind(bind to texture unit)C3D_TexSetFilter,C3D_TexSetWrap(bridge — static inline, set mag/min filter and U/V wrap)C3D_Tex2DGetImagePtr(bridge — returns raw GPU pixel buffer pointer for direct writes)C3D_TexShadowParams,C3D_TexSetLodBias(bridge — float params, declared; not tested in this phase)
Also declared (not yet example-tested):
C3D_TexInitWithParams,C3D_TexGenerateMipmap
Morton (Z-curve) tiling
The PICA200 GPU does not accept scanline-order texture data. Pixels must be
stored in Morton (Z-curve) order within 8×8 tiles, with tiles arranged
row-major. The interleaving formula for a pixel at (px, py) within a tile:
morton = (px&1) | ((py&1)<<1) | ((px&2)<<1) | ((py&2)<<2) | ((px&4)<<2) | ((py&4)<<3)
The same algorithm is used in tools/tritex.odin for converting PNG files.
ABGR byte order
GPU_RGBA8 stores each pixel as four bytes in [A, B, G, R] order at consecutive
addresses (equivalent to a little-endian 0xRRGGBBAA u32 word).
Direct-write pattern
C3D_TexInit allocates the GPU-visible buffer in linear heap. Rather than
allocating a separate staging buffer and going through C3D_TexLoadImage (GX DMA),
writing directly to tex.data via C3D_Tex2DGetImagePtr and then calling
C3D_TexFlush is simpler and matches the official citro3d example pattern.
C3D_TexFlush calls GSPGPU_FlushDataCache to flush CPU caches before the GPU
reads the texture.
All effect bindings validated with two focused examples.
Implemented and tested:
C3D_DepthMap(bridge — float params: zScale, zOffset)C3D_DepthTest(enable, test function, write mask)C3D_AlphaBlend(blend equations and factors for colour and alpha)C3D_CullFace(already used since Phase 5)
Declared but not yet example-tested:
C3D_StencilTest,C3D_StencilOpC3D_AlphaTestC3D_ColorLogicOp,C3D_FragOpModeC3D_EarlyDepthTest
PICA200 uses reverse-Z with GEQUAL
The PICA200 GPU clips z to the range [-w, 0] rather than [-w, w]. With
C3D_DepthMap(true, -1.0, 0.0) the depth buffer value is computed as
depth = -z_clip. Near geometry (small vertex-z → z_clip ≈ -1) gets a
high depth value (≈1); far geometry gets a low depth value (≈0).
Use GPU_GEQUAL as the test function: "pass if new_depth >= stored_depth".
This means nearer geometry (higher depth) beats farther geometry (lower depth).
Clear the depth buffer to 0 each frame so the first geometry drawn always
passes (any depth value ≥ 0).
c3d.C3D_DepthMap(true, -1.0, 0.0) // near → depth 1, far → depth 0
c3d.C3D_DepthTest(true, .GEQUAL, .ALL) // nearer wins
// ...
c3d.C3D_RenderTargetClear(top, c3d.C3D_CLEAR_ALL, bgColour, 0) // clearDepth=0Vertex alpha flows through TexEnv to the blend stage
The vertex color attribute carries alpha in its .w component. The vertex
shader passes it through unchanged (mov out1, vtxclr). With TexEnv in
C3D_Both / PRIMARY_COLOR / REPLACE mode the full RGBA reaches the fragment
stage, where C3D_AlphaBlend uses it:
c3d.C3D_AlphaBlend(.ADD, .ADD,
.SRC_ALPHA, .ONE_MINUS_SRC_ALPHA,
.SRC_ALPHA, .ONE_MINUS_SRC_ALPHA)Back-to-front draw order is mandatory
Alpha blending reads the existing framebuffer colour as the destination.
Geometry must be submitted back-to-front; depth test should be disabled
(C3D_DepthTest(false, .ALWAYS, .ALL)) to avoid farther transparent surfaces
failing the depth test before they can blend.
Full implementation of maths.h in lib/c3d/math.odin:
- Static-inline functions reimplemented natively in Odin (correct field-mapping)
- Exported functions bridged via
lib/c3d/bridge.c(float ABI and FVec-by-value wrappers) - Demo:
SpinningCube-Example— 6-face colour-coded cube with live Y-axis rotation
Static-inline → Odin-native:
FVec4_New/Add/Subtract/Negate/Scale/PerspDivide/Dot/Magnitude/NormalizeFVec3_New/Dot/Magnitude/Normalize/Add/Subtract/Distance/Scale/Negate/CrossMtx_Zeros/Copy/Diagonal/Identity/Add/Subtract/MultiplyFVecHQuat_New/Negate/Add/Subtract/Scale/Normalize/Dot/Identity/Conjugate/InverseFVec3_CrossQuat
Exported → bridged:
Mtx_Transpose,Mtx_Multiply(pointer-only — direct foreign import, no bridge needed)Mtx_Inverse→mtx_inverse(returns float bits as u32)Mtx_MultiplyFVec3/4→ output-pointer bridges (FVec return value)Mtx_FromQuat,Mtx_LookAt,Mtx_Rotate→ FVec/FQuat by-value bridgesMtx_Translate/Scale/RotateX/Y/Z→ float parameter bridgesMtx_Ortho/Persp/OrthoTilt/PerspTilt/PerspStereo/PerspStereoTilt→ full projection bridgesQuat_Multiply/Pow/CrossFVec3/Rotate/RotateX/Y/Z/FromMtx/FromPitchYawRoll/LookAt/FromAxisAngleodin_sqrtfbridge (libm sqrtf — needed by Magnitude/Normalize)
All math respects the reversed C3D_FVec field layout
The C3D_FVec C struct stores { float w; float z; float y; float x; } at offsets {0, 4, 8, 12}.
Odin declares { x, y, z, w: f32 } at the same offsets, so field names are swapped:
Odin .x = logical W (offset 0), Odin .w = logical X (offset 12).
Every static-inline function in math.odin accounts for this. The helper FVec4_New(lx, ly, lz, lw) is the canonical constructor:
FVec4_New :: proc(lx, ly, lz, lw: f32) -> C3D_FVec {
return C3D_FVec{ x = lw, y = lz, z = ly, w = lx }
}Component-wise operations (Add, Subtract, Scale, Dot) use Odin field names directly since the same offset mapping applies symmetrically to both operands.
FVec3 operations zero out the logical W slot (Odin .x)
FVec3_New(lx, ly, lz) = C3D_FVec{ x=0, y=lz, z=ly, w=lx }.
FVec3_Dot sums only Odin .w + .z + .y (skipping .x = logical W = 0).
FVec3_Cross field derivation
Cross product A×B = (Ay·Bz−Az·By, Az·Bx−Ax·Bz, Ax·By−Ay·Bx) in logical x,y,z.
With the mapping (logical X = Odin .w, logical Y = Odin .z, logical Z = Odin .y):
FVec3_Cross :: proc(a, b: C3D_FVec) -> C3D_FVec {
return C3D_FVec{
x = 0,
y = a.w*b.z - a.z*b.w, // logical Z
z = a.y*b.w - a.w*b.y, // logical Y
w = a.z*b.y - a.y*b.z, // logical X
}
}Mtx_Diagonal field mapping
C3D_Mtx diagonal entries: r[0].x = lx in C means r[0].w = lx in Odin (Odin .w = C .x):
out.r[0].w = x; out.r[1].z = y; out.r[2].y = z; out.r[3].x = wBridged FVec-by-value functions use output-pointer pattern
Functions that pass or return C3D_FVec/C3D_FQuat by value are homogeneous floating-point
aggregates (HFAs) — the hard-float ABI passes them in VFP registers s0–s3, not integer registers.
The bridge pattern is:
void mtx_multiply_fvec4(const C3D_Mtx* mtx, C3D_FVec* v, C3D_FVec* out) {
*out = Mtx_MultiplyFVec4(mtx, *v); // bridge.c (hard-float) passes *v in VFP regs correctly
}Odin calls this with stack pointers; bridge.c (compiled -mfloat-abi=hard) handles the HFA
convention when forwarding to citro3d.
sqrtf requires its own bridge
sqrtf from libm uses the hard-float ABI (argument in s0, result in s0). It is wrapped the same
way as all other float functions:
uint32_t odin_sqrtf(uint32_t x) { return f2u(sqrtf(u2f(x))); }SpinningCube-Example — coordinate conventions
With Mtx_PerspTilt(fovy=60°, aspect=400/240, near=0.01, far=100, isLeftHanded=true):
- Camera at origin; +Z direction goes into the screen (left-handed)
- Cube placed at z=+2 via
Mtx_Translate(0, 0, 2, true) Mtx_RotateY(angle, true)applied before translation so the cube spins in placeC3D_CullFace(.NONE)— disable culling while validating winding conventions- Depth test with GEQUAL (same as DepthTest-Example reverse-Z setup)
- ✅
C3D_LightEnv*,C3D_Light*— hardware lighting environment, material, LUTs (Lighting-Example) - ✅
FogLut_Exp,C3D_FogGasMode— distance fog - ✅
C3D_ProcTex*,ProcTexLut_*— procedural texture generation
PICA200 hardware lighting pipeline overview
The PICA200 lighting unit reads two special vertex outputs written by the vertex shader:
outnq(normalquat) — a half-angle quaternion encoding the view-space surface normaloutview(view) — the view-space vertex position (used for attenuation and specular)
The lighting unit applies the bound C3D_LightEnv (material, LUTs, light positions/colours) and
produces FRAGMENT_PRIMARY, which is then consumed by the TexEnv stage. The TexEnv source must
be set to GPU_TEVSRC.FRAGMENT_PRIMARY (not PRIMARY_COLOR) to display the lit colour.
Normalquat computation
The normalquat is a half-angle quaternion from the +Z axis to the view-space normal (nx, ny, nz):
c = sqrt((1 + nz) / 2) -- half-angle cosine, clamped away from zero
q = (nx/(2c), ny/(2c), c, 0) -- quaternion (x, y, z, w)
PICA200 dp4 and reversed C3D_FVec order in temp registers
When a dp4 group writes to a temp register, the results land in reversed C3D_FVec order —
the first dp4 instruction fills the W component (stored in .x), the second fills Z (.y), etc.:
dp4 r2.x, modelView[0], r1 ; r2.x = nw_vs (= 0 for a direction)
dp4 r2.y, modelView[1], r1 ; r2.y = nz_vs
dp4 r2.z, modelView[2], r1 ; r2.z = ny_vs
dp4 r2.w, modelView[3], r1 ; r2.w = nx_vs
The normalquat math therefore reads nx = r2.w, ny = r2.z, nz = r2.y.
The view-space position is computed the same way and then swizzled back to natural order:
dp4 r5.x, modelView[0], r0
dp4 r5.y, modelView[1], r0
dp4 r5.z, modelView[2], r0
dp4 r5.w, modelView[3], r0
mov outview, r5.wzyx ; swizzle reversed → natural (x,y,z,w)
PICA200 ALU: constants must be src1, not src2
The PICA200 ALU restricts constant registers (.constf aliases) to the first source operand
only. Placing a constant in the second source position (src2) is a hard assembler error:
; WRONG — assembler rejects "ones" in src2:
add r3.x, r2.y, ones
; CORRECT — swap operands (add/mul/max are commutative):
add r3.x, ones, r2.y
Two dp4 groups per destination, never chain through a temp
Two consecutive dp4 groups reading through an intermediate temp register give wrong results
because the reversed component order in the temp corrupts the second group's inputs. The fix
used throughout the lighting shader is one dp4 group per logical destination, each reading
directly from the original vertex input registers (r0, r1), never from a temp filled by a
previous dp4 group.
Mtx_Scale — fixed
The original Odin implementation built a diagonal matrix and called Mtx_Multiply, but
Mtx_Multiply operates on the reversed C3D flat-storage layout so the scale factors permuted
columns instead of scaling them. Fixed by routing through the C bridge (mtx_scale in
lib/c3d/bridge.c), which calls citro3d's Mtx_Scale directly — it multiplies r[i].x/y/z
in-place, bypassing the multiply entirely.
// Now works correctly:
c3d.Mtx_Scale(&model, 0.5, 0.5, 0.5)Rather than a separate example, here is the key pattern for combining a diffuse texture with per-vertex lighting in a single TexEnv stage.
The goal: fragment_color = texture_color × lighting_color.
TexEnv stage 0 — modulate texture by primary color (lit vertex color):
env := c3d.C3D_GetTexEnv(0)
c3d.C3D_TexEnvInit(env)
// Source: texture for RGB, primary (lit) color for alpha
c3d.C3D_TexEnvSrc(env, c3d.C3D_RGB, .TEXTURE0, .PRIMARY_COLOR, .PRIMARY_COLOR)
c3d.C3D_TexEnvSrc(env, c3d.C3D_Alpha, .PRIMARY_COLOR, .PRIMARY_COLOR, .PRIMARY_COLOR)
// MODULATE multiplies src0 × src1 — texture × lighting
c3d.C3D_TexEnvFunc(env, c3d.C3D_RGB, .MODULATE)
c3d.C3D_TexEnvFunc(env, c3d.C3D_Alpha, .REPLACE)Vertex shader: output both texcoord0 and color0. The lighting system writes its result
into the fragment's primary color, which the TexEnv stage then multiplies against the texture
sample.
Why PRIMARY_COLOR carries the light result: C3D_LightEnvBind causes the GPU's fragment
pipeline to compute diffuse + specular into the fragment primary color before TexEnv runs.
TEXTURE0 is the bound diffuse texture. MODULATE on .RGB produces the lit, textured color.
Alpha: use REPLACE on the alpha channel sourced from PRIMARY_COLOR (the material alpha)
unless the texture has its own meaningful alpha, in which case source from TEXTURE0 with
MODULATE for both channels.
Four new files added to lib/ctru/:
| File | Contents |
|---|---|
svc.odin |
Full kernel syscall bindings from 3ds/svc.h — threads, mutexes, events, timers, memory, IPC |
apt.odin |
Full APT service bindings — home button hooks, sleep, chainloader, N3DS CPU time limit |
ndsp.odin |
Full NDSP audio bindings — channel setup, wave buffers, mix, interpolation, IIR filters |
fs.odin |
Full FS filesystem bindings — SDMC/romfs archives, file and directory operations |
NDSP functions that take scalar float parameters use the hard-float ABI (values in VFP registers). These are wrapped in lib/ctru/bridge.c using the same u2f/f2u pattern as the Citro2D/3D bridges. Functions that take float arrays by pointer (e.g. ndspChnSetMix) are safe to call directly.
Audio-Example — demonstrates NDSP end-to-end:
- Parses
romfs:/audio.wav(uncompressed PCM, 8 or 16 bit, mono or stereo) using C stdio (fopen/fread) afterromfsInit() - Allocates a
linearAllocbuffer for DMA-safe audio data - Configures NDSP channel 0 with a single looping
ndspWaveBuf - Live console display: status, volume, sample position
- Controls:
Apause/resume ·Up/Downvolume ±5% ·Startexit
⚠️ Citra DSP note:ndspInitwill fail on Citra regardless of DSP mode (HLE or LLE Accurate). Citra's DSP emulation does not fully support the NDSP initialization sequence used by libctru. The example prints a warning and continues so the UI is visible in Citra, but audio only works on real hardware. On real hardware, dump your DSP firmware once with the dsp1 homebrew app — it savesdspfirm.cdctosdmc:/3ds/dspfirm.cdc, which libctru finds automatically on every subsequentndspInitcall. No rebuild required.
SaveData-Example — persistent save data on the SD card:
- Reads/writes a
SaveData{magic, play_count, high_score, checksum}struct tosdmc:/3ds/SaveData-Example/save.bin - Increments
play_counton every launch; starts fresh on first run or bad checksum - Controls:
Ahigh score +10 and save ·Startfinal save and exit
ARM32 u64 register-alignment bug in Odin → FS service calls
The ARM32 AAPCS (Procedure Call Standard) requires that a u64 argument be placed starting
at an even-numbered register (r0, r2, …). If the preceding arguments have consumed an
odd number of registers, the next register is skipped (wasted as padding) so the u64 lands
on an even boundary.
Odin's freestanding_arm32 target does not insert this padding register, so any foreign C
function with a u64 parameter preceded by an odd number of register-sized arguments receives
corrupted data.
The two FS service functions affected are:
| Function | Problem |
|---|---|
FSUSER_OpenFile(file: ^Handle, archive: FS_Archive, ...) |
^Handle occupies r0 → NCRN=1 (odd). FS_Archive (u64) should skip to r2:r3, but Odin places it in r1:r2. libctru reads the archive handle from the wrong registers → all file opens fail. |
FSFILE_SetSize(handle: Handle, size: u64) |
Handle (u32) occupies r0 → NCRN=1 (odd). size (u64) should skip to r2:r3, but Odin places it in r1:r2. libctru reads size=0 → file stays at zero bytes → subsequent write fails. |
Functions where the u64 lands on an even boundary are not affected:
| Function | Why it's safe |
|---|---|
FSUSER_OpenArchive(archive: ^FS_Archive, id: FS_ArchiveID, ...) |
No u64 by value — archive is an output pointer. |
FSUSER_CreateFile(archive: FS_Archive, ...) |
FS_Archive is the first arg → NCRN=0 (even) → r0:r1. ✓ |
FSUSER_CreateDirectory, FSUSER_DeleteFile, FSUSER_CloseArchive |
Same — FS_Archive is always the first argument. ✓ |
FSFILE_Write(handle, bytesWritten, offset: u64, ...) |
Two register args before offset → NCRN=2 (even) → r2:r3. ✓ |
FSFILE_Read(handle, bytesRead, offset: u64, ...) |
Same. ✓ |
Workaround: use C stdio (fopen / fwrite / fread from newlib) for all actual file I/O,
and mkdir (also from newlib) for directory creation. Newlib routes these through the FS service
internally with a correct calling convention. FSUSER_OpenArchive + FSUSER_CreateDirectory
remain usable from Odin since FS_Archive is always their first argument.
This pattern is identical to how Audio-Example loads WAV data from romfs — C stdio is the proven, safe path for file I/O from Odin on 3DS.
Four new bindings added to lib/ctru/:
| File | Contents |
|---|---|
cam.odin |
Full CAM service bindings — camera init, size/format/framerate config, DMA capture, transfer-byte control |
mic.odin |
Full MIC service bindings — PCM ring buffer recording, gain control, sample rate selection |
nfc.odin |
NFC service bindings — tag/amiibo detection, state events |
uds.odin |
UDS (local wireless) service bindings — network scan, create, join, send/receive |
Camera and Microphone have been tested and confirmed working on 3DS LL hardware. They are ports of the official devkitPro examples listed below. NFC has been tested and confirmed working. UDS builds but is untested (requires two devices).
Camera-Example — dual-camera live feed on the top screen:
- Based on the devkitPro 3DS camera/video example
- Both outer cameras run simultaneously via
PORT_BOTH - Uses
CAMU_GetMaxBytes+CAMU_SetTransferBytesto configure DMA (notGetMaxLines) - Continuous capture with
svcWaitSynchronizationNacross 4 handles (2 error + 2 receive events) - Buffer errors trigger automatic capture restart (
captureInterruptedflag) - RGB565 → BGR8 blit via
write_picture_rgb565(column-major framebuffer, y-flip) - 3D slider support: both cameras blitted to left/right eye when slider > 0
Microphone-Example — live amplitude VU meter:
- Based on the devkitPro 3DS audio/mic example
- Ring buffer allocated with
memalign(0x1000, size)— page alignment required for kernel shared-memory mapping micGetSampleDataSize()called aftermicInitto get usable buffer size- Byte-at-a-time ring buffer scan advancing read-head toward write-head
- Peak decay smoothing for stable bar display
- UP/DOWN controls mic gain (0–160)
Camera: CAMU_GetMaxBytes not CAMU_GetMaxLines
CAMU_GetMaxLines fails for 400×240 (image size 192 000 bytes > the 184 320-byte internal limit),
leaving transferUnit = 0, which causes CAMU_SetReceiving to fail and svcWaitSynchronization
to block forever on a zero handle — a kernel panic. The correct approach is:
bufSize: u32
CAMU_GetMaxBytes(&bufSize, WIDTH, HEIGHT) // always succeeds
CAMU_SetTransferBytes(CAM_PORT_BOTH, bufSize, WIDTH, HEIGHT)
// ... in the receive call:
CAMU_SetReceiving(&event, buf, PORT_CAM1, SCREEN_SIZE, i16(bufSize))Microphone: page-aligned heap allocation
micInit maps the buffer as a kernel shared-memory object. The 3DS kernel requires shared
memory to start on a 0x1000-byte (4096-byte) page boundary. linearAlloc only guarantees
16-byte alignment and will silently fail or kernel-panic. Use memalign(0x1000, size) from
the regular heap instead:
micBuf := cast([^]u8)ctru.memalign(0x1000, uint(MIC_BUF_SIZE))
defer ctru.free(cast(rawptr)micBuf)A drop-in replacement for the official tex3ds tool. Converts PNG/JPEG images to .t3x files
in the standard Tex3DS binary format, compatible with Tex3DS_TextureImportStdio,
C2D_SpriteSheetLoad, and C2D_SpriteSheetLoadFromHandle.
(Developer Note) I encounted a bug with tex3ds and ImageMagick on window. The bug would currupt the pathing string that tex3ds and ImageMagick used
when converting a image to a .t3x file. This was not just a .bat usage related bug. The bug happened when using the CLI calls directly.
The rather convoluted fix/workaround was to just develop a tool that does the image to .t3x directly. That is the reason why tritex exists.
It doesn't implement all of the features that tex3ds has but it should have enough for most use cases.
See tools/howto.md for the full usage reference.
Usage
# Single image
tritex input.png output.t3x
# Single image with flags
tritex -o output.t3x input.png
# Texture atlas: pack multiple images into one .t3x (multi-frame sprite sheet)
tritex -a -o atlas.t3x sprite0.png sprite1.png sprite2.png
# Texture atlas via .t3s include file (matches tex3ds -i usage exactly)
tritex -a -o atlas.t3x -i sprites.t3s
Supported flags
| Flag | Description |
|---|---|
-a, --atlas |
Pack all inputs into a single texture atlas |
-o, --output <file> |
Output .t3x filename |
-i, --include <file> |
Read options and input filenames from a .t3s file |
-f, --format <fmt> |
Accepted for compatibility; RGBA8 output only |
-z, --compress <cmp> |
Accepted for compatibility; uncompressed output only |
-m/-d/-H/-p/-q/-r/-t/-b/-c/-s |
Accepted for compatibility; silently ignored |
-h, --help |
Show help |
-v, --version |
Show version |
.t3s include file format
Plain-text, whitespace-tokenised. Flags and filenames can be mixed freely on any line.
Comments start with #. Paths are resolved relative to the .t3s file's directory.
# sprites.t3s
-f rgba8
-z none
sprite_idle.png
sprite_run.png
"path with spaces/sprite_jump.png"
Typical invocation mirroring the real tex3ds workflow:
tritex -a -o romfs/sprites.t3x -i assets/sprites.t3sWhat the output contains
- Header starts with
u16 numSubTextures(no magic bytes) texture_paramsbyte:log2(w)−3in bits[2:0],log2(h)−3in bits[5:3]- One 12-byte sub-texture UV entry per input image (written in input order)
- Sub-texture UVs:
uv_top = 1.0,uv_bottom = 0.0for a full-size image (no Y-flip) - Canvas dimensions are the smallest power-of-two ≥ 8 that fits all images (up to 1024×1024)
- Shelf-packing algorithm: images sorted by height descending, packed left-to-right in rows
- Pixels: ABGR8 byte order, Morton (Z-curve) swizzled in 8×8 tiles
- 4-byte BIOS compression header with type
0x00(no compression) - Output verified byte-for-byte identical to
tex3ds -z noneon Linux
Rebuilding
odin build tools/tritex.odin -file -out:tools/tritex.exeAll image-based examples' build.bat files invoke tritex automatically before the build step.
Folders containing a .t3s file are handled in atlas mode; individual PNGs without a .t3s
are converted one-to-one.
Converts MP3 files to WAV (PCM16) using Odin's vendor:miniaudio bindings.
Purpose-built for preparing audio for 3DS NDSP playback, which requires uncompressed PCM.
Usage
mp3towav input.mp3 output.wavOutputs 44100 Hz, mono, 16-bit PCM by default — the format expected by NDSP with
NDSP_FORMAT_MONO_PCM16. miniaudio handles all decoding and resampling internally.
Rebuilding
odin build tools/mp3towav -out:tools/mp3towav.exeA complete Flappy Bird clone demonstrating every major system in the binding library working together in a real game.
Features
- Animated sprite sheet player (4-frame wing flap via
C2D_SpriteSheetLoad+C2D_SpriteFromSheet) - Velocity-based physics (
velocity_y += gravity,y += velocity_y) with floor/ceiling clamping - Procedurally placed pipe pairs recycled as they scroll off screen
- AABB collision detection with per-pixel-snapped hitboxes
- Parallax scrolling background (3 tiled copies, sub-pixel seam-free)
- Score tracking with pipe-pass detection
- Persistent high score saved to
sdmc:/3ds/FlappyClone/highscore.dat - Coin sound effect via NDSP (
lib/audio—linearAlloc-backed WAV) - Three-state game loop:
MainMenu → Playing → Deadwith labeled-break exit - Bottom screen UI: live score/best HUD during play; animated game-over/main-menu with d-pad cursor
- SMDH metadata with custom icon
Key implementation lessons documented in docs.md:
- Sub-pixel jitter fix:
cast(f32)cast(i32)before everySpriteSetPoscall - Parallax tile count formula:
int(SCREEN_W / img_w) + 2 - Animation timer pattern:
frame_timercounter +frame_delaythreshold - Velocity physics vs fixed-offset movement
consoleInitvs citro2d screen ownership conflictbreak game_looplabeled break to exit aforfrom inside aswitch
Asset pipeline
:: Bird animation — 4 frames packed into one atlas via .t3s
tritex -a -o romfs\gfx\bird.t3x -i assets\images\bird\bird.t3s
:: Pipe and background — individual images
tritex assets\images\pipe\pipe.png romfs\gfx\pipe.t3x
tritex assets\images\Background4.png romfs\gfx\Background4.t3x
:: Audio — WAV copied directly to romfs
xcopy /y assets\audio\*.wav romfs\audio\You will notice foreign imports for things that already exist in standard Odin (e.g. printf).
Because we compile with -no-entry-point and -no-thread-local for the freestanding target, the standard Odin runtime is not available. Every OS-level or C-runtime function must be explicitly imported from libctru.