Skip to content

Commit f9a0270

Browse files
committed
Finalize locked CFW boot files via a pre-HOS swap payload
package3 and stratosphere.romfs are locked while Atmosphere runs and can't be replaced in-session (proven by the [boot] logs). The boot payload must match package3, so updating fusee/reboot_payload while package3 stays old is the "incorrect fusee version" brick -- including via an RCM-injected fusee.bin. Fix: stage the whole version-matched boot set (package3, stratosphere.romfs, reboot_payload.bin, root fusee.bin) as .ab_new and never write any of them in place, so the old matched set always stays consistent. Reboot into TegraExplorer (bundled, GPL), whose sd:/startup.te renames the sidecars into place before HOS boots -- when they're unlocked -- then chainloads the now-consistent CFW. Arming uses Atmosphere's bpc:ams (amsBpcSetRebootPayload), done dead-last in main() after smExit(); Daybreak's post-install reboot lands in the payload, so CFW + firmware still take one reboot. CFW-only updates reboot via cfwMgrReboot. If anything fails (arming, missing payload, power loss) the old set is intact, so it just boots the old CFW and retries -- it cannot brick. - lib/ams_bpc.{c,h}: vendored Atmosphere reboot-to-payload interface - source/payload_swap.{c,h}: swapPending/swapPrepare/swapArm + startup.te - romfs/TegraExplorer.bin: stock TegraExplorer v4.2.0 (GPL, suchmememanyskill) - extract.c: force-stage the boot set, keep it off pending.txt - firmware_mgr/cfw_mgr/main: arm the swap, clean up stray startup.te Bump to 2.1.0.
1 parent 6eca439 commit f9a0270

12 files changed

Lines changed: 372 additions & 48 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ ROMFS := romfs
1818

1919
APP_TITLE := AetherBlock
2020
APP_AUTHOR := HeXbyt3
21-
APP_VERSION := 2.0.8
21+
APP_VERSION := 2.1.0
2222

2323
#---------------------------------------------------------------------------------
2424
ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE

README.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,18 +115,18 @@ Atmosphere has to be updated before the reboot that loads the new firmware. New
115115

116116
Extracting the CFW package just places files on the SD card; it doesn't touch the currently running system. The old Atmosphere keeps running in memory while you do everything else. The new files only matter at boot, which is why you can do CFW → firmware → one reboot back to back.
117117

118-
### How AetherBlock Handles Locked Files
118+
### How AetherBlock Handles Locked Boot Files
119119

120-
A couple of Atmosphere files (`package3`, `stratosphere.romfs`, and sometimes `AetherBlock.nro` itself) are held open by the running CFW and can't be overwritten with a plain file write. AetherBlock handles this transparently:
120+
`package3` and `stratosphere.romfs` are held open by the running Atmosphere and **cannot** be overwritten, renamed, or deleted while CFW is running — every in-session trick (plain write, stash-and-rename, libnx direct write) fails on them. The boot payload (`fusee.bin` / `reboot_payload.bin`) is the version-matched partner of `package3`; if the payload updates but `package3` doesn't, the console boots to a "incorrect fusee version" fatal. The only safe place to replace `package3` is **before HOS boots**, so AetherBlock finalizes the swap from a reboot payload:
121121

