Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/3ds-runtime.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ 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
- 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/3ds-dev-worker.test.ts tests/3ds-pairing.test.ts
- run: bun test --conditions=browser tests/offload.test.ts tests/offload-images.test.ts tests/offload-meshes.test.ts tests/offload-provider.test.ts tests/tile-viewport.test.ts tests/resource-cache.test.ts tests/resource-view.test.ts tests/resource-pack.test.ts tests/resource-pack-view.test.ts
38 changes: 38 additions & 0 deletions engine/core/src/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,20 @@ pub fn select_guest<'a>(
})
}

/// Compare already hash-verified packages against the embedded native contract.
/// Package identity and the resolved native plan cannot change through JS reload.
pub fn compatible_guest(a: &[u8], b: &[u8], target: &str) -> bool {
fn check(a: &[u8], b: &[u8], target: &str) -> Result<bool, PackageError> {
let a = Package::parse(a, true)?;
let b = Package::parse(b, true)?;
let (Some(a), Some(b)) = (a.find_variant(target)?, b.find_variant(target)?) else { return Ok(false) };
let (Some(ai), Some(bi)) = (a.identity()?, b.identity()?) else { return Ok(false) };
Ok(ai.id == bi.id && ai.output == bi.output && a.host_abi == b.host_abi &&
a.section(section::PLAN)? == b.section(section::PLAN)?)
}
check(a, b, target).unwrap_or(false)
}

