2011 · 2014 · Handheld console · Nintendo

Nintendo 3DS

One QuickJS guest, two native PICA200 surfaces, touch on the auxiliary display.

Hardware-tested hostGuest · QuickJS + native corehosts/3ds/

Nintendo introduced the 3DS family in 2011. The hardware receipt recorded here uses the 2014 New Nintendo 3DS LL — sold as the New Nintendo 3DS XL outside Japan — with four ARM11 cores, 256 MB of RAM and a 268 MHz PICA200. Its 4.88-inch autostereoscopic upper screen presents 400×240 pixels per eye; the 4.18-inch 320×240 lower panel is resistive touch.

PocketJS runs on the Japanese LL model as a 3ds-dev Guest host. One application owns both displays: the primary 400×240 surface shows a contact detail while an independent 320×240 auxiliary DrawList holds a 10,000-row virtual list and receives touch. The host can package the runtime as a Homebrew Launcher .3dsx or an installed CIA.

Processor
ARM11 MPCore ×4 · 804 MHz
Memory
256 MB + 10 MB VRAM
Display
400 × 240 + 320 × 240
An open metallic-black New Nintendo 3DS XL, the model sold as New Nintendo 3DS LL in Japan
An open metallic-black New Nintendo 3DS XL, the model sold as New Nintendo 3DS LL in Japan. Photo: Ejay; transparent cutout by Pokemon59 · CC BY-SA 4.0 · The XL name is used outside Japan; it is the same large-body model as the LL hardware receipt.
01

Hardware

curated here · sources below

Compute

CPU
4× ARM11 MPCore, up to 804 MHz PocketJS enables the New 3DS speedup and L2 cache at boot
System processor
ARM946, 134 MHz
GPU
Digital Media Professionals PICA200, 268 MHz
RAM
256 MB FCRAM + 10 MB VRAM 64 MB of FCRAM is reserved for the operating system

Display & input

Upper display
4.88″ autostereoscopic LCD, 800 × 240 400 × 240 per eye; PocketJS owns one native 400 × 240 surface
Lower display
4.18″ LCD, 320 × 240, resistive touch
Input
D-pad, Circle Pad, C-Stick, A/B/X/Y, L/R/ZL/ZR, Start/Select/Home
Sensors
Accelerometer, gyroscope, infrared face tracking; two rear VGA cameras and one front VGA camera

Storage & connectivity

Storage
1 GB internal flash; 4 GB microSDHC included
Media
Nintendo 3DS and Nintendo DS Game Cards
Wireless
Wi-Fi 802.11b/g, NFC, infrared
Battery
1750 mAh / 6.5 Wh lithium-ion rated for roughly 3.5–7 hours of 3DS software

Body

Model
RED-001 New Nintendo 3DS LL in Japan; New Nintendo 3DS XL elsewhere
Dimensions
160 × 93.5 × 21.5 mm, closed
Weight
329 g including battery, stylus and microSD card
Released
3DS family: 26 February 2011 · receipt model: 11 October 2014 (Japan)
02

PocketJS on this machine

Guest · QuickJS + native core

What runs

QuickJS runs the guest while a no_std Rust static library owns the retained tree, layout, animation and DrawList emission. A C backend walks independent primary and auxiliary DrawLists into citro3d calls for the PICA200. The runtime embeds an admitted .pocket recovery guest, accepts authenticated guest updates over a paired Wi-Fi connection, and commits a candidate only after its first PICA command list retires successfully.

What is proven

The CIA boots and renders the calibration application on a New 3DS LL. Azahar runs the same PICA200 code path against top- and bottom-screen goldens; an installed CIA capture is byte-identical to the .3dsx goldens. The profile remains outside POCKET_TARGETS because the hardware and golden suite does not directly cover the synthesized cursor, sprites, streamed textures or a large font atlas.

Record

  1. v0.11.0

    Nintendo 3DS joins the host set with two native surfaces and a paired Wi-Fi development loop.

Profile · as declared by the demo manifestsource ↗
Profile
3ds-dev (private, host ABI 8)
Logical viewport
400 × 240
Physical viewport
400 × 240
Raster density
Requires
  • text.glyphs.baked
  • input.buttons
  • display.auxiliary
  • input.touch.auxiliary
04

Example code

upstream source · highlighted at build time
apps/3ds-demo/app.tsxtsx · 485 lines · @2d20dda

One Solid app split across a primary detail card and an auxiliary 10,000-row VirtualList.

// apps/3ds-demo/app.tsx — dual-output acceptance demo for the 3ds-dev host.
//
// Two screens split one classic iPhone app. The auxiliary display holds the
// Contacts list — a 10,000-row VirtualList whose viewport mounts only the
// visible window plus overscan, while touch drag/fling changes the canvas
// transform without laying out all rows. The primary display holds the detail
// card the phone had to push a whole screen to reach: tap a row and the card
// changes, and it stays put while the list scrubs away underneath it. That
// cross-surface flow is what the two outputs are for, and it exercises the
// host the old diagnostic panel only described: two independent DrawLists, a
// baked image, and touch that arrives on the auxiliary surface alone.
//
// The circle pad scrolls the list through the VirtualList's own d-pad
// binding, which reads `analogY()` — no app code needed, which is why nothing
// here samples the pad.
//
// The auxiliary screen is 320 px wide — the classic iPhone width — but its
// 3.02" panel runs ~133 ppi against the phone's ~165, so copying the phone's
// pixel metrics 1:1 would draw everything a fifth larger than the phone drew
// it. The contact list scales them by 0.82 instead, which is the same
// PHYSICAL size and fits 5 rows on a 240 px screen: a 36 px navigation bar, a
// 36 px search field that scrolls away as the table header, 36 px contact
// rows at 16 px, and 18 px section headers that stick to the top of the table
// until the next one pushes them off.

