Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/3ds-runtime.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
name: 3DS runtime contracts
on:
pull_request:
paths: ['hosts/3ds/**', 'contracts/**', 'tools/3ds*.ts', 'tools/media-stream.ts', 'framework/src/media.ts', 'tests/media.test.ts', 'tools/native-source.ts', 'tools/native-host-build.ts', 'tests/native-source.test.ts', 'tests/3ds*.test.ts', 'tests/fixtures/3ds-*', 'tests/fixtures/3ds-*/**', '.github/workflows/3ds-runtime.yml']
paths: ['hosts/3ds/**', 'contracts/**', 'tools/3ds*.ts', 'tools/media-stream.ts', 'tools/media-download.ts', 'tests/media-library.test.ts', 'tests/fixtures/media-library/**', 'framework/src/media.ts', 'tests/media.test.ts', 'tools/native-source.ts', 'tools/native-host-build.ts', 'tests/native-source.test.ts', 'tests/3ds*.test.ts', 'tests/fixtures/3ds-*', 'tests/fixtures/3ds-*/**', '.github/workflows/3ds-runtime.yml']
push:
branches: [main]
paths: ['hosts/3ds/**', 'contracts/**', 'tools/3ds*.ts', 'tools/media-stream.ts', 'framework/src/media.ts', 'tests/media.test.ts', 'tools/native-source.ts', 'tools/native-host-build.ts', 'tests/native-source.test.ts', 'tests/3ds*.test.ts', 'tests/fixtures/3ds-*', 'tests/fixtures/3ds-*/**', '.github/workflows/3ds-runtime.yml']
paths: ['hosts/3ds/**', 'contracts/**', 'tools/3ds*.ts', 'tools/media-stream.ts', 'tools/media-download.ts', 'tests/media-library.test.ts', 'tests/fixtures/media-library/**', 'framework/src/media.ts', 'tests/media.test.ts', 'tools/native-source.ts', 'tools/native-host-build.ts', 'tests/native-source.test.ts', 'tests/3ds*.test.ts', 'tests/fixtures/3ds-*', 'tests/fixtures/3ds-*/**', '.github/workflows/3ds-runtime.yml']
permissions:
contents: read
jobs:
Expand All @@ -17,4 +17,4 @@ jobs:
with:
bun-version: 1.3.14
- run: bun install --frozen-lockfile
- run: bun test tests/3ds-profile.test.ts tests/3ds-runtime-state.test.ts tests/3ds-runtime-wire.test.ts tests/3ds-soc.test.ts tests/native-source.test.ts tests/blackberry-classic.test.ts tests/media.test.ts
- run: bun test tests/3ds-profile.test.ts tests/3ds-runtime-state.test.ts tests/3ds-runtime-wire.test.ts tests/3ds-soc.test.ts tests/native-source.test.ts tests/blackberry-classic.test.ts tests/media.test.ts tests/media-library.test.ts
19 changes: 18 additions & 1 deletion contracts/spec/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,28 @@ export const MEDIA = Object.freeze({
sampleRate: 22050, channels: 2, audioFrames: 1024,
tokenChars: 64,
packetCredits: 8,
captionWidth: 256, captionHeight: 32,
});
export const MEDIA_PACKET = Object.freeze({ video: 1, audio: 2, end: 3, error: 4 });
export const MEDIA_PACKET = Object.freeze({ video: 1, audio: 2, end: 3, error: 4, caption: 5 });
export interface MediaSource {
/** Numeric IPv4 address of the paired companion's media endpoint. */
host: string;
port: number;
/** Ephemeral stream ticket issued by that companion. */
token: string;
}
export interface LocalMediaSource { file: string; positionMs?: number }
export interface MediaCaption { width: number; height: number; coverage: string; endMs: number }
export interface MediaLibraryEntry { key: string; title: string; language: string; durationMs: number; bytes: number; video: boolean; captions: boolean }
export interface MediaDownloadStatus { phase: "idle" | "connecting" | "downloading" | "verifying" | "complete" | "cancelled" | "error"; receivedBytes: number; totalBytes: number; error: string }
export interface MediaLibraryOps {
download(host: string, port: number, token: string, key: string): boolean;
cancelDownload(): void;
downloadStatus(): string;
refreshLibrary(): boolean;
library(): string | null;
removeDownload(key: string): boolean;
}
export type MediaPhase = "idle" | "opening" | "buffering" | "playing" | "paused" | "ended" | "error";
export interface MediaStatus {
phase: MediaPhase;
Expand All @@ -39,7 +52,11 @@ export interface MediaOps {
texture(): number;
/** Bounded snapshot. No socket, filesystem or decoder calls on the UI. */
status(): string;
openLocal?(key: string, positionMs: number): boolean;
/** Changed timed caption, null when unchanged, or an empty object to clear. */
caption?(): string | null;
}
export function validMediaKey(key: string): boolean { return typeof key === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(key); }
export function validMediaSource(source: MediaSource): boolean {
return typeof source?.host === "string" && /^\d{1,3}(\.\d{1,3}){3}$/.test(source.host)
&& source.host.split(".").every(p => Number(p) <= 255)
Expand Down
49 changes: 48 additions & 1 deletion docs/MEDIA.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ firmware in Rosalina, then reopen the media source. The dump reads firmware
from the console; the application does not distribute firmware. See the
[devkitPro audio setup](https://github.com/devkitPro/3ds-examples/blob/master/audio/README.md).
The 3DS profile remains
private and its host ABI is 10; ABI 8 launchers require a native replacement.
private and its host ABI is 11; earlier launchers require a native replacement.
The CIA declares `mvd:STD` service access and the MVD system-module dependency
listed in [3dbrew's title table](https://www.3dbrew.org/wiki/Title_list#00040130_-_System_Modules).

Expand All @@ -93,3 +93,50 @@ The 3DS decoder requests **`MVD_OUTPUT_BGR565` (0x40002)** for the packed words
consumed by `GX_TRANSFER_FMT_RGB565` and `GPU_RGB565`. The MVD and GPU names use
different channel-order conventions. Selecting MVD's `RGB565` exchanges red
and blue; the companion must retain the source colors.

## Local media and downloads

`mediaLibrary()` submits downloads, refreshes and deletions to a storage
worker. `download(source, key)` accepts a ticketed companion endpoint and an
entry key containing 1–64 ASCII letters, digits, hyphens or underscores.
**The key names an entry under `sdmc:/pocketjs/media/<app-slot>/`.** It cannot
name a path outside that directory. The application reads `status()` for
connection, transfer, verification, completion, cancellation and errors;
`entries()` returns a new library snapshot when one is published.

`createMediaDownloadServer` in `tools/media-download.ts` serves immutable
PKDL files. Each transfer consumes one ticket and acknowledges the 256-byte
header, then each **32 KiB block**. The storage worker writes `.part`, checks
the payload CRC32, closes the file, reads it back, checks CRC32 again, exports
the UTF-8 `.vtt` sidecar, and renames the package to `.pkd`. **Only committed
packages appear in the library.** Cancel and transfer failures remove the
temporary package. Starting a download never replaces an existing key.

The 256-byte PKDL header records media, index and caption byte lengths,
duration, CRC32, a bounded UTF-8 title and language. The payload contains a
PKMV stream, 12-byte keyframe records `(PTS, media offset, active-caption
offset)`, then WebVTT. Caption-only packages have zero media and index bytes.
The current limits are **64 library entries, a package below 2 GiB, a duration
up to 24 hours, and 4 MiB of WebVTT**. The companion owns preparation and
progress reporting before transfer; the native worker owns SD progress.

`mediaPlayer().open({ file: key, positionMs })` opens a committed package.
The playback worker searches the keyframe index, starts at the preceding IDR,
and discards audio and presentation frames before the requested position.
**Local playback, pause, volume and seek require no companion connection.**
An application must keep its local player mounted when a companion session
disconnects. Deleting the selected entry requires closing that player first.

## Timed captions

Packet kind 5 contains a duration in milliseconds, 16-bit width and height,
and **256×32 pixels of 2-bit alpha coverage**. The companion rasterizes text
in the source script. Coverage stays in the saved stream, and WebVTT remains
available as text on SD. Native playback queues eight cues and selects them
using the audio clock; local seek restores the cue active at the keyframe.

`mediaPlayer().caption()` returns a changed cue, an empty object to clear,
or `null` when unchanged. The guest polls this method even when captions are
hidden, uploads changed coverage through `uploadCoverage`, and releases the
previous texture. Each caption handoff copies at most 2 KiB of coverage;
the UI performs no file or socket reads.
1 change: 1 addition & 0 deletions framework/compiler/subpaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const SUBPATHS: Record<string, SubpathDecl> = {
audio: { file: "framework/src/audio-api.ts", aliases: TWINS },
media: { file: "framework/src/media.ts", aliases: TWINS },
"media/provider": { file: "tools/media-stream.ts" },
"media/download-provider": { file: "tools/media-download.ts" },
"media/audio": { file: "contracts/spec/media-adpcm.ts" },
clock: { file: "framework/src/clock.ts", aliases: TWINS },
config: { file: "framework/src/config.ts" },
Expand Down
29 changes: 26 additions & 3 deletions framework/src/media.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { MEDIA, validMediaSource, type MediaOps, type MediaSource, type MediaStatus } from "../../contracts/spec/media.ts";
import { MEDIA, validMediaSource, validMediaKey, type MediaOps, type MediaSource, type MediaStatus, type LocalMediaSource, type MediaCaption, type MediaLibraryOps, type MediaLibraryEntry, type MediaDownloadStatus } from "../../contracts/spec/media.ts";
export { MEDIA };
export type { MediaSource, MediaStatus, MediaPhase } from "../../contracts/spec/media.ts";
export type { MediaSource, MediaStatus, MediaPhase, LocalMediaSource, MediaCaption, MediaLibraryEntry, MediaDownloadStatus } from "../../contracts/spec/media.ts";

/** One native playback plane per realm. Surface placement is an ordinary
* Image binding, independent of the number of displays and input devices. */
export function mediaPlayer(ops = (globalThis as unknown as { media?: MediaOps }).media) {
if (!ops) throw new Error("Host does not implement media.playback");
return {
open(source: MediaSource): boolean {
open(source: MediaSource | LocalMediaSource): boolean {
if ("file" in source) {
const position = source.positionMs ?? 0;
if (!validMediaKey(source.file) || !Number.isFinite(position) || position < 0 || position > 86400000) throw new Error("Invalid local media source");
return ops.openLocal?.(source.file, Math.round(position)) ?? false;
}
if (!validMediaSource(source)) throw new Error("Invalid companion media source");
return ops.open(source.host, source.port, source.token);
},
Expand All @@ -16,6 +21,24 @@ export function mediaPlayer(ops = (globalThis as unknown as { media?: MediaOps }
volume: (value: number) => ops.volume(Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0),
texture: () => ops.texture(),
status: (): MediaStatus => JSON.parse(ops.status()),
caption: (): MediaCaption | Record<string, never> | null => { const value = ops.caption?.(); return value ? JSON.parse(value) : null; },
};
}

/** SD library operations are bounded handoffs to a storage worker. Keys name
* entries in the host's app-specific media directory, never arbitrary paths. */
export function mediaLibrary(ops = (globalThis as unknown as { media?: MediaLibraryOps }).media) {
if (!ops) throw new Error("Host does not implement a media library");
return {
download(source: MediaSource, key: string) {
if (!validMediaSource(source) || !validMediaKey(key)) throw new Error("Invalid media download");
return ops.download(source.host, source.port, source.token, key);
},
cancel: () => ops.cancelDownload(),
status: (): MediaDownloadStatus => JSON.parse(ops.downloadStatus()),
refresh: () => ops.refreshLibrary(),
entries: (): MediaLibraryEntry[] | null => { const value = ops.library(); return value ? JSON.parse(value) : null; },
remove(key: string) { if (!validMediaKey(key)) throw new Error("Invalid media key"); return ops.removeDownload(key); },
};
}

Expand Down
8 changes: 8 additions & 0 deletions framework/src/virtual-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export interface VirtualListProps {
* false: rows are plain views and the d-pad scrolls the im way. */
focusRows?: boolean;
onRowPress?: (index: number) => void;
/** A stationary hold claims the contact and suppresses its release tap. */
onRowLongPress?: (index: number) => void;
/** Fired every frame while the offset is within nearStartPx of the top —
* guard with your own loading/hasMore flags (the im convention). */
onNearStart?: () => void;
Expand Down Expand Up @@ -286,6 +288,12 @@ export function VirtualList(props: VirtualListProps): SolidJSX.Element {
props.onRowPress?.(row.index);
}
},
onLongPress: props.onRowLongPress ? (c) => {
if (!active()) return;
const row = rowFromContact(c);
setActiveNode(null);
if (row) { setFocusedIndex(row.index); props.onRowLongPress?.(row.index); }
} : undefined,
onCancel: () => {
setActiveNode(null);
if (scroller.state() === "tracking") scroller.endDrag(0); // no fling out of a modal open
Expand Down
6 changes: 4 additions & 2 deletions hosts/3ds/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ endif

ifeq ($(POCKETJS_MEDIA),1)
CFLAGS += -DPOCKETJS_MEDIA
CFLAGS += -DPOCKETJS_MEDIA_ROOT='"sdmc:/pocketjs/media/$(POCKETJS_OFFLOAD_SLOT)"'
endif

ifeq ($(POCKETJS_CAPTURE),1)
Expand All @@ -106,7 +107,7 @@ LDFLAGS := -specs=3dsx.specs $(ARCH) -Wl,--gc-sections -Wl,-Map,$(BUILD)/pocketj
LIBPATHS := -L$(DEVKITPRO)/libctru/lib
LIBS := -lcitro3d -lctru -lm

OBJECTS := $(BUILD)/main.o $(BUILD)/media.o $(BUILD)/offload.o $(BUILD)/soc.o $(BUILD)/svcwire.o $(BUILD)/runtime.o $(BUILD)/dev_protocol.o $(BUILD)/devserver.o $(BUILD)/devmenu.o $(BUILD)/gfx.o $(BUILD)/qjs.o $(BUILD)/input.o $(BUILD)/vshader_shbin.o
OBJECTS := $(BUILD)/main.o $(BUILD)/media.o $(BUILD)/media_library.o $(BUILD)/offload.o $(BUILD)/soc.o $(BUILD)/svcwire.o $(BUILD)/runtime.o $(BUILD)/dev_protocol.o $(BUILD)/devserver.o $(BUILD)/devmenu.o $(BUILD)/gfx.o $(BUILD)/qjs.o $(BUILD)/input.o $(BUILD)/vshader_shbin.o
ELF := $(BUILD)/pocketjs-3ds.elf
SMDH := $(BUILD)/pocketjs-3ds.smdh

Expand Down Expand Up @@ -152,7 +153,8 @@ $(BUILD)/%.o: $(SOURCE)/%.c $(BUILD)/vshader_shbin.h $(FLAGS_STAMP) | $(BUILD)

$(BUILD)/offload.o: $(SOURCE)/offload.h $(SOURCE)/offload_queue.h
$(BUILD)/qjs.o: $(SOURCE)/offload.h $(SOURCE)/offload_coverage.h
$(BUILD)/media.o: $(SOURCE)/media.h $(SOURCE)/media_wire.h $(SOURCE)/media_adpcm.h
$(BUILD)/media.o: $(SOURCE)/media.h $(SOURCE)/media_wire.h $(SOURCE)/media_adpcm.h $(SOURCE)/media_archive.h $(SOURCE)/media_library.h
$(BUILD)/media_library.o: $(SOURCE)/media_library.h $(SOURCE)/media_archive.h $(SOURCE)/media_wire.h
$(BUILD)/main.o $(BUILD)/qjs.o $(BUILD)/gfx.o: $(SOURCE)/media.h

$(ELF): $(OBJECTS) $(POCKETJS_CORE_LIB) $(POCKETJS_QUICKJS_DIR)/libquickjs.a
Expand Down
Loading