impl<'a> Variant<'a> {
/// A section payload by kind (unknown kinds are simply never asked for —
/// forward compatible by construction).
Expand Down Expand Up @@ -374,6 +388,30 @@ mod tests {
assert_eq!(widget.host_abi, 3);
}

#[test]
fn reload_keeps_app_and_native_plan_but_allows_guest_asset_changes() {
// This comparator runs only AFTER select_guest verifies the footer.
assert!(compatible_guest(FIXTURE, FIXTURE, "psp"));
assert!(!compatible_guest(FIXTURE, FIXTURE, "3ds-dev"));
assert!(!compatible_guest(FIXTURE, &FIXTURE[..100], "psp"));
let pkg = Package::parse(FIXTURE, false).unwrap();
let variant = pkg.find_variant("psp").unwrap().unwrap();
for (kind, allowed) in [(section::JS, true), (section::PAK, true), (section::PLAN, false)] {
let section = variant.section(kind).unwrap().unwrap();
let offset = section.as_ptr() as usize - FIXTURE.as_ptr() as usize;
let mut changed = FIXTURE.to_vec();
changed[offset] ^= 1;
assert_eq!(compatible_guest(FIXTURE, &changed, "psp"), allowed);
}
let identity = variant.identity().unwrap().unwrap();
for field in [identity.id, identity.output] {
let offset = field.as_ptr() as usize - FIXTURE.as_ptr() as usize;
let mut changed = FIXTURE.to_vec();
changed[offset] = b'Z';
assert!(!compatible_guest(FIXTURE, &changed, "psp"));
}
}

#[test]
fn tamper_trips_the_footer_hash() {
let mut evil = FIXTURE.to_vec();
Expand Down
6 changes: 5 additions & 1 deletion hosts/3ds/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ LDFLAGS := -specs=3dsx.specs $(ARCH) -Wl,--gc-sections -Wl,-Map,$(BUILD)/pocketj
LIBPATHS := -L$(DEVKITPRO)/libctru/lib -L$(DEVKITPRO)/portlibs/3ds/lib
LIBS := -lcitro3d -lctru -lz -lm

OBJECTS := $(BUILD)/main.o $(BUILD)/asset_pack.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)/asset_pack.o $(BUILD)/offload.o $(BUILD)/soc.o $(BUILD)/svcwire.o $(BUILD)/runtime.o $(BUILD)/dev_protocol.o $(BUILD)/devserver.o $(BUILD)/dev_transport.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 @@ -150,6 +150,10 @@ $(FLAGS_STAMP): $(FLAGS_STAMP).probe ;
$(BUILD)/%.o: $(SOURCE)/%.c $(BUILD)/vshader_shbin.h $(FLAGS_STAMP) | $(BUILD)
$(CC) $(CFLAGS) -c $< -o $@

$(BUILD)/devserver.o $(BUILD)/dev_transport.o: CFLAGS += -Werror=frame-larger-than=8192

$(BUILD)/devserver.o $(BUILD)/dev_transport.o $(BUILD)/main.o: $(SOURCE)/devserver.h $(SOURCE)/dev_transport.h $(SOURCE)/runtime.h

$(BUILD)/offload.o: $(SOURCE)/offload.h $(SOURCE)/offload_queue.h $(SOURCE)/offload_image.h
$(BUILD)/qjs.o: $(SOURCE)/offload.h $(SOURCE)/offload_coverage.h

Expand Down
71 changes: 59 additions & 12 deletions hosts/3ds/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ core/ pocketjs-3ds-core: the ui_* C ABI over pocketjs-core
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/devserver.c background worker, bounded UI mailboxes, update transactions
src/dev_transport.c worker-owned discovery, paired TCP, uploads and screenshots
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
Expand Down Expand Up @@ -85,8 +86,11 @@ curl --ftp-create-dirs -T dist/3ds/pocket3ds-demo-main.pocket \
ftp://<device>/pocketjs/runtime/apps/<runtime-slot>/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
The worker verifies the package footer, exact `3ds-dev` target, host ABI and
NUL-terminated JS section before it can boot. **Application id, output name and
resolved native plan must match the embedded package.** SD updates are limited
to **8 MiB per package**; changes to native capabilities or build configuration
require installing a new `.3dsx`. A
complete pending package is renamed to
`sdmc:/pocketjs/runtime/apps/<runtime-slot>/packages/<hash>.pocket`; package
blobs are immutable.
Expand All @@ -110,7 +114,8 @@ 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
`L+R+X` requests the same package check on the background worker. A verified
candidate moves to the UI 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.
Expand Down Expand Up @@ -162,9 +167,10 @@ 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,
**The pairing command adopts an existing device key into
`.pocket/3ds/devices/`.** If the device has no key, it installs a random 32-byte
key and verifies the FTP readback byte for byte. Only `--rotate` replaces an
existing device key, preserving pairings across separate application checkouts. 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.

Expand Down Expand Up @@ -214,9 +220,49 @@ 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.
guest. App JS and baked assets can change within the embedded native plan; a
different plan requires a native reinstall.

**Development updates work alongside `io.offload` and SD resource packs.**
A dedicated native worker owns development sockets, pairing-file reads, package
uploads, hashing, immutable-blob preparation and generation-marker writes. The
UI thread copies bounded mailboxes, boots the admitted guest and renders. An
upload or `fsync` cannot make it wait for the development worker. Offload and
asset-pack workers continue independently; deterministic capture builds leave
the development worker disabled.

**Guest replacement restarts QuickJS and the UI tree.** It does not preserve
component state. Replacement happens after the previous GPU submission retires;
shutdown fences old offload and SD requests before freeing the old JS context
and GPU resources. Debug control records carry a guest generation so queued
commands cannot execute against the next guest. The old accepted package stays
resident until the new guest's first GPU frame retires and the worker commits
its generation. Commit failure restores that resident package without a UI
thread SD read. Later guest failures load last-good asynchronously, with the
embedded guest available during recovery.

The mailboxes hold **four 16 KiB input records and four 64 KiB output records**,
plus one runtime snapshot and one candidate transaction. A screenshot uses one
pair of UI-owned linear buffers; the worker borrows them until transmission or
disconnect completes. Large screenshot scratch storage stays outside the
worker's **32 KiB stack**; the build rejects individual worker stack frames over
8 KiB.

To exercise a running console or emulator, use an already paired, compatible
production package. The test temporarily installs diagnostic variants and
restores the supplied package:

```sh
bun tests/e2e/3ds-hot-update.ts --host <device-ip> \
--key .pocket/3ds/devices/<device-ip>-8131.key \
--package dist/3ds/<output>.pocket --out dist/3ds/update-qa
```

It checks frame advancement during a deliberately slow upload, native-plan and
hash rejection, eval and first-frame rejection, recovery after a later frame
fails, and an authenticated two-screen screenshot. Host tests additionally
stall admission and commit, force commit failure, and check queue, screenshot
and buffer ownership with ASan/UBSan.

Two build-time facts are load-bearing:

Expand All @@ -235,8 +281,9 @@ Two build-time facts are load-bearing:
`src/svcwire.c` implements spec ops 30..32 (`svcOpen`/`svcPoll`/`svcSend`)
over the **SVC WIRE (PKNT) protocol** (`contracts/spec/spec.ts`,
`engine/core/src/wire.rs`) — the transport the Vita host speaks in
`hosts/vita/src/net.rs`, reduced to the devserver.c shape: non-blocking
sockets pumped once per frame by the main thread, no threads. The device
`hosts/vita/src/net.rs`. This legacy channel uses non-blocking sockets pumped
by the main thread in builds without `io.offload`; the offload capability uses
its own native worker instead. The device
listens for the companion's once-a-second UDP beacon on port 8621 (or reads a
`sdmc:/pocketjs/host.txt` override, one line `a.b.c.d[:port]`, for
broadcast-hostile networks — a failed override alternates back to beacon
Expand Down
7 changes: 7 additions & 0 deletions hosts/3ds/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ mod heap;

static mut UI: Option<Ui> = None;

/// Worker-safe: borrowed package metadata only, no UI or allocator access.
#[no_mangle]
pub unsafe extern "C" fn pocket_package_same_app(a: *const u8, a_len: usize, b: *const u8, b_len: usize) -> bool {
if a.is_null() || b.is_null() { return false; }
pocketjs_core::package::compatible_guest(bytes(a, a_len), bytes(b, b_len), "3ds-dev")
}

/// Snapshot of the most recent `ui_draw`. The core's `Vec<u32>` reallocates as
/// a frame's op count changes, so this is refreshed per build rather than
/// cached by the caller across frames.
Expand Down
4 changes: 4 additions & 0 deletions hosts/3ds/include/pocket_core.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
*/

#include <stddef.h>
#include <stdbool.h>
#include <stdint.h>

/* Verified target variant borrowed from a caller-owned `.pocket` buffer. */
Expand All @@ -27,6 +28,9 @@ typedef struct {
uint64_t variant_hash;
} PocketGuestPackage;

/* No UI state or allocation: compare admitted package identity sections. */
bool pocket_package_same_app(const uint8_t *a, size_t a_len, const uint8_t *b, size_t b_len);

/* 0 = admitted. The package footer, target, host ABI, identity, plan and
* NUL-terminated JS section are all checked before success. */
int32_t pocket_package_open(
Expand Down
Loading
Loading