import { createMemo, createSignal } from "solid-js";
import {
  AuxiliarySurface,
  Image,
  Text,
  View,
  type NodeMirror,
} from "@pocketjs/framework/components";
import { createGesture } from "@pocketjs/framework/gesture";
import { createScroller } from "@pocketjs/framework/kinetics";
import { VirtualList } from "@pocketjs/framework/virtual-list";

const LIST_ROWS = 10_000;

// Classic iPhone table metrics at 0.82. SLOT is the section-header height and
// the VirtualList's uniform unit; a contact row and the search header each
// span two slots, which is how an 18 px header and a 36 px row share one list.
const SLOT = 18;
const ROW_SLOTS = 2;
const SEARCH_SLOTS = 2;
const ROW_HEIGHT = SLOT * ROW_SLOTS;
const SEARCH_HEIGHT = SLOT * SEARCH_SLOTS;
const NAV_HEIGHT = 36;
const LIST_TOP = NAV_HEIGHT;
const LIST_HEIGHT = 240 - LIST_TOP;

const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const GIVEN_NAMES = [
  "Avery",
  "Chloe",
  "Elliot",
  "Harper",
  "Jamie",
  "Morgan",
  "Riley",
  "Taylor",
] as const;
const SURNAMES = [
  "Adams",
  "Bennett",
  "Carter",
  "Dawson",
  "Ellis",
  "Foster",
  "Garcia",
  "Hayes",
  "Irwin",
  "Jordan",
  "Keller",
  "Lewis",
  "Morris",
  "Nelson",
  "Owens",
  "Parker",
  "Quinn",
  "Reed",
  "Sullivan",
  "Turner",
  "Underwood",
  "Vaughn",
  "Walker",
  "Xavier",
  "Young",
  "Zimmerman",
] as const;

const MONTHS = [
  "January", "February", "March", "April", "May", "June",
  "July", "August", "September", "October", "November", "December",
] as const;

interface ContactSection {
  letter: string;
  surname: string;
  contactStart: number;
  contactCount: number;
  /** Slot holding this section's 18 px header; contacts follow, two slots each. */
  slotStart: number;
}

const CONTACT_SECTIONS: readonly ContactSection[] = (() => {
  let contactStart = 0;
  let slotStart = SEARCH_SLOTS;
  return SURNAMES.map((surname, index) => {
    const contactCount = Math.floor(LIST_ROWS / LETTERS.length) +
      (index < LIST_ROWS % LETTERS.length ? 1 : 0);
    const section = {
      letter: LETTERS[index],
      surname,
      contactStart,
      contactCount,
      slotStart,
    };
    contactStart += contactCount;
    slotStart += 1 + contactCount * ROW_SLOTS;
    return section;
  });
})();

const LIST_SLOTS = SEARCH_SLOTS + LIST_ROWS * ROW_SLOTS + CONTACT_SECTIONS.length;
const MAX_SCROLL = LIST_SLOTS * SLOT - LIST_HEIGHT;

// A-Z index. 26 entries never fit 204 px at a readable size, so the strip
// drops letters the way UITableView does and marks each gap with a dot. The
// touch mapping stays continuous over all 26 sections.
const INDEX_PITCH = 12;
const INDEX_ENTRIES = (() => {
  const fits = Math.floor((LIST_HEIGHT - 8) / INDEX_PITCH);
  const count = fits % 2 === 1 ? fits : fits - 1; // start and end on a letter
  const letters = (count + 1) / 2;
  return Array.from({ length: count }, (_, entry) =>
    entry % 2 === 0
      ? LETTERS[Math.round((entry / 2) * (LETTERS.length - 1) / (letters - 1))]
      : null,
  );
})();
const INDEX_PAD = (LIST_HEIGHT - INDEX_ENTRIES.length * INDEX_PITCH) / 2;

function sectionForSlot(slot: number): number {
  const bounded = Math.max(0, Math.min(LIST_SLOTS - 1, slot));
  for (let index = CONTACT_SECTIONS.length - 1; index >= 0; index--) {
    if (bounded >= CONTACT_SECTIONS[index].slotStart) return index;
  }
  return 0;
}

/** The 18 px gradient bar carrying a section letter — in the table and pinned. */
function SectionHeader(letter: () => string, debugName: string) {
  return (
    <View
      debugName={debugName}
      class="relative w-full h-[18] bg-gradient-to-b from-[#b7bec8] to-[#949ca8]"
    >
      <View class="absolute left-0 right-0 top-0 h-[1] bg-[#d2d7de]" />
      <View class="absolute left-0 right-0 bottom-0 h-[1] bg-[#7b838e]" />
      <Text class="absolute left-[8] top-[1] text-sm text-[#71798599] font-bold">{letter()}</Text>
      <Text class="absolute left-[8] top-0 text-sm text-white font-bold">{letter()}</Text>
    </View>
  );
}

function SearchHeader() {
  return (
    <View
      debugName="ContactsSearch"
      class="relative w-full h-[36] bg-gradient-to-b from-[#cbcfd4] to-[#a6acb4]"
    >
      <View class="absolute left-0 right-0 top-0 h-[1] bg-[#e4e7ea]" />
      <View class="absolute left-0 right-0 bottom-0 h-[1] bg-[#888e97]" />
      <View class="absolute left-[6] top-[6] right-[20] h-[24] flex-row items-center pl-[7] gap-[4] rounded-[12] bg-white border border-[#9aa0a8]">
        <View class="relative w-[9] h-[9]">
          <View class="absolute left-0 top-0 w-[7] h-[7] rounded-full border border-[#8b9199]" />
          <View
            class="absolute left-[5] top-[7] w-[4] h-[1] bg-[#8b9199]"
            style={{ rotate: 45 }}
          />
        </View>
        <Text class="text-sm text-[#8b9199]">Search</Text>
      </View>
    </View>
  );
}