122-
1. During CFW extraction, it first tries a direct write, then a stash-and-rename, then a libnx direct-write path that bypasses stdio's share semantics. Most files land at that stage.
123-
2. For anything that still refuses to budge, the new content is staged as `<file>.ab_new` and queued in `/config/AetherBlock/pending.txt`.
124-
3. Right before the post-Daybreak reboot, AetherBlock tries to swap the staged files into place using a backup-rename-restore pattern so the real file is never in a missing state.
125-
4. On the next launch after reboot, any sidecars that are still around get one more attempt — including a byte-for-byte content check so redundant `.ab_new` files get cleaned up automatically.
122+
1. During CFW extraction the whole version-matched boot set — `package3`, `stratosphere.romfs`, `atmosphere/reboot_payload.bin`, and the root `fusee.bin` — is staged as `<file>.ab_new` next to the originals and **never written in place**, even the files that aren't locked. That's deliberate: it guarantees neither the reboot payload nor an RCM-injected `fusee.bin` can ever get ahead of `package3`. The old, matched set stays fully in place — the running CFW is never left inconsistent, and no reboot path (normal, RCM jig, or cold boot) can pair a new fusee with an old `package3`.
123+
2. AetherBlock writes `sd:/startup.te` (a TegraScript) and arms **TegraExplorer** as the next reboot payload via Atmosphere's `bpc:ams` extension (`amsBpcSetRebootPayload`). Arming is done dead-last, after every other service is torn down, because it requires `smExit()`.
124+
3. The single reboot — Daybreak's post-install reboot, or AetherBlock's own for a CFW-only update — lands in TegraExplorer instead of fusee. Before any menu is drawn, `startup.te` renames each `.ab_new` into place (now unlocked, because HOS hasn't booted) and chainloads the now-consistent CFW.
125+
4. If anything goes wrong — arming fails, the payload is missing, power is lost — the old set is still intact, so the console simply boots the **old** CFW and the `.ab_new` files wait for a retry. A new fusee never meets an old `package3`, so this path **cannot brick**.
126126

127-
Critically, libnx never flushes SD writes to the card on its own `fclose` only drops the data into the filesystem cache. After every extraction, after every staged swap, and one final time right before control passes to Daybreak's reboot, AetherBlock calls `fsdevCommitDevice("sdmc")` to force everything to physically persist. Without this, a large file like the 8 MB `package3` can read back stale after the reboot even though every write reported success — leaving a new `fusee` paired with an old `package3` and the dreaded "incorrect fusee version" boot error.
127+
libnx also never flushes SD writes on its own (`fclose` only fills the FS cache), so AetherBlock calls `fsdevCommitDevice("sdmc")` after extraction, after writing `startup.te`, and on every log line, to guarantee everything is physically on the card before the reboot.
128128

129-
The net effect: you never have to manually rename anything, stale sidecars don't accumulate on the SD card, and the boot files are guaranteed on-disk before the reboot.
129+
The bundled `romfs/TegraExplorer.bin` is the stock [TegraExplorer](https://github.com/suchmememanyskill/TegraExplorer) v4.2.0 release (GPL-2.0) by suchmememanyskill — used unmodified; it auto-runs `sd:/startup.te` on boot.
130130

131131
## Building
132132

include/config.h

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
#define CONFIG_H
33

44
#define APP_NAME "AetherBlock"
5-
#define APP_VERSION "2.0.11"
5+
#define APP_VERSION "2.1.0"
66
#define APP_AUTHOR "HeXbyt3"
77

88
#define HOSTS_MAX_ENTRIES 256
@@ -41,6 +41,12 @@
4141
#define AETHERBLOCK_CONFIG_DIR "/config/AetherBlock/"
4242
#define FW_CLEANUP_MARKER_PATH "/config/AetherBlock/fw_cleanup_pending"
4343

44+
/* pre-HOS swap: TegraExplorer payload + the script it auto-runs on boot.
45+
Used to finalize locked boot files (package3, stratosphere.romfs,
46+
reboot_payload.bin) that can't be replaced while Atmosphère is running. */
47+
#define SWAP_PAYLOAD_ROMFS "romfs:/TegraExplorer.bin"
48+
#define STARTUP_TE_PATH "/startup.te"
49+
4450
/* diagnostics + download integrity */
4551
#define APP_LOG_PATH "/config/AetherBlock/last_error.log"
4652
#define APP_LOG_MAX_SIZE 16384 /* rotate the log past this many bytes */

include/payload_swap.h

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#ifndef PAYLOAD_SWAP_H
2+
#define PAYLOAD_SWAP_H
3+
4+
#include <stdbool.h>
5+
6+
/*
7+
* Finalizes a staged CFW update that couldn't be applied in-session.
8+
*
9+
* package3 / stratosphere.romfs (and, to keep the boot set atomic,
10+
* reboot_payload.bin) are locked while Atmosphère runs and can only be
11+
* replaced before HOS boots. We stage them as <file>.ab_new, then reboot into
12+
* TegraExplorer, whose sd:/startup.te renames the sidecars into place and
13+
* chainloads the now-consistent CFW. The old set stays fully in place until
14+
* that swap runs, so a failure here can never brick -- it just boots the old
15+
* CFW and leaves the sidecars for a retry.
16+
*/
17+
18+
/* True if any boot-critical .ab_new sidecar is waiting to be swapped in. */
19+
bool swapPending(void);
20+
21+
/* Write sd:/startup.te and load the TegraExplorer payload into memory.
22+
Must be called while romfs and sdmc are still mounted. Returns 0 on
23+
success. After this, call swapArm() once services are being torn down. */
24+
int swapPrepare(void);
25+
26+
/* True once swapPrepare() has succeeded and an arm is owed. */
27+
bool swapIsPrepared(void);
28+
29+
/* Stage the loaded payload as the next reboot target via Atmosphère's bpc
30+
extension. MUST be called dead-last, after all other service cleanup,
31+
because it calls smExit(). If reboot_now is true it also triggers the
32+
reboot itself (CFW-only path); otherwise it just arms and returns so a
33+
following Daybreak reboot lands in the payload. Erista-only; returns
34+
negative and leaves the staged set untouched on Mariko or on failure. */
35+
int swapArm(bool reboot_now);
36+
37+
#endif /* PAYLOAD_SWAP_H */

lib/ams_bpc.c

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#include "ams_bpc.h"
2+
3+
/* bpc:ams is a named port exposed by Atmosphère's ams_mitm. We talk to it
4+
directly via the kernel (svcConnectToNamedPort) rather than through sm --
5+
which is why callers must smExit() before amsBpcInitialize(). Command 65001
6+
is SetRebootPayload; it copies the supplied buffer into the live reboot
7+
buffer and forces the next reboot to be a payload reboot. */
8+
9+
static Service g_amsBpcSrv;
10+
static u64 g_refCnt;
11+
12+
Result amsBpcInitialize(void) {
13+
if (g_refCnt > 0) {
14+
g_refCnt++;
15+
return 0;
16+
}
17+
18+
Handle h;
19+
Result rc = svcConnectToNamedPort(&h, "bpc:ams");
20+
if (R_SUCCEEDED(rc)) {
21+
serviceCreate(&g_amsBpcSrv, h);
22+
g_refCnt++;
23+
}
24+
return rc;
25+
}
26+
27+
void amsBpcExit(void) {
28+
if (g_refCnt == 0)
29+
return;
30+
if (--g_refCnt == 0)
31+
serviceClose(&g_amsBpcSrv);
32+
}
33+
34+
Result amsBpcSetRebootPayload(const void *src, size_t src_size) {
35+
return serviceDispatch(&g_amsBpcSrv, 65001,
36+
.buffer_attrs = { SfBufferAttr_In | SfBufferAttr_HipcMapAlias },
37+
.buffers = { { src, src_size } },
38+
);
39+
}

lib/ams_bpc.h

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Minimal interface to Atmosphère's bpc:ams extension (reboot-to-payload).
3+
*
4+
* Vendored from Atmosphère's troposphere/reboot_to_payload (ams_bpc.c/.h).
5+
* This is NOT part of libnx -- bpc:ams is an Atmosphère-only named port that
6+
* lets a running app stage an arbitrary fusee/RCM payload into the live
7+
* ams_mitm reboot buffer, so the next reboot lands in that payload instead
8+
* of the normal one. We use it to reboot into TegraExplorer, which finalizes
9+
* a staged CFW update (renaming package3 etc.) before HOS boots.
10+
*
11+
* Atmosphère is MIT/BSD-licensed; see Atmosphere-NX/Atmosphere.
12+
*/
13+
#ifndef AMS_BPC_H
14+
#define AMS_BPC_H
15+
16+
#include <switch.h>
17+
18+
#ifdef __cplusplus
19+
extern "C" {
20+
#endif
21+
22+
Result amsBpcInitialize(void);
23+
void amsBpcExit(void);
24+
25+
/* src_size must be <= 0x24000 (the ams_mitm reboot buffer size). */
26+
Result amsBpcSetRebootPayload(const void *src, size_t src_size);
27+
28+
#ifdef __cplusplus
29+
}
30+
#endif
31+
32+
#endif /* AMS_BPC_H */

romfs/TegraExplorer.bin

122 KB
Binary file not shown.

source/cfw_mgr.c

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#include "download.h"
33
#include "extract.h"
44
#include "pending.h"
5+
#include "payload_swap.h"
56
#include "cfw_detect.h"
67
#include "config.h"
78
#include "applog.h"
@@ -204,16 +205,19 @@ void cfwMgrStartDownload(CfwPackageManager *cm) {
204205
}
205206

206207
int cfwMgrReboot(bool is_mariko) {
207-
/* swap any files that were staged as .ab_new during extraction
208-
(package3, stratosphere.romfs, AetherBlock.nro, etc.) — do this
209-
right before the reboot call so nothing else has a chance to
210-
reopen them first */
208+
/* If a CFW update left boot files staged (package3 etc. can't be swapped
209+
while Atmosphère runs), reboot into the swap payload instead of a normal
210+
reboot: it renames the .ab_new files in place pre-HOS and chainloads the
211+
now-consistent CFW. swapArm(true) calls smExit() and reboots, so it does
212+
not return on success. On Mariko or any failure it returns and we fall
213+
through to a normal reboot -- the old, matched set is still in place, so
214+
that just boots the old CFW (no brick). */
215+
if (!is_mariko && swapPending() && swapPrepare() == 0)
216+
swapArm(true);
217+
218+
/* swap any ordinary (non-boot) files staged as .ab_new, then flush --
219+
fsdev doesn't commit on its own. */
211220
pendingApply();
212-
213-
/* fsdev doesn't flush on its own -- make sure every staged swap and
214-
freshly written boot file is actually on the card before we pull the
215-
trigger, otherwise a stale package3 boots and fails the fusee version
216-
check. */
217221
fsdevCommitDevice("sdmc");
218222

219223
Result rc;

source/extract.c

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,20 @@ static bool is_boot_critical(const char *name) {
2020
|| strstr(name, "fusee") || strstr(name, "reboot_payload");
2121
}
2222

23+
/* The version-matched boot set (package3, stratosphere.romfs,
24+
reboot_payload.bin, root fusee.bin) is ALWAYS staged as .ab_new and never
25+
written in place -- even the ones that aren't locked. They only flip
26+
together in the pre-HOS swap payload (startup.te). This is what makes a
27+
failed update non-bricking: until the swap runs, the whole old set is intact
28+
and consistent, so no reboot path -- normal reboot, RCM-injected fusee, or
29+
cold boot -- can ever pair a new boot payload with an old package3. */
30+
static bool is_force_stage(const char *name) {
31+
return strstr(name, "package3") != NULL
32+
|| strstr(name, "stratosphere.romfs") != NULL
33+
|| strstr(name, "reboot_payload.bin") != NULL
34+
|| strstr(name, "fusee.bin") != NULL;
35+
}
36+
2337
static bool has_path_traversal(const char *name) {
2438
if (strcmp(name, "..") == 0) return true;
2539
if (strncmp(name, "../", 3) == 0) return true;
@@ -183,8 +197,11 @@ int extractZip(const char *zip_path, const char *dest_path,
183197
bool stashed = false;
184198
bool staged = false;
185199
bool wrote_via_libnx = false;
186-
FILE *fp = fopen(full_path, "wb");
187-
if (!fp) {
200+
/* force-staged boot files skip every in-place path and go straight
201+
to a .ab_new sidecar, leaving the live file untouched */
202+
bool force = stage_locked_files && is_force_stage(filename);
203+
FILE *fp = force ? NULL : fopen(full_path, "wb");
204+
if (!fp && !force) {
188205
/* probably a running sysmodule holding the file open;
189206
shove the old one aside and try again */
190207
snprintf(stash_path, sizeof(stash_path), "%s.ab_old", full_path);
@@ -194,7 +211,7 @@ int extractZip(const char *zip_path, const char *dest_path,
194211
fp = fopen(full_path, "wb");
195212
}
196213
}
197-
if (!fp && stage_locked_files) {
214+
if (!fp && !force && stage_locked_files) {
198215
/* stdio can't touch this file. before we give up and
199216
write a sidecar, try the libnx direct path — it uses
200217
different open flags and sometimes wins where fopen
@@ -259,11 +276,16 @@ int extractZip(const char *zip_path, const char *dest_path,
259276
if (is_boot_critical(filename))
260277
appLog(" [boot] %s: write FAILED mid-stream", filename);
261278
} else if (staged) {
262-
/* queued for later; don't count as an error */
263-
pendingAdd(full_path);
279+
/* Locked files go on the pending list so a later in-session
280+
pass can retry them. Force-staged files (reboot_payload.bin)
281+
deliberately stay OFF the list -- they must only ever be
282+
swapped by the pre-HOS payload, never in-session, or the
283+
live boot payload would race ahead of package3. */
284+
if (!force)
285+
pendingAdd(full_path);
264286
if (is_boot_critical(filename))
265-
appLog(" [boot] %s: LOCKED -> staged as .ab_new, queued for swap",
266-
filename);
287+
appLog(" [boot] %s: %s -> staged as .ab_new, queued for swap",
288+
filename, force ? "force" : "LOCKED");
267289
} else if (stashed) {
268290
remove(stash_path);
269291
if (is_boot_critical(filename))

source/firmware_mgr.c

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#include "download.h"
33
#include "extract.h"
44
#include "pending.h"
5+
#include "payload_swap.h"
56
#include "config.h"
67
#include "applog.h"
78
#include <cJSON.h>
@@ -213,31 +214,34 @@ int fwMgrLaunchDaybreak(void) {
213214
if (stat(DAYBREAK_PATH, &st) != 0)
214215
return -1;
215216

216-
/* swap in any CFW files that were staged as .ab_new during extraction
217-
(package3, stratosphere.romfs, ...) BEFORE we hand off. Daybreak
218-
reboots the console itself via reboot-to-payload and never returns
219-
control to us, so this is our last chance to get package3 in sync
220-
with the new fusee/reboot_payload — skip it and the boot payload
221-
loads a stale package3 ("fusee is not on the latest package"). */
222217
appLogSection("DAYBREAK HANDOFF");
223-
appLog("applying any staged CFW swaps before reboot...");
224-
pendingApply();
225-
if (pendingHasEntries())
226-
appLog("WARNING: staged CFW files still pending after swap -- "
227-
"package3 is likely STALE; reboot will mismatch fusee");
228-
else
229-
appLog("no staged CFW files remain pending");
230218

231-
/* Final flush before we lose control to Daybreak's reboot. fsdev never
232-
commits on its own; an uncommitted 8 MB package3 reads stale after the
233-
reboot even though every write "succeeded". This is the single most
234-
important commit in the whole update flow. */
219+
/* package3 / stratosphere.romfs are locked while Atmosphère runs and can't
220+
be swapped in-session -- proven by the logs. If any boot file is staged,
221+
prepare the pre-HOS swap: write sd:/startup.te and load TegraExplorer.
222+
The actual arm (smExit + bpc) happens dead-last in main(), so Daybreak's
223+
post-install reboot lands in TegraExplorer, which renames the .ab_new
224+
files into place and chainloads the now-consistent CFW.
225+
226+
We deliberately do NOT pendingApply() here: the locked files can't swap
227+
anyway, and force-staged reboot_payload.bin must stay old until the
228+
payload flips the whole set at once (that's what prevents a brick). */
229+
if (swapPending()) {
230+
appLog("staged CFW boot files present -- arming pre-HOS swap payload");
231+
if (swapPrepare() != 0)
232+
appLog("WARNING: could not arm swap payload; CFW will stay on the "
233+
"old version (no brick) -- finish via PC if needed");
234+
} else {
235+
/* nothing locked this time -- apply any ordinary staged swaps */
236+
pendingApply();
237+
appLog("no staged boot files; normal handoff");
238+
}
239+
240+
/* flush everything (startup.te, sidecars, pending list) before we lose
241+
control -- fsdev never commits on its own. */
235242
Result crc = fsdevCommitDevice("sdmc");
236243
if (R_FAILED(crc))
237-
appLog("WARNING: sdmc commit before Daybreak failed (rc=0x%X) -- "
238-
"package3 may not have persisted", crc);
239-
else
240-
appLog("sdmc committed; handing off to Daybreak");
244+
appLog("WARNING: sdmc commit before Daybreak failed (rc=0x%X)", crc);
241245

242246
char args[256];
243247
snprintf(args, sizeof(args), "\"%s\" \"%s\"", DAYBREAK_PATH, FIRMWARE_EXTRACT_PATH);

0 commit comments

Comments
 (0)