/** Contact index a slot renders, or -1 for the search header, a section
 *  header, or a contact row's second slot. */
function contactAtSlot(slot: number): number {
  if (slot < SEARCH_SLOTS || slot >= LIST_SLOTS) return -1;
  const section = CONTACT_SECTIONS[sectionForSlot(slot)];
  if (slot === section.slotStart) return -1;
  const withinSection = slot - section.slotStart - 1;
  return withinSection % ROW_SLOTS === 0
    ? section.contactStart + withinSection / ROW_SLOTS
    : -1;
}

function givenNameFor(contactIndex: number, sectionIndex: number): string {
  return GIVEN_NAMES[(contactIndex * 5 + sectionIndex) % GIVEN_NAMES.length];
}

function ContactRow(contactIndex: number, sectionIndex: number, selected: () => boolean) {
  const section = CONTACT_SECTIONS[sectionIndex];
  const ordinal = String(contactIndex + 1).padStart(5, "0");
  const given = givenNameFor(contactIndex, sectionIndex);
  return (
    <View
      debugName={`VirtualContact${ordinal}`}
      class={
        selected()
          ? "absolute left-0 right-0 top-0 h-[36] flex-row items-center pl-[8] pb-[4] gap-[4] bg-gradient-to-b from-[#4c9bf5] to-[#0a63dd]"
          : "absolute left-0 right-0 top-0 h-[36] flex-row items-center pl-[8] pb-[4] gap-[4] bg-white"
      }
    >
      <Text class={selected() ? "text-base text-white" : "text-base text-black"}>{given}</Text>
      <Text class={selected() ? "text-base text-white font-bold" : "text-base text-black font-bold"}>
        {section.surname}
      </Text>
      <View
        class={
          selected()
            ? "absolute left-0 right-0 bottom-0 h-[1] bg-[#0a55c4]"
            : "absolute left-0 right-0 bottom-0 h-[1] bg-[#d0d0d3]"
        }
      />
    </View>
  );
}

/** One grouped-cell field: bold label right-aligned in the gutter, value after. */
function Field(label: string, value: () => string, link: boolean) {
  return (
    <View class="flex-row items-center w-full h-[32] pb-[3] gap-[8]">
      <Text class="w-[58] text-right text-xs text-[#55677d] font-bold">{label}</Text>
      <Text class={link ? "text-sm text-[#1b4fa8]" : "text-sm text-[#15181c]"}>{value()}</Text>
    </View>
  );
}

export default function ThreeDsDemo() {
  const [indexing, setIndexing] = createSignal(false);
  const [selected, setSelected] = createSignal(0);
  let contactIndexNode: NodeMirror | undefined;

  // The card on the primary display. Every field is a pure function of the
  // contact index, so a 10,000-record directory needs no stored rows.
  const card = createMemo(() => {
    const index = Math.max(0, Math.min(LIST_ROWS - 1, selected()));
    let sectionIndex = 0;
    for (let i = CONTACT_SECTIONS.length - 1; i >= 0; i--) {
      if (index >= CONTACT_SECTIONS[i].contactStart) {
        sectionIndex = i;
        break;
      }
    }
    const surname = CONTACT_SECTIONS[sectionIndex].surname;
    const given = givenNameFor(index, sectionIndex);
    // 555-0100..555-0199 is the range reserved for fictional numbers.
    const line = (salt: number) => `555-01${String((index * salt + salt) % 100).padStart(2, "0")}`;
    return {
      given,
      surname,
      ordinal: String(index + 1).padStart(5, "0"),
      mobile: `(415) ${line(7)}`,
      home: `(415) ${line(13)}`,
      work: `(212) ${line(29)}`,
      email: `${given}@${surname}.com`.toLowerCase(),
      birthday:
        `${MONTHS[(index * 5 + 2) % 12]} ${1 + (index * 7) % 28}, 19${58 + (index * 3) % 40}`,
    };
  });

  /** A tap anywhere on a contact's 36 px row selects it — the row's second
   *  18 px slot is an empty view that claims the hit for the row above it. */
  const selectSlot = (slot: number) => {
    const direct = contactAtSlot(slot);
    const index = direct >= 0 ? direct : contactAtSlot(slot - 1);
    if (index >= 0) setSelected(index);
  };

  const renderSlot = (slot: number) => {
    if (slot < SEARCH_SLOTS) return slot === 0 ? SearchHeader() : null;
    const sectionIndex = sectionForSlot(slot);
    const section = CONTACT_SECTIONS[sectionIndex];
    if (slot === section.slotStart) {
      return SectionHeader(() => section.letter, `ContactSection${section.letter}`);
    }
    const contactIndex = contactAtSlot(slot);
    if (contactIndex < 0) return null; // the row's second slot
    return ContactRow(contactIndex, sectionIndex, () => selected() === contactIndex);
  };

  const listScroller = createScroller({
    max: () => MAX_SCROLL,
    extent: () => LIST_HEIGHT,
  });

  // The section header of the row at the top of the table stays pinned there
  // until the next section's header reaches it and pushes it off.
  const pinnedSection = () => {
    const offset = listScroller.offset();
    if (offset < SEARCH_HEIGHT) return -1;
    return sectionForSlot(Math.floor(offset / SLOT));
  };
  const pinnedLetter = () => {
    const index = pinnedSection();
    return index < 0 ? "" : CONTACT_SECTIONS[index].letter;
  };
  const pinnedShift = () => {
    const index = pinnedSection();
    if (index < 0 || index >= CONTACT_SECTIONS.length - 1) return 0;
    const nextTop = CONTACT_SECTIONS[index + 1].slotStart * SLOT - listScroller.offset();
    return nextTop < SLOT ? nextTop - SLOT : 0;
  };

  const sectionIndexForY = (y: number) => {
    const fraction = Math.max(0, Math.min(0.999999, (y - LIST_TOP) / LIST_HEIGHT));
    return Math.floor(fraction * CONTACT_SECTIONS.length);
  };
  const jumpToSection = (y: number) => {
    const section = CONTACT_SECTIONS[sectionIndexForY(y)];
    listScroller.scrollTo(section.slotStart * SLOT, { immediate: true });
  };

  createGesture({
    surface: "auxiliary",
    region: { node: () => contactIndexNode },
    axis: "y",
    panSlop: 1,
    onDown: (contact) => {
      setIndexing(true);
      listScroller.stop();
      jumpToSection(contact.y);
    },
    onPanMove: (contact) => jumpToSection(contact.y),
    onUp: () => setIndexing(false),
    onCancel: () => setIndexing(false),
  });

  return (
    <>
      {/* Primary display: the detail card the phone reached by pushing a
          screen. 400x240 at the same ~133 ppi as the touch screen, so it
          keeps the auxiliary screen's 0.82 metrics — 16 px status bar, 36 px
          navigation bar, 32 px grouped-cell fields. */}
      <View debugName="ThreeDsScreen" class="relative w-full h-full bg-[#c5ccd3] overflow-hidden">
        <View debugName="StatusBar" class="absolute left-0 right-0 top-0 h-[16] bg-gradient-to-b from-[#cbcfd4] to-[#8f959c]">
          <View class="absolute left-0 right-0 bottom-0 h-[1] bg-[#6d737a]" />
          <View class="absolute left-[6] top-[10] w-[3] h-[3] bg-[#23272c]" />
          <View class="absolute left-[10] top-[8] w-[3] h-[5] bg-[#23272c]" />
          <View class="absolute left-[14] top-[6] w-[3] h-[7] bg-[#23272c]" />
          <View class="absolute left-[18] top-[4] w-[3] h-[9] bg-[#23272c]" />
          <View class="absolute left-[22] top-[2] w-[3] h-[11] bg-[#23272c40]" />
          <Text class="absolute left-[31] top-0 text-xs text-[#23272c]">PocketJS</Text>
          <Text class="absolute left-0 right-0 top-0 text-center text-xs text-[#23272c] font-bold">9:41 AM</Text>
          <View class="absolute left-[368] top-[4] w-[20] h-[9] rounded-[2] border border-[#23272c]" />
          <View class="absolute left-[370] top-[6] w-[16] h-[5] bg-[#23272c]" />
          <View class="absolute left-[389] top-[6] w-[2] h-[5] bg-[#23272c]" />
        </View>

        <View debugName="CardNavigation" class="absolute left-0 right-0 top-[16] h-[36] bg-[#6d7e99]">
          <View class="absolute left-0 right-0 top-0 h-[18] bg-gradient-to-b from-[#b2becf] to-[#8d9cb4]" />
          <View class="absolute left-0 right-0 top-[18] h-[18] bg-gradient-to-b from-[#7d8ea8] to-[#66778f]" />
          <View class="absolute left-0 right-0 top-0 h-[1] bg-[#ccd4df]" />
          <View class="absolute left-0 right-0 bottom-0 h-[1] bg-[#3d4d64]" />

          <Text class="absolute left-[62] right-[62] top-[7] text-center text-base text-[#3c4d6480] font-bold">Info</Text>
          <Text class="absolute left-[62] right-[62] top-[8] text-center text-base text-white font-bold">Info</Text>

          <View class="absolute right-[4] top-[6] w-[44] h-[24] rounded-[4] border border-[#3f4f66] bg-gradient-to-b from-[#9dabc0] via-[#7b8ca5] to-[#67788f]">
            <View class="absolute left-[2] right-[2] top-[1] h-[1] bg-[#c8d1de80]" />
            <Text class="absolute left-0 right-0 top-[3] text-center text-xs text-[#39495f80] font-bold">Edit</Text>
            <Text class="absolute left-0 right-0 top-[4] text-center text-xs text-white font-bold">Edit</Text>
          </View>
        </View>

        <View debugName="CardIdentity" class="absolute left-[14] top-[60] w-[130]">
          <View class="w-[70] h-[70] p-[3] rounded-[4] bg-white border border-[#8f959d] shadow">
            <Image debugName="ContactPhoto" class="w-[64] h-[64]" src="contact-photo.svg" />
          </View>
          <Text class="absolute left-0 top-[78] text-sm text-[#3b4149]">{card().given}</Text>
          <Text class="absolute left-0 top-[96] text-lg text-[#14181d] font-bold">{card().surname}</Text>
          <Text class="absolute left-0 top-[126] text-xs text-[#6a727b]">
            Record {card().ordinal} of 10,000
          </Text>
        </View>

        <View class="absolute left-[14] top-[205] w-[130] h-[26] items-center justify-center rounded-[8] bg-white border border-[#a4abb3]">
          <Text class="text-sm text-[#1b4fa8] font-bold">Share Contact</Text>
        </View>

        <View debugName="CardPhones" class="absolute left-[154] top-[60] w-[232] h-[98] flex-col rounded-[8] bg-white border border-[#a4abb3] overflow-hidden">
          {Field("mobile", () => card().mobile, false)}
          <View class="w-full h-[1] bg-[#c9ced4]" />
          {Field("home", () => card().home, false)}
          <View class="w-full h-[1] bg-[#c9ced4]" />
          {Field("work", () => card().work, false)}
        </View>

        <View debugName="CardDetails" class="absolute left-[154] top-[166] w-[232] h-[65] flex-col rounded-[8] bg-white border border-[#a4abb3] overflow-hidden">
          {Field("email", () => card().email, true)}
          <View class="w-full h-[1] bg-[#c9ced4]" />
          {Field("birthday", () => card().birthday, false)}
        </View>
      </View>

      <AuxiliarySurface>
        <View debugName="Contacts" class="relative w-full h-full bg-white overflow-hidden">
          <View debugName="ContactsTable" class="absolute left-0 right-0 top-[36] bottom-0">
            <VirtualList
              surface="auxiliary"
              controller={listScroller}
              count={LIST_SLOTS}
              rowHeight={SLOT}
              height={LIST_HEIGHT}
              overscan={ROW_HEIGHT}
              focusRows={false}
              renderRow={renderSlot}
              onRowPress={selectSlot}
            />

            <View
              class={pinnedSection() < 0 ? "hidden" : "absolute left-0 right-0 top-0 h-[18]"}
              style={{ translateY: pinnedShift() }}
            >
              {SectionHeader(pinnedLetter, "PinnedSection")}
            </View>
          </View>

          <View
            debugName="ContactsNavigation"
            class="absolute left-0 right-0 top-0 h-[36] bg-[#6d7e99]"
          >
            <View class="absolute left-0 right-0 top-0 h-[18] bg-gradient-to-b from-[#b2becf] to-[#8d9cb4]" />
            <View class="absolute left-0 right-0 top-[18] h-[18] bg-gradient-to-b from-[#7d8ea8] to-[#66778f]" />
            <View class="absolute left-0 right-0 top-0 h-[1] bg-[#ccd4df]" />
            <View class="absolute left-0 right-0 bottom-0 h-[1] bg-[#3d4d64]" />

            <Text class="absolute left-[62] right-[62] top-[7] text-center text-base text-[#3c4d6480] font-bold">All Contacts</Text>
            <Text class="absolute left-[62] right-[62] top-[8] text-center text-base text-white font-bold">All Contacts</Text>

            <View class="absolute left-[4] top-[6] w-[52] h-[24] rounded-[4] border border-[#3f4f66] bg-gradient-to-b from-[#9dabc0] via-[#7b8ca5] to-[#67788f]">
              <View class="absolute left-[2] right-[2] top-[1] h-[1] bg-[#c8d1de80]" />
              <Text class="absolute left-0 right-0 top-[3] text-center text-xs text-[#39495f80] font-bold">Groups</Text>
              <Text class="absolute left-0 right-0 top-[4] text-center text-xs text-white font-bold">Groups</Text>
            </View>

            <View
              debugName="ContactsAdd"
              class="absolute right-[4] top-[6] w-[26] h-[24] rounded-[4] border border-[#3f4f66] bg-gradient-to-b from-[#9dabc0] via-[#7b8ca5] to-[#67788f]"
            >
              <View class="absolute left-[2] right-[2] top-[1] h-[1] bg-[#c8d1de80]" />
              <View class="absolute left-[7] top-[10] w-[11] h-[3] bg-white" />
              <View class="absolute left-[11] top-[6] w-[3] h-[11] bg-white" />
            </View>
          </View>

          <View
            debugName="ContactsIndex"
            ref={(node) => (contactIndexNode = node)}
            class={
              indexing()
                ? "absolute right-0 top-[36] bottom-0 w-[20] rounded-[8] bg-[#9aa3ad99]"
                : "absolute right-0 top-[36] bottom-0 w-[20]"
            }
          >
            {INDEX_ENTRIES.map((entry, index) => (
              <View
                class="absolute right-0 w-[15] flex-row items-center justify-center"
                style={{ insetT: INDEX_PAD + index * INDEX_PITCH, height: INDEX_PITCH }}
              >
                {entry === null
                  ? <View class="w-[3] h-[3] rounded-full bg-[#2f5288]" />
                  : <Text class="text-xs text-[#2f5288]">{entry}</Text>}
              </View>
            ))}
          </View>
        </View>
      </AuxiliarySurface>
    </>
  );
}
05

Bring-up guide

upstream documents · rendered verbatim

Toolchain, build, deploy and acceptance are owned by pocket-stack/pocketjs. The documents below are rendered from the pinned checkout without edits; relative links point back into the repository at the same revision.

Nintendo 3DS host

13 min read · 2,911 words

Rust and devkitARM toolchains, dual PICA200 targets, `.3dsx` / CIA packaging, Wi-Fi updates and Azahar goldens.

rendered verbatim fromhosts/3ds/README.md@ 2d20ddaraw ↗

PocketJS on the 3DS: QuickJS runs the guest bundle, the Rust core owns the retained tree, layout, animation and DrawList emission, and a C backend walks that DrawList into PICA200 draw calls through citro3d. The app owns the 400x240 top screen and a simultaneous 320x240 auxiliary bottom screen, both at rasterDensity 1 and presentation native, under the out-of-registry 3ds-dev profile in tools/3ds-profile.ts. The resistive panel reports contacts through input.touch.auxiliary.

The CIA boots and renders the calibration app on a New 3DS LL. The profile remains out of the production registry because the current hardware and golden suite does not directly exercise the synthesized cursor, sprites, streamed textures or a large font atlas.

hosts/psp puts its GPU backend in Rust because the psp crate has bindings for the GE. citro3d is a C library of mostly static inline functions, so here the split is the other way round and matches hosts/iphone2g: C owns the graphics API, Rust owns everything above it. That is why this host's crate exports the DrawList itself (ui_draw, ui_draw_list_ptr, ui_draw_list_len) and the texture and font registries over the C ABI, which engine/ui-cabi does not — its GLES backends consume the list internally.

core/                 pocketjs-3ds-core: the ui_* C ABI over pocketjs-core
  src/lib.rs          lifecycle, HostOps, DrawList handoff, pak feed
  src/alloc.rs        #[global_allocator] over newlib + panic handler
include/pocket_core.h the C header for the above
src/main.c            process boot, reusable guest lifecycle, frame loop
src/runtime.c         .pocket admission, immutable storage, active/rollback state
src/devserver.c       discovery, paired TCP pump, uploads, screenshots, receipts
src/dev_protocol.c    byte-order-safe development wire encoding and admission
src/devmenu.c         Runtime-owned bottom-screen development menu
src/gfx.c             the DrawList -> citro3d walker
src/qjs.c             QuickJS embedding: globalThis.ui -> ui_* calls
src/input.c           3DS keys and circle pad -> the PSP BTN bitmask
src/vshader.v.pica    the PICA200 vertex shader
Makefile              run INSIDE the container by tools/3ds.ts
app.rsf               the CIA descriptor makerom reads
icon.png              48x48 SMDH icon

Building

Two toolchains, one repository:

  • The Rust staticlib builds on macOS. armv6k-nintendo-3ds is a built-in rustc target, so core/.cargo/config.toml only has to ask for build-std; core/rust-toolchain.toml pins the nightly. The target defaults to unwind, so the crate sets panic = "abort".
  • The C half builds in the digest-pinned devkitpro/devkitarm image, which brings arm-none-eabi-gcc, libctru, citro3d, picasso, smdhtool and 3dsxtool.

tools/3ds.ts drives both and hands this Makefile container paths in environment variables (the list is at the top of the Makefile). Nothing here reaches outside hosts/3ds except through them.

bun tools/3ds.ts 3ds-demo              # dist/3ds/<output>.3dsx
bun tools/3ds.ts 3ds-demo --capture    # the deterministic e2e binary
bun tools/3ds.ts 3ds-demo --cia        # also dist/3ds/<output>.cia
bun tools/3ds.ts 3ds-demo --pocket-only # rebuild only dist/3ds/<output>.pocket

Every build writes a target-thinned .pocket next to the native artifact. The .pocket contains the admitted manifest, resolved plan, compiled JS and target-flavoured PAK. The native runtime embeds the same file as its immutable recovery guest; it no longer embeds independent app.js and app.pak files.

Updating the guest from SD

The runtime checks one staging path at boot:

sdmc:/pocketjs/runtime/pending.pocket

With an FTP server running as a separate homebrew application, build and upload the guest package, then exit the FTP server and start Pocket Runtime:

bun tools/3ds.ts 3ds-demo --pocket-only
curl --ftp-create-dirs -T dist/3ds/pocket3ds-demo-main.pocket \
  ftp://<device>/pocketjs/runtime/pending.pocket

The runtime verifies the package footer, exact 3ds-dev target, host ABI, identity, resolved plan and NUL-terminated JS section before it can boot. A complete pending package is renamed to sdmc:/pocketjs/runtime/packages/<hash>.pocket; package blobs are immutable. An incomplete FTP upload stays at pending.pocket and does not replace the running or accepted guest.

A package becomes active only after its first submitted PICA command list has retired successfully. State is committed by appending a generation marker under sdmc:/pocketjs/runtime/state/. Eval, frame or first-render failure loads the previous active package, then last-good, then the embedded ROMFS recovery package. Power loss before the generation marker leaves the previous generation active.

L+R+X requests the same package check at a GPU-idle frame boundary. The full chord is removed from the application's button mask. This supports an emulator or direct SD writer; a separate 3DS ftpd cannot run concurrently with Pocket Runtime.

Runtime receipts are written to:

sdmc:/pocketjs/runtime/status.txt
sdmc:/pocketjs/runtime/last-error.txt

status.txt records the current generation, active hash, last-good hash, running package hash and source path. last-error.txt records the failed phase without deleting the rejected package blob.

In-process development connection

L+R+SELECT opens the 3DS host's native development menu on the bottom screen. It shows the current IP and port, pairing or connection state, active generation, running package hash, update and screenshot counts, and transport errors. X requests a dual-screen screenshot from a connected client; B or START closes the menu.

While visible, the menu replaces the guest's bottom-screen DrawList with a host-owned DrawList and consumes all guest input. It uses the same verified PICA200 backend as the guest and reads a fixed native Runtime snapshot; it is not part of globalThis.ui, the guest input contract, or a published PocketJS capability. The input latch stays active until the keys used to close the menu have been released.

Pocket Runtime listens on TCP and UDP port 8131 when this file exists:

sdmc:/pocketjs/runtime/dev.key

Pair once while ftpd is running, then restart Pocket Runtime:

bun run 3ds:dev pair --host <device-ip> --ftp-port 5000

The pairing command generates a random 32-byte key, stores the local copy under .pocket/3ds/devices/, uploads the device copy, and verifies the FTP readback byte for byte. The Runtime does not open a listener without the key, and a client must prove the complete key before any command or package byte is accepted.

The key authenticates a client but does not encrypt the TCP stream. Use the listener on a trusted LAN, and pass --rotate to pair after a key is exposed.

After pairing, ftpd is not part of the development loop:

bun run 3ds:dev discover
bun run 3ds:dev push  --app 3ds-demo
bun run 3ds:dev probe
bun run 3ds:dev dev   --app 3ds-demo

discover, push, probe, and dev do not require the 3DS IP. The Runtime answers one fixed-size UDP discovery request with its target, ABI, TCP port, generation, active hash, and a stable ID derived from the pairing key. The reply never contains the key. The desktop tool matches that ID to a local key and then authenticates the TCP connection with the complete 32-byte key. This keeps the pairing valid when DHCP changes the console's address. Pass --host <device-ip> when broadcast discovery is unavailable; the native menu supplies that address.

dev waits when no paired Runtime is present, replaces a disconnected TCP client, and rediscovers the same paired device ID until it reconnects. Panel commands and keyboard shortcuts are routed only to the current authenticated client. A client that receives no PONG for eight seconds is replaced even when the operating system has not reported the half-open TCP socket as closed. The Runtime reserves the latest PONG until the bounded output queue can send it; bulk screenshot traffic cannot discard the heartbeat response.

push builds and transfers the target-thinned .pocket, then waits for the device's accepted-after-retired-frame receipt. probe requests runtime status, native counters, the component tree, a live REPL evaluation, a console message, and a combined top/bottom PNG. dev keeps the DevTools panel attached; r rebuilds and pushes, s captures both screens, and o opens the panel.

One authenticated, ordered TCP connection carries every development message. JSON frames contain only Pocket DevTools control and logs. Package and rotated RGB8 screenshot bytes use bounded binary frames, so bulk data never enters QuickJS or the application's capability surface. Uploads stream to network-upload.pocket; the existing package admission, immutable blob, GPU-idle cold-swap, acceptance, and rollback path remains the only route to an active guest.

The connection updates the guest .pocket, not the running .3dsx or CIA host binary. A native host or ABI change still requires deploying a new .3dsx/CIA and restarting it; the embedded .pocket remains its final recovery guest. Keeping that boundary lets ordinary app, asset and resolved-plan changes use the in-process loop without letting a guest replace the process that admits and rolls it back.

Two build-time facts are load-bearing:

  • -DJS_NO_NAN_BOXING must be on every translation unit that includes quickjs.h. The header turns NaN boxing on by default for any 32-bit target, which makes JSValue 8 bytes instead of 16, while libquickjs.a is compiled with the flag. The mismatch links cleanly and then hands the library differently shaped values: the guest boots and QuickJS's GC walks garbage pointers a few hundred milliseconds later.
  • __stacksize__ is raised to 1 MiB. devkitPro's 3dsx crt0 gives the main thread 32 KiB, and QuickJS's interpreter plus the guest's render pass recurse far past that.

The CIA, and the memory region it asks for

--cia writes dist/3ds/<output>.cia next to the .3dsx, from the same ELF and the same staged romfs directory.

A .3dsx runs under the Homebrew Launcher and lives inside hbmenu's memory allocation. A CIA is its own installed title and asks the kernel for its own memory region. That request is SystemMode: 64MB in app.rsf — the largest region an Old 3DS gives an application, out of the console's 128 MiB — plus SystemModeExt: 124MB, which a New 3DS honours and an Old 3DS ignores. A guest whose arena, expanded textures and pak add up past what hbmenu hands out has no way to ask for more as a .3dsx. That is why the format is here: Pocket Voxel's 12 MiB arena plus ~14 MiB of expanded textures plus a 30.6 MiB pak is exactly the budget that may not fit under the Homebrew Launcher on a real console.

Three facts about the packaging itself:

  • No banner is required. makerom needs one only for a title that plays an animated banner in HOME Menu. The SMDH passed as -icon already carries the icon and the title strings, and -exefslogo supplies the boot logo.
  • The romfs is a directory, not an image. makerom builds the romfs itself from RomFs.RootPath, pointed at the same directory 3dsxtool embeds. Handing it the raw romfs binary that mkromfs3ds produces — the container 3dsxtool takes — fails with Invalid RomFS Binary; the two packagers share the staged directory and nothing else.
  • makerom ships in neither devkitPro nor Homebrew, so tools/3ds.ts fetches one pinned github.com/3DSGuy/Project_CTR revision into dist/3ds/makerom/src, builds it in the same container as everything else, and caches the binary against the container image and revision. mbedtls, blz and yaml are vendored in that repository, so the fetch is the only step that needs the network.

The title's identity comes from the resolved plan, never from a literal per app (ciaUniqueId, ciaProductCode, ciaProcessName in tools/3ds.ts): the unique id is 0xFF000 | hash(app.id) & 0xFFF, inside the 0xFF000-0xFFFFF block that no retail or system title uses, so an app keeps one title id across rebuilds and an install replaces its predecessor instead of accumulating. The product code is CTR-P- plus four characters of the app id. The RSF's BasicInfo.Title is the exheader's process name, which is 8 bytes — the cut happens in TypeScript rather than silently inside makerom, and the title HOME Menu shows is the SMDH's, still whole.

Azahar installs one and then boots the installed title from its own SD card:

azahar -i dist/3ds/pocket3ds-demo-main.cia
azahar "$HOME/Library/Application Support/Azahar/sdmc/Nintendo 3DS/\
00000000000000000000000000000000/00000000000000000000000000000000/\
title/00040000/0ffc1900/content/0429b6bc.app"

00040000 is the application category and 0ffc1900 is this demo's unique id shifted up by its 8-bit variation; tools/3ds.ts prints the whole title id when it writes the file. A capture build installed and booted this way produced frames byte-identical to the .3dsx goldens in tests/goldens/3ds/.

What globalThis.ui has to publish

Beyond the HostOps table, src/qjs.c publishes four properties the framework reads directly. __host and __hostAbi come from the build's -D defines and gate mounting. __textures and __sprites are the pak name tables. The fourth is geometry:

  • ui.__viewport is the logical UI size, and omitting it is a layout bug, not a missing nicety. framework/src/index.ts sizes the mounted app and overlay layers from it and falls back to the spec screen, 480x272, when a host leaves it off. On this 400x240 panel that fallback lays the app out 80 px too wide: the extra width is invisible for anything anchored left, and moves everything measured from the layer's right edge — justify-between, a row's last child after a grow sibling, every right-0 absolute — off the panel. The value is read back from the core with ui_viewport_width / ui_viewport_height after main.c has called ui_set_viewport, so the JS root layer and the native root node cannot drift apart. Publishing a size is not a live-resize capability: that needs installResizeViewportHook, which a takeover host never calls.

What the backend has to honour

src/gfx.c is the 3DS twin of engine/ui-cabi/src/gl/mod.rs: the same walk, the same texture and font-atlas caches, the same batching by texture and scissor. It does no clipping — the core's CPU clip stage guarantees every coordinate is already inside the viewport and i16-safe. The PICA200 adds:

  • Render targets are created rotated: C3D_RenderTargetCreate(240, 400, …) for the top screen, and Mtx_OrthoTilt keeps guest coordinates landscape. C3D_FrameDrawOn resets the viewport, so C3D_SetViewport comes after it.
  • The scissor register is in raw framebuffer pixels and both of its axes run opposite to the logical ones: the horizontal pair counts down from the logical height, the vertical pair from the logical width. Flipping only one of them mirrors the clip along the other axis, which stays invisible until the clipped content is not already the size of its window.
  • Textures must be power-of-two, 8..1024 per side, and already in the hardware's tiled layout — 8x8 tiles row-major, Morton order inside a tile. C3D_TexUpload is a plain memcpy. Non-power-of-two images get a power-of-two envelope and their UVs are rescaled.
  • Tiled row 0 is sampled at v = 1, so the source is flipped vertically while it is tiled and DrawList UVs then pass through unchanged.
  • RGBA8 texels are stored bytes A, B, G, R — the reverse of the core's order.
  • Vertex buffers must live in linearAlloc memory (BufInfo_Add rejects any pointer below physical 0x18000000), and the arena is flushed out of the data cache before the draws that read it.
  • There is no fragment shader: one TEV stage modulates the sampled texel by the vertex colour, and untextured ops bind an 8x8 white texture so that single stage covers every op. There is also no paletted format, so PSM_T8 is expanded at upload.

Capture and the Azahar loop

-DPOCKETJS_CAPTURE turns main.c into the e2e binary: input comes from a tape baked into the binary rather than from the emulator's filesystem, the frames in [POCKETJS_CAP_START, POCKETJS_CAP_START + POCKETJS_CAP_N) are read back off the render target, and the process parks instead of exiting — Azahar does not stop when the app returns from main().

Emitted under sdmc:/pocketjs-captures/: fNNNN.raw named by the process-global frame counter (exactly 400*240*4 bytes), then done written only after the last frame is closed, and error.txt on the failure path so the driver reports the message instead of a timeout.

The readback is not gfxGetFramebuffer after C3D_FrameEnd — that buffer has already been swapped and reads back black. It is an explicit C3D_SyncDisplayTransfer of the render target, after a vblank so the GPU has finished. The bytes stay in the screen's rotated orientation, 240 wide by 400 tall, so the driver decodes src[(x * 240 + (239 - y)) * 4] into dst[y * 400 + x] and reads the channels back as A, B, G, R.

That transfer's output format is GX_TRANSFER_FMT_RGB8, and main.c widens B, G, R into the A, B, G, R capture word itself. Asking the transfer engine for a 32-bit linear output out of this 240x400 tiled colour buffer returns rows that are each individually correct and progressively misregistered — every fourth output row slips a further 64 texels — while the same frame presents perfectly on the screen. Azahar's software rasterizer answers the 32-bit request correctly, so the wrong format is invisible until something renders through a GPU: the identical build and the identical CIA both came back shredded under Vulkan. Measured in the Pocket Voxel host against a known probe rectangle, RGBA8 out matched 74.6% of it and RGB8 out matched 100.0%. RGB8 is also the format citro3d's own presentation transfer uses, so the capture travels the path the screen travels; the alpha byte it drops was never read, because the decode takes R, G and B only.

bun tests/e2e/azahar.ts

Azahar's two renderers agree on the picture but not on every byte. With the RGB8 readback in place, a Vulkan (graphics_api=2) capture of the demo differs from the committed Software (graphics_api=0) goldens on 5.1% of pixels, 99.5% of them by 1 or 2 of 255 — the two rasterizers round texture filtering and TEV blending differently — plus 24 pixels along the logo's one diagonal edge, by up to 157. A golden therefore still belongs to one backend, and the e2e fixture pins it to Software, the backend that does not depend on the developer's GPU driver. E2E_AZAHAR_GRAPHICS_API=2 bun tests/e2e/azahar.ts re-measures the gap.

Azahar derives its whole user directory from $HOME and has no switch for any part of it, so a run gets its own config and SD card by getting its own $HOME.

Not advertised

input.touch is deliberately absent from the profile. The touchscreen belongs to the bottom auxiliary surface, so it is exposed only as input.touch.auxiliary; contacts are never remapped into the top screen's coordinate space. audio.pcm is not implemented in v1.

Photo

Wikimedia Commons · New-3DS-XL-Black-Transparent-Fixed.png — Ejay; transparent cutout by Pokemon59, CC BY-SA 4.0. The XL name is used outside Japan; it is the same large-body model as the LL hardware receipt.