Integrate demand-first PSOs, far-base tracing and NRO loading

Integrate PRs #75, #90 and #91 with focused correctness fixes:
- Prioritize demanded render pipelines and bound speculative compilation.
- Follow copied jump-table bases and reject W/X register clobbers.
- Collect and rewrite registered NROs, load them on the Mac, and preserve
  runtime activation order through unload/reload and symbol rebinding.
- Preserve refused-only manifests and invalidate collection caches when
  archive readers or symbol parsing change.

Add pipeline scheduling, register tracing, NRO packaging and loader
lifecycle regression coverage. Document that executable NRO embedding
on iOS remains outside this integration.

Validation: 41 Python tests; 48 pipeline scheduling, 102 NVN and 95
geometry checks; three pipeline-key serialization cases; full host test
suite; Mac host build; iOS loader syntax checks; git diff --check.
Gameplay and controlled performance A/B measurements were not run.

Co-authored-by: blindlight86 <25404088+blindlight86@users.noreply.github.com>
Co-authored-by: v0lumehi <aljic.zijad@gmail.com>
This commit is contained in:
committed 2026-10-24 11:38:12 +08:00
1 parent f596c75012
commit d979c346b9
19 files changed
+1160 -89

No files matched your search

+26 -13
View File
@@ -4145,27 +4145,35 @@ static void pso_key_fill(pso_key *k, NvShader *vs, NvShader *fs, const uint32_t
for (int v = 0; v < rf->nv; v++) { int a = rf->v[v].nvn; if (a < 16 && a < S.nattr) { k->attr[a] = S.attr[a]; int st = S.attr[a].stream; if (st < 16) k->stream[st] = S.stream[st]; } }
}
// ---- Parallel precompile: pipelines met for the first time used to be compiled synchronously one by one on the submit thread; 9 misses in a row in one frame = 284 ms (heavy scene on a real device).
// Now: on the first pipeline miss in a submission, scan ahead once along the remaining commands (replaying state only, no drawing) and kick all the normal-pass pipelines that will also miss later to the background to compile in parallel;
// After satisfying the first pipeline miss in a submission, scan ahead once along the remaining commands (replaying state only, no drawing) and kick a bounded number of normal-pass pipelines to the background;
// when the submit thread reaches that draw it only waits for 'the remaining time not yet compiled'. The compiled pipelines are identical to synchronously compiled ones (same descriptor). Pipeline keys of merged passes / specialized fragments depend on the planning result at replay time, are not predicted, and are compiled synchronously as before.
#define PSO_PEND_MAX 384
static struct { pso_key k; id<MTLRenderPipelineState> ps; int used, done; } g_pp[PSO_PEND_MAX];
enum { PSO_PEND_ACTIVE = 2, PSO_PREDICT_BUDGET = 2 };
static struct { pso_key k; id<MTLRenderPipelineState> ps; int used, done; uint64_t order; } g_pp[PSO_PEND_MAX];
static pthread_mutex_t g_pp_mu = PTHREAD_MUTEX_INITIALIZER; static pthread_cond_t g_pp_cv = PTHREAD_COND_INITIALIZER;
static uint64_t g_pp_submit_mark, g_pp_kicked, g_pp_taken; // Whether this submission has already been predicted (by submission serial); cumulative kicked / taken counts
static void pso_predict_rest(void);
static int pso_pend_find(const pso_key *k) { for (int i = 0; i < PSO_PEND_MAX; i++) if (g_pp[i].used && !memcmp(&g_pp[i].k, k, sizeof *k)) return i; return -1; }
static void pso_pend_kick(const pso_key *k, NvShader *vs, NvShader *fs) { // Called by the submit thread: kick only if it is neither in the cache nor in the table
if (hm_get_id(&g_pso, k)) return;
static int pso_pend_kick(const pso_key *k, NvShader *vs, NvShader *fs) { // Nonblocking admission: no speculative backlog in front of a demanded compile
if (hm_get_id(&g_pso, k)) return 0;
pthread_mutex_lock(&g_pp_mu);
int at = pso_pend_find(k), slot = -1;
if (at < 0) for (int i = 0; i < PSO_PEND_MAX; i++) if (!g_pp[i].used) { slot = i; break; }
if (at < 0 && slot >= 0) { g_pp[slot].k = *k; g_pp[slot].ps = nil; g_pp[slot].done = 0; g_pp[slot].used = 1; }
int at = pso_pend_find(k), slot = -1, reclaim = -1, active = 0;
for (int i = 0; i < PSO_PEND_MAX; i++) {
if (!g_pp[i].used) { if (slot < 0) slot = i; }
else if (!g_pp[i].done) active++;
else if (reclaim < 0 || g_pp[i].order < g_pp[reclaim].order) reclaim = i;
}
if (active >= PSO_PEND_ACTIVE) slot = -1;
else if (slot < 0) slot = reclaim; // Only completed, unused predictions may be discarded; their workers no longer touch the slot
if (at < 0 && slot >= 0) { g_pp[slot].k = *k; g_pp[slot].ps = nil; g_pp[slot].done = 0; g_pp[slot].used = 1; g_pp[slot].order = ++g_pp_kicked; }
pthread_mutex_unlock(&g_pp_mu);
if (at >= 0 || slot < 0) return;
MTLRenderPipelineDescriptor *d = pso_desc(k, vs, fs, nil, 0); g_pp_kicked++;
if (at >= 0 || slot < 0) return 0;
MTLRenderPipelineDescriptor *d = pso_desc(k, vs, fs, nil, 0);
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
NSError *e = nil; id<MTLRenderPipelineState> ps = [g_dev newRenderPipelineStateWithDescriptor:d error:&e];
pthread_mutex_lock(&g_pp_mu); g_pp[slot].ps = ps; g_pp[slot].done = 1; pthread_cond_broadcast(&g_pp_cv); pthread_mutex_unlock(&g_pp_mu);
});
return 1;
}
static id<MTLRenderPipelineState> pso_pend_take(const pso_key *k) { // Called by the submit thread: if it is in the table, wait for it to finish compiling and take it; otherwise return nil
pthread_mutex_lock(&g_pp_mu);
@@ -4204,7 +4212,6 @@ static __unsafe_unretained id<MTLRenderPipelineState> pso_get_(NvProg *pr, MTLRe
ps = g_pso_lmap || ms_fn ? nil : pso_pend_take(&k);
if (ps) g_pp_taken++;
else {
if (g_pp_submit_mark != g_sub_serial) { g_pp_submit_mark = g_sub_serial; pso_predict_rest(); } // First miss in this submission: first kick the later ones out to compile in parallel, then synchronously compile the one at hand
MTLRenderPipelineDescriptor *d = pso_desc(&k, vs, fs, ms_fn ? ms_fn : g_pso_fsfn, g_pso_lmap != NULL);
ps = [g_dev newRenderPipelineStateWithDescriptor:d error:&e];
// A vertex shader that writes the layer (render_target_array_index) needs the pipeline's input primitive class, which the reflection does not record and the pipeline key does not hold.
@@ -4222,6 +4229,11 @@ static __unsafe_unretained id<MTLRenderPipelineState> pso_get_(NvProg *pr, MTLRe
pso_record("R\t%s\n", shex);
pso_user_record("R\t%s\n", shex); }
hm_put_id(&g_pso, &k, ps);
// Metal may serialize requests internally. Submit the pipeline needed now before speculative work, and preserve record-on-use above.
if (g_pp_submit_mark != g_sub_serial) {
g_pp_submit_mark = g_sub_serial; uint64_t scan_t0 = bp_now(); pso_predict_rest();
g_bp_pso_ns += bp_now() - scan_t0; // Keep lookahead work in the pipeline-miss timing, as before
}
return ps;
}
static NSString *warm_key(const char *s, size_t n) {
@@ -4981,7 +4993,7 @@ static void pso_predict_rest(void) {
int pso_dirty = g_pso_dirty, dss_dirty = g_dss_dirty;
uint32_t cf[8]; memcpy(cf, g_pass.cfmt, sizeof cf); uint32_t df = g_pass.dfmt, sf = g_pass.sfmt, samples = g_pass.samples, dfull = df, sfull = sf; int kicked0 = (int)g_pp_kicked, draws = 0;
fz_cursor cur = {.p = g_exec_next};
// Collect them all first, then dispatch: keys for "attachments attached as-is" go first (most likely to be used), the other attach modes after -- background concurrency is limited, and what is dispatched first is compiled first
// Collect likely attachment keys before alternatives, then admit only a small batch. Global dispatch and Metal do not promise compile order.
enum { QN = 160 }; static struct { pso_key k; void *vs, *fs; } qa[QN], qb[QN]; int na = 0, nb = 0;
for (int guard = 0; guard < 200000 && na < QN; guard++) {
const uint8_t *q; op_hdr hq; const void *aq;
@@ -5016,8 +5028,9 @@ static void pso_predict_rest(void) {
state_op(hq.op, aq);
}
(void)df; (void)sf;
for (int i = 0; i < na; i++) pso_pend_kick(&qa[i].k, (__bridge NvShader *)qa[i].vs, (__bridge NvShader *)qa[i].fs);
for (int i = 0; i < nb; i++) pso_pend_kick(&qb[i].k, (__bridge NvShader *)qb[i].vs, (__bridge NvShader *)qb[i].fs);
int admitted = 0;
for (int i = 0; i < na && admitted < PSO_PREDICT_BUDGET; i++) admitted += pso_pend_kick(&qa[i].k, (__bridge NvShader *)qa[i].vs, (__bridge NvShader *)qa[i].fs);
for (int i = 0; i < nb && admitted < PSO_PREDICT_BUDGET; i++) admitted += pso_pend_kick(&qb[i].k, (__bridge NvShader *)qb[i].vs, (__bridge NvShader *)qb[i].fs);
memcpy(&S, &Ssave, sizeof S); args_dirty_all(); g_pso_dirty = pso_dirty; g_dss_dirty = dss_dirty; prog_resolve();
static int logged; if (g_pp_kicked > (uint64_t)kicked0 && logged++ < 20) MLOG("parallel precompile: %d draws left in this submission, %d pipelines handed to the background", draws, (int)g_pp_kicked - kicked0);
}
+2
View File
@@ -62,3 +62,5 @@ t_readback
t_readback.dSYM/
t_linear_readback
t_linear_readback.dSYM/
t_pso_pending
t_pso_pending.dSYM/
+4 -1
View File
@@ -77,6 +77,8 @@ t_rt_range: t_rt_range.m $(MTL_T_DEP)
clang $(CFLAGS) -fobjc-arc -o $@ t_rt_range.m $(MTL_T_SRC) $(FW)
t_rt_depth_ro: t_rt_depth_ro.m $(MTL_T_DEP)
clang $(CFLAGS) -fobjc-arc -o $@ t_rt_depth_ro.m $(MTL_T_SRC) $(FW)
t_pso_pending: t_pso_pending.m $(MTL_T_DEP)
clang $(CFLAGS) -fobjc-arc -o $@ t_pso_pending.m $(MTL_T_SRC) $(FW)
t_pso_skey: t_pso_skey.m ../nvn/nvn_pso_skey.h ../nvn/nvn_obj.h
clang $(CFLAGS) -fobjc-arc -o $@ t_pso_skey.m -framework Foundation
t_view_swizzle: t_view_swizzle.m $(MTL_T_DEP)
@@ -94,7 +96,7 @@ t_pfv: t_pfv.m $(MTL_T_DEP)
# The Vulkan layer (#included) over the whole NVN layer, whose device and present path it uses
t_vk_desc: t_vk_desc.m ../vk/hle_vk.m $(MTL_T_DEP)
clang $(CFLAGS) -I../../third_party/vulkan-headers/include -fobjc-arc -o $@ t_vk_desc.m $(MTL_T_SRC) ../nvn/nvn_mtl.m $(FW)
run: t_nvn t_dirty_slots t_dirty_race t_builtin t_vin t_tbuf2d t_deswz_gpu t_pace t_fstep t_refl t_hmap t_cover t_fuse_check t_present_crop t_reorder_chain t_defer_chain t_zskip t_xv t_ub t_vbuf t_geometry t_mip_extent t_rt_range_shape t_rt_range_ro t_rt_range t_rt_depth_ro t_pso_skey t_pfv t_view_levels t_view_swizzle t_vk_desc
run: t_nvn t_dirty_slots t_dirty_race t_builtin t_vin t_tbuf2d t_deswz_gpu t_pace t_fstep t_refl t_hmap t_cover t_fuse_check t_present_crop t_reorder_chain t_defer_chain t_zskip t_xv t_ub t_vbuf t_geometry t_mip_extent t_rt_range_shape t_rt_range_ro t_rt_range t_rt_depth_ro t_pso_skey t_pso_pending t_pfv t_view_levels t_view_swizzle t_vk_desc
./t_nvn
./t_dirty_slots
./t_dirty_race
@@ -128,6 +130,7 @@ run: t_nvn t_dirty_slots t_dirty_race t_builtin t_vin t_tbuf2d t_deswz_gpu t_pac
DM_NVN_UNWRITTEN_DC=0 MTL_DEBUG_LAYER=1 ./t_rt_range --small-depth
DM_NVN_UNWRITTEN_DC=0 MTL_DEBUG_LAYER=1 ./t_rt_range --sample-attachment --both-flags
./t_pso_skey
./t_pso_pending
MTL_DEBUG_LAYER=1 ./t_pfv
MTL_DEBUG_LAYER=1 ./t_view_levels
MTL_DEBUG_LAYER=1 ./t_view_swizzle
+351
View File
@@ -0,0 +1,351 @@
#include "../nvn/nvn_mtl.m"
#include <errno.h>
#include <pthread.h>
#include <time.h>
void *front_metal_layer(void) { return NULL; }
module_t *module_of(const void *p) { (void)p; return NULL; }
module_t g_mod[MAX_MOD]; int g_nmod;
void svc_thread_dump(void) {}
uint64_t now_ns(void) { return bp_now(); }
static int g_checks, g_failed;
#define EXPECT_EQ(name, want, got) do { \
long long w_ = (long long)(want), g_ = (long long)(got); g_checks++; \
printf("%s expected %lld -> got %lld %s\n", name, w_, g_, w_ == g_ ? "OK" : "FAIL"); \
g_failed += w_ != g_; \
} while (0)
#define EXPECT_TRUE(name, condition) EXPECT_EQ(name, 1, !!(condition))
@interface TestPipelineState : NSObject { @public int call; } @end
@implementation TestPipelineState @end
// The fake compiler blocks selected invocation numbers, so the test can hold
// real pso_pend_kick workers in the compiler without relying on driver timing.
@interface GateDevice : NSObject {
pthread_mutex_t _mu;
pthread_cond_t _cv;
int _entered, _released, _block_through, _nil_call;
int _sync_calls, _async_calls;
pthread_t _submitter;
NSMutableArray *_results;
BOOL _submitter_call[1024];
int _check_absent_call;
pso_key _absent_key;
BOOL _absent_key_observed[1024];
}
- (instancetype)initBlockingThrough:(int)call nilCall:(int)nilCall;
- (id<MTLRenderPipelineState>)newRenderPipelineStateWithDescriptor:(MTLRenderPipelineDescriptor *)descriptor error:(NSError **)error;
- (BOOL)waitForEntered:(int)count timeoutMs:(int)timeoutMs;
- (void)releaseThrough:(int)count;
- (void)releaseAll;
- (int)entered;
- (int)syncCalls;
- (int)asyncCalls;
- (BOOL)callWasOnSubmitter:(int)call;
- (void)expectAbsentPendingKey:(const pso_key *)key onSubmitterCall:(int)call;
- (BOOL)submitterCallObservedKeyAbsent:(int)call;
- (TestPipelineState *)resultForCall:(int)call;
@end
@implementation GateDevice
- (instancetype)initBlockingThrough:(int)call nilCall:(int)nilCall {
if ((self = [super init])) {
pthread_mutex_init(&_mu, NULL); pthread_cond_init(&_cv, NULL);
_block_through = call; _nil_call = nilCall; _submitter = pthread_self();
_results = [NSMutableArray array];
}
return self;
}
- (void)dealloc { pthread_cond_destroy(&_cv); pthread_mutex_destroy(&_mu); }
- (id<MTLRenderPipelineState>)newRenderPipelineStateWithDescriptor:(MTLRenderPipelineDescriptor *)descriptor error:(NSError **)error {
(void)descriptor;
pthread_mutex_lock(&_mu);
int n = ++_entered;
TestPipelineState *result = [TestPipelineState new]; result->call = n;
[_results addObject:result];
BOOL on_submitter = pthread_equal(pthread_self(), _submitter);
_submitter_call[n] = on_submitter;
BOOL check_absent = on_submitter && n == _check_absent_call;
pso_key absent_key = _absent_key;
if (on_submitter) _sync_calls++; else _async_calls++;
pthread_cond_broadcast(&_cv);
while (n <= _block_through && _released < n) pthread_cond_wait(&_cv, &_mu);
BOOL fail = n == _nil_call;
pthread_mutex_unlock(&_mu);
if (check_absent) {
pthread_mutex_lock(&g_pp_mu); BOOL absent = pso_pend_find(&absent_key) < 0; pthread_mutex_unlock(&g_pp_mu);
pthread_mutex_lock(&_mu); _absent_key_observed[n] = absent; pthread_mutex_unlock(&_mu);
}
if (error) *error = nil;
return fail ? nil : (id<MTLRenderPipelineState>)result;
}
- (BOOL)waitForEntered:(int)count timeoutMs:(int)timeoutMs {
struct timespec end; clock_gettime(CLOCK_REALTIME, &end);
end.tv_sec += timeoutMs / 1000; end.tv_nsec += (long)(timeoutMs % 1000) * 1000000L;
if (end.tv_nsec >= 1000000000L) { end.tv_sec++; end.tv_nsec -= 1000000000L; }
pthread_mutex_lock(&_mu);
int rc = 0;
while (_entered < count && rc != ETIMEDOUT) rc = pthread_cond_timedwait(&_cv, &_mu, &end);
BOOL ok = _entered >= count;
pthread_mutex_unlock(&_mu);
return ok;
}
- (void)releaseThrough:(int)count {
pthread_mutex_lock(&_mu); if (count > _released) _released = count; pthread_cond_broadcast(&_cv); pthread_mutex_unlock(&_mu);
}
- (void)releaseAll { [self releaseThrough:INT_MAX]; }
- (int)entered { pthread_mutex_lock(&_mu); int n = _entered; pthread_mutex_unlock(&_mu); return n; }
- (int)syncCalls { pthread_mutex_lock(&_mu); int n = _sync_calls; pthread_mutex_unlock(&_mu); return n; }
- (int)asyncCalls { pthread_mutex_lock(&_mu); int n = _async_calls; pthread_mutex_unlock(&_mu); return n; }
- (BOOL)callWasOnSubmitter:(int)call { pthread_mutex_lock(&_mu); BOOL b = call > 0 && call < 1024 && _submitter_call[call]; pthread_mutex_unlock(&_mu); return b; }
- (void)expectAbsentPendingKey:(const pso_key *)key onSubmitterCall:(int)call {
pthread_mutex_lock(&_mu); if (key) _absent_key = *key; _check_absent_call = call; pthread_mutex_unlock(&_mu);
}
- (BOOL)submitterCallObservedKeyAbsent:(int)call {
pthread_mutex_lock(&_mu); BOOL b = call > 0 && call < 1024 && _absent_key_observed[call]; pthread_mutex_unlock(&_mu); return b;
}
- (TestPipelineState *)resultForCall:(int)call {
pthread_mutex_lock(&_mu); TestPipelineState *o = call > 0 && call <= (int)_results.count ? _results[(NSUInteger)call - 1] : nil; pthread_mutex_unlock(&_mu); return o;
}
@end
static struct timespec deadline_after_ms(int timeout_ms) {
struct timespec end; clock_gettime(CLOCK_REALTIME, &end);
end.tv_sec += timeout_ms / 1000; end.tv_nsec += (long)(timeout_ms % 1000) * 1000000L;
if (end.tv_nsec >= 1000000000L) { end.tv_sec++; end.tv_nsec -= 1000000000L; }
return end;
}
static int pending_done(const pso_key *k, int timeout_ms) {
struct timespec end = deadline_after_ms(timeout_ms);
pthread_mutex_lock(&g_pp_mu);
int at = pso_pend_find(k), rc = 0;
while (at >= 0 && !g_pp[at].done && rc != ETIMEDOUT) {
rc = pthread_cond_timedwait(&g_pp_cv, &g_pp_mu, &end);
at = pso_pend_find(k);
}
int done = at >= 0 && g_pp[at].done;
pthread_mutex_unlock(&g_pp_mu);
return done;
}
static int queue_index(const pso_key *k) {
pthread_mutex_lock(&g_pp_mu); int at = pso_pend_find(k); pthread_mutex_unlock(&g_pp_mu); return at;
}
static int queue_used_count(void) {
int n = 0; pthread_mutex_lock(&g_pp_mu);
for (int i = 0; i < PSO_PEND_MAX; i++) n += g_pp[i].used != 0;
pthread_mutex_unlock(&g_pp_mu); return n;
}
static int queue_active_count(void) {
int n = 0; pthread_mutex_lock(&g_pp_mu);
for (int i = 0; i < PSO_PEND_MAX; i++) n += g_pp[i].used && !g_pp[i].done;
pthread_mutex_unlock(&g_pp_mu); return n;
}
static int queue_done_at(int at) {
pthread_mutex_lock(&g_pp_mu); int done = at >= 0 && at < PSO_PEND_MAX ? g_pp[at].done : -1; pthread_mutex_unlock(&g_pp_mu); return done;
}
static int drain_queue(int timeout_ms) {
struct timespec end = deadline_after_ms(timeout_ms);
pthread_mutex_lock(&g_pp_mu);
int active = 1, rc = 0;
while (active && rc != ETIMEDOUT) {
active = 0;
for (int i = 0; i < PSO_PEND_MAX; i++) active |= g_pp[i].used && !g_pp[i].done;
if (active) rc = pthread_cond_timedwait(&g_pp_cv, &g_pp_mu, &end);
}
pthread_mutex_unlock(&g_pp_mu);
return !active;
}
static void reset_queue(void) {
pthread_mutex_lock(&g_pp_mu);
for (int i = 0; i < PSO_PEND_MAX; i++) {
g_pp[i].ps = nil; memset(&g_pp[i].k, 0, sizeof g_pp[i].k);
g_pp[i].used = g_pp[i].done = 0; g_pp[i].order = 0;
}
g_pp_kicked = g_pp_taken = 0;
pthread_mutex_unlock(&g_pp_mu);
}
static void release_cf_value(void *p) { if (p) CFRelease(p); }
static void reset_pso_cache(void) {
nvn_hm_clear(&g_pso, release_cf_value);
nvn_hm_clear(&g_pso_fail, release_cf_value);
}
static NvShader *test_vs, *test_fs;
static uint64_t g_key_vs[1024], g_key_fs[1024];
static pso_key make_key(unsigned tag) {
pso_key k = {0};
g_key_vs[tag] = tag * 3 + 1; g_key_fs[tag] = tag * 3 + 2;
k.vs = &g_key_vs[tag]; k.fs = &g_key_fs[tag];
k.cfmt[0] = MTLPixelFormatRGBA8Unorm; k.samples = 1; k.chmask = 0xffffffffu;
return k;
}
typedef struct { pso_key k; NvShader *vs, *fs; pthread_mutex_t mu; pthread_cond_t cv; int done, result; } kick_job;
static void *kick_job_main(void *arg) {
kick_job *j = arg; int result = pso_pend_kick(&j->k, j->vs, j->fs);
pthread_mutex_lock(&j->mu); j->result = result; j->done = 1; pthread_cond_broadcast(&j->cv); pthread_mutex_unlock(&j->mu);
return NULL;
}
static int wait_kick_job(kick_job *j, int timeout_ms) {
struct timespec end = deadline_after_ms(timeout_ms);
pthread_mutex_lock(&j->mu); int rc = 0;
while (!j->done && rc != ETIMEDOUT) rc = pthread_cond_timedwait(&j->cv, &j->mu, &end);
int done = j->done; pthread_mutex_unlock(&j->mu); return done;
}
static void test_two_active_exact_take_and_release(void) {
reset_queue();
GateDevice *dev = [[GateDevice alloc] initBlockingThrough:2 nilCall:0]; g_dev = (id<MTLDevice>)(id)dev;
pso_key a = make_key(1), b = make_key(2), c = make_key(3), wrong_attachment = a;
wrong_attachment.cfmt[0] = MTLPixelFormatBGRA8Unorm;
EXPECT_EQ("first prediction admitted", 1, pso_pend_kick(&a, test_vs, test_fs));
if (![dev waitForEntered:1 timeoutMs:5000]) { EXPECT_TRUE("first worker entered fake compiler", 0); [dev releaseAll]; drain_queue(5000); reset_queue(); return; }
EXPECT_EQ("second prediction admitted", 1, pso_pend_kick(&b, test_vs, test_fs));
if (![dev waitForEntered:2 timeoutMs:5000]) { EXPECT_TRUE("second worker entered fake compiler", 0); [dev releaseAll]; drain_queue(5000); reset_queue(); return; }
EXPECT_EQ("two compilation slots are outstanding", 2, queue_active_count());
kick_job job = {.k = c, .vs = test_vs, .fs = test_fs}; pthread_mutex_init(&job.mu, NULL); pthread_cond_init(&job.cv, NULL);
pthread_t thread; int thread_error = pthread_create(&thread, NULL, kick_job_main, &job);
EXPECT_EQ("start bounded third-kick check", 0, thread_error);
if (thread_error) { [dev releaseAll]; drain_queue(5000); reset_queue(); pthread_cond_destroy(&job.cv); pthread_mutex_destroy(&job.mu); return; }
int returned_without_wait = wait_kick_job(&job, 1000);
EXPECT_TRUE("third kick returns while both compiler gates remain closed", returned_without_wait);
if (!returned_without_wait) [dev releaseAll];
pthread_join(thread, NULL);
EXPECT_EQ("third prediction is rejected at active limit", 0, job.result);
pthread_cond_destroy(&job.cv); pthread_mutex_destroy(&job.mu);
if (!returned_without_wait) { drain_queue(5000); reset_queue(); return; }
EXPECT_EQ("rejected third prediction never reaches compiler", 2, [dev entered]);
EXPECT_EQ("rejected third prediction does not consume an admission order", 2, g_pp_kicked);
EXPECT_TRUE("different attachment key does not match pending key", pso_pend_take(&wrong_attachment) == nil);
EXPECT_TRUE("wrong-key take leaves original pending", queue_index(&a) >= 0);
[dev releaseThrough:1];
EXPECT_TRUE("first worker completes", pending_done(&a, 5000));
TestPipelineState *expected_a = [dev resultForCall:1];
id<MTLRenderPipelineState> taken_a = pso_pend_take(&a);
EXPECT_TRUE("exact-key take returns first compilation", taken_a != nil && (__bridge void *)taken_a == (__bridge void *)expected_a);
EXPECT_EQ("taking completed result releases its slot", -1, queue_index(&a));
EXPECT_EQ("other worker remains active in its slot", 1, queue_active_count());
[dev releaseThrough:2];
EXPECT_TRUE("second worker completes", pending_done(&b, 5000));
TestPipelineState *expected_b = [dev resultForCall:2];
id<MTLRenderPipelineState> taken_b = pso_pend_take(&b);
EXPECT_TRUE("second exact-key take returns its own result", taken_b != nil && (__bridge void *)taken_b == (__bridge void *)expected_b);
EXPECT_EQ("both take operations leave no active workers", 0, queue_active_count());
reset_queue();
}
static void test_full_table_reclaims_done_not_running(void) {
reset_queue();
GateDevice *dev = [[GateDevice alloc] initBlockingThrough:2 nilCall:0]; g_dev = (id<MTLDevice>)(id)dev;
pso_key oldest = make_key(4), running = make_key(5);
EXPECT_EQ("oldest slot admitted", 1, pso_pend_kick(&oldest, test_vs, test_fs));
if (![dev waitForEntered:1 timeoutMs:5000]) { EXPECT_TRUE("oldest worker entered fake compiler", 0); [dev releaseAll]; drain_queue(5000); reset_queue(); return; }
EXPECT_EQ("running slot admitted", 1, pso_pend_kick(&running, test_vs, test_fs));
if (![dev waitForEntered:2 timeoutMs:5000]) { EXPECT_TRUE("running worker entered fake compiler", 0); [dev releaseAll]; drain_queue(5000); reset_queue(); return; }
int oldest_slot = queue_index(&oldest), running_slot = queue_index(&running);
[dev releaseThrough:1];
EXPECT_TRUE("oldest prediction becomes completed but remains in table", pending_done(&oldest, 5000));
int filled = 0;
for (int i = 0; i < PSO_PEND_MAX - 2; i++) {
pso_key k = make_key((unsigned)(10 + i));
if (pso_pend_kick(&k, test_vs, test_fs) != 1 || !pending_done(&k, 5000)) break;
filled++;
}
EXPECT_EQ("completed entries fill all remaining slots", PSO_PEND_MAX - 2, filled);
EXPECT_EQ("table is full before overflow admission", PSO_PEND_MAX, queue_used_count());
EXPECT_EQ("one existing worker remains active in full table", 1, queue_active_count());
pso_key newest = make_key(500);
EXPECT_EQ("full table admits by reclaiming a completed entry", 1, pso_pend_kick(&newest, test_vs, test_fs));
EXPECT_TRUE("overflow compilation completes", pending_done(&newest, 5000));
EXPECT_EQ("oldest completed key was reclaimed", -1, queue_index(&oldest));
EXPECT_EQ("new key reused oldest completed slot", oldest_slot, queue_index(&newest));
EXPECT_EQ("running key kept its original slot", running_slot, queue_index(&running));
EXPECT_EQ("running slot is still not done", 0, queue_done_at(running_slot));
EXPECT_EQ("table remains full after replacing done entry", PSO_PEND_MAX, queue_used_count());
[dev releaseThrough:2];
EXPECT_TRUE("running compilation eventually completes", pending_done(&running, 5000));
id<MTLRenderPipelineState> got_running = pso_pend_take(&running);
EXPECT_TRUE("running result is still retrievable", got_running != nil && (__bridge void *)got_running == (__bridge void *)[dev resultForCall:2]);
id<MTLRenderPipelineState> got_newest = pso_pend_take(&newest);
EXPECT_TRUE("replacement result is retrievable", got_newest != nil && (__bridge void *)got_newest == (__bridge void *)[dev resultForCall:PSO_PEND_MAX + 1]);
EXPECT_TRUE("reclaimed key cannot be taken", pso_pend_take(&oldest) == nil);
EXPECT_EQ("no workers remain after takes", 0, queue_active_count());
reset_queue();
}
static void append_op(uint8_t *buf, size_t *used, unsigned op, const void *payload, size_t n) {
size_t size = (8 + n + 7) & ~7ull;
op_hdr h = {op, (uint32_t)size}; memcpy(buf + *used, &h, sizeof h);
if (n) memcpy(buf + *used + 8, payload, n);
*used += size;
}
static void test_nil_prediction_falls_back_then_predicts(void) {
reset_queue(); reset_pso_cache(); state_reset();
unsetenv("DM_NVN_HILITE"); unsetenv("DM_NVN_MAGSCAN"); unsetenv("DM_PSO_RECORD"); unsetenv("DM_PSO_USER"); unsetenv("DM_SAVE");
GateDevice *dev = [[GateDevice alloc] initBlockingThrough:1 nilCall:1]; g_dev = (id<MTLDevice>)(id)dev;
NvShader *vs1 = [NvShader new], *fs1 = [NvShader new], *vs2 = [NvShader new], *fs2 = [NvShader new];
snprintf(vs1->key, sizeof vs1->key, "pending-vs1"); snprintf(fs1->key, sizeof fs1->key, "pending-fs1");
snprintf(vs2->key, sizeof vs2->key, "pending-vs2"); snprintf(fs2->key, sizeof fs2->key, "pending-fs2");
NvProg *demand = [NvProg new]; demand->sh[0] = vs1; demand->sh[1] = fs1;
NvProg *predicted = [NvProg new]; predicted->sh[0] = vs2; predicted->sh[1] = fs2;
pso_key demand_key; pso_key_fill(&demand_key, vs1, fs1, g_pass.cfmt, g_pass.dfmt, g_pass.sfmt, g_pass.samples, 0);
EXPECT_EQ("speculative version admitted", 1, pso_pend_kick(&demand_key, vs1, fs1));
if (![dev waitForEntered:1 timeoutMs:5000]) { EXPECT_TRUE("speculative worker entered fake compiler", 0); [dev releaseAll]; drain_queue(5000); reset_queue(); return; }
[dev releaseThrough:1];
EXPECT_TRUE("fake compiler can complete speculative job with nil", pending_done(&demand_key, 5000));
EXPECT_TRUE("completed nil result remains available to demand lookup", queue_index(&demand_key) >= 0);
// A repeated demanded draw must use the freshly populated cache, while a
// second program supplies a real prediction without a Metal encoder.
prog_t predicted_control = {.magic = MAGIC('P', 'R', 'O', 'G'), .nstage = 2, .serial = 0x7f010001};
serial_put(predicted_control.serial, predicted);
prog_t demand_control = {.magic = MAGIC('P', 'R', 'O', 'G'), .nstage = 2, .serial = 0x7f010002};
serial_put(demand_control.serial, demand);
uint8_t stream[256] = {0}; size_t used = 0;
p_bind_program repeated = {&demand_control, 3}; append_op(stream, &used, OP_BIND_PROGRAM, &repeated, sizeof repeated);
p_draw repeated_draw = {.prim = NVN_DRAW_PRIMITIVE_TRIANGLES, .first = 0, .count = 3, .instances = 1};
append_op(stream, &used, OP_DRAW, &repeated_draw, sizeof repeated_draw);
p_bind_program bind = {&predicted_control, 3}; append_op(stream, &used, OP_BIND_PROGRAM, &bind, sizeof bind);
p_draw draw = {.prim = NVN_DRAW_PRIMITIVE_TRIANGLES, .first = 0, .count = 3, .instances = 1};
append_op(stream, &used, OP_DRAW, &draw, sizeof draw); append_op(stream, &used, OP_END, NULL, 0);
pso_key predicted_key; pso_key_fill(&predicted_key, vs2, fs2, g_pass.cfmt, g_pass.dfmt, g_pass.sfmt, g_pass.samples, 0);
[dev expectAbsentPendingKey:&predicted_key onSubmitterCall:2];
g_exec_next = stream; g_sub_serial = 1; g_pp_submit_mark = 0;
id<MTLRenderPipelineState> got = pso_get_(demand, nil);
EXPECT_TRUE("demand compile synchronously falls back after speculative nil", got != nil && (__bridge void *)got == (__bridge void *)[dev resultForCall:2]);
EXPECT_TRUE("repeated demanded draw is not queued as duplicate speculation", queue_index(&demand_key) < 0);
EXPECT_TRUE("demand compiler call ran on submitter thread", [dev callWasOnSubmitter:2]);
EXPECT_TRUE("predicted key was not reserved when demand compile entered", [dev submitterCallObservedKeyAbsent:2]);
EXPECT_TRUE("prediction compiler call ran after demand call", [dev waitForEntered:3 timeoutMs:5000] && ![dev callWasOnSubmitter:3]);
EXPECT_EQ("fake compiler observed one synchronous and two async calls", 1, [dev syncCalls]);
EXPECT_EQ("fake compiler observed speculative and predicted async calls", 2, [dev asyncCalls]);
EXPECT_TRUE("lookahead prediction was admitted after demand", pending_done(&predicted_key, 5000));
id<MTLRenderPipelineState> pred_result = pso_pend_take(&predicted_key);
EXPECT_TRUE("predicted key remains distinct from demanded key", pred_result != nil && queue_index(&demand_key) < 0);
[dev releaseAll]; EXPECT_TRUE("all fake compiler work has drained", drain_queue(5000));
g_exec_next = NULL; reset_queue(); reset_pso_cache();
}
int main(void) {
@autoreleasepool {
setvbuf(stdout, NULL, _IONBF, 0);
test_vs = [NvShader new]; test_fs = [NvShader new];
snprintf(test_vs->key, sizeof test_vs->key, "pending-base-vs"); snprintf(test_fs->key, sizeof test_fs->key, "pending-base-fs");
test_two_active_exact_take_and_release();
test_full_table_reclaims_done_not_running();
test_nil_prediction_falls_back_then_predicts();
printf("== %d PSO pending checks, %d failed ==\n", g_checks, g_failed);
return g_failed != 0;
}
}
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Super Smash Bros. Ultimate's data.arc: the file-system table and the current file of each path (files), and a file's bytes (read). Used by shader_split.py (its shaders) and
dm/tools/nro_collect.py (its NROs). The paths are only Hash40 values (CRC-32 of the path and its length); the extension Hash40 (crc32(b'nro') | 3 << 32) tells a file's kind."""
import struct, sys
MAGIC = struct.pack('<Q', 0xABCDEF9876543210)
ZSTD_MAGIC = bytes.fromhex('28b52ffd')
def zstd_mod(): # compression.zstd from Python 3.14, its backport before (pip install backports.zstd), the same API
try: from compression import zstd
except ImportError:
try: from backports import zstd
except ImportError: sys.exit(f'[RED] Python {sys.version_info[0]}.{sys.version_info[1]} has no compression.zstd (3.14+): pip install backports.zstd')
return zstd
def files(path):
"""Super Smash Bros. Ultimate's data.arc (17.6 GB at 13.0.5): u64 magic 0xABCDEF9876543210, then u64 offsets of the music section, the file section, the shared file section,
the file system table and its search table. The table is {u32 data start (0x10), u32 size, u32 stored size, u32 section size} and one zstd frame. Inflated: a 0x58-byte header
(u32 table size, path count, path-to-info index count, directory count, directory offset count, child hash count, file info count, info-to-data count, file data count,
second directory offset count, second file data count, 0, 0x10, 0x10, u8 region count, u8 language count, u16, u32 version (0x000d0005 = 13.0.5), extra directory offsets,
extra files, 0, extra files again, extra info-to-data, extra info-to-data sub count), the regions (12 bytes each), the stream header {quick directory count, stream hash count,
stream file index count, stream offset count} and those tables (12, 8 + 12, 4 and 16 bytes each), {u32 path hash group count, u32 bucket count} with the buckets (8) and groups (8),
then the paths (32 bytes: four Hash40 references {u32 CRC-32, u8 length, u24 index}: path, extension, parent, name), path-to-info indices (8: directory offset index, file info
index), directory hashes (8) and directory infos (0x34), directory offsets (0x1c: u64 offset in the file section, sizes, first file, file count, directory index), child hashes (8),
file infos (16: path index, path-to-info index, info-to-data index, flags), info-to-data (12: directory offset index, file data index, load type) and file data (16: offset in the
directory / 4, stored size, size, flags; 3 = zstd). A file's data is at file section + its directory offset + 4 × its offset. The table ends with a section the lookups do not use.
The update rewrote the archive in place and kept the superseded copies, which the file infos still reach (764 NROs for 98 paths); following each path to its file info gives the
current file only. → [(path Hash40, absolute offset, stored size, size, extension Hash40)] in offset order, one per data, or None for another file"""
with open(path, 'rb') as f:
h = f.read(0x38)
if h[:8] != MAGIC: return None
file_off, fs_off = struct.unpack_from('<Q', h, 0x10)[0], struct.unpack_from('<Q', h, 0x20)[0]
f.seek(fs_off); _, size, stored, _ = struct.unpack('<4I', f.read(16))
fs = zstd_mod().decompress(f.read(stored))
if len(fs) != size: sys.exit(f'[RED] {path}: file system table inflates to {len(fs):#x} bytes, its header says {size:#x}')
(tsize, n_paths, n_idx, n_dirs, n_off1, n_hfold, n_info, n_sub, n_data, n_off2, n_data2, _) = struct.unpack_from('<12I', fs, 0)
version, extra_dirs, extra_files, _, _, extra_f2d, _ = struct.unpack_from('<7I', fs, 0x3c)
o = 0x58 + fs[0x38] * 12
qd, sh, sfi, soe = struct.unpack_from('<4I', fs, o); o += 16 + qd * 12 + sh * 20 + sfi * 4 + soe * 16
groups, buckets = struct.unpack_from('<2I', fs, o); o += 8 + buckets * 8 + groups * 8
P = o; o += n_paths * 32
IDX = o; o += n_idx * 8 + n_dirs * (8 + 0x34)
DO = o; n_do = n_off1 + n_off2 + extra_dirs; o += n_do * 0x1c + n_hfold * 8
FI = o; n_fi = n_info + n_data2 + extra_files; o += n_fi * 16
F2D = o; n_f2d = n_sub + n_data2 + extra_f2d; o += n_f2d * 12
FD = o; n_fd = n_data + n_data2 + extra_files; o += n_fd * 16
if o > tsize: sys.exit(f'[RED] {path}: version {version:#x}: the file system table does not have the layout this reader knows (it would end at {o:#x} of {tsize:#x})')
out, seen = [], set()
for i in range(n_paths):
c, w = struct.unpack_from('<II', fs, P + 32 * i); k = w >> 8
if k >= n_idx: continue
fii = struct.unpack_from('<I', fs, IDX + 8 * k + 4)[0]
if fii >= n_fi: continue
f2di = struct.unpack_from('<I', fs, FI + 16 * fii + 8)[0]
if f2di >= n_f2d: continue
doi, fdi = struct.unpack_from('<II', fs, F2D + 12 * f2di)
if doi >= n_do or fdi >= n_fd: continue
off_in, csz, usz = struct.unpack_from('<3I', fs, FD + 16 * fdi)
at = file_off + struct.unpack_from('<Q', fs, DO + 0x1c * doi)[0] + 4 * off_in
if at in seen or not csz: continue
ec, ew = struct.unpack_from('<II', fs, P + 32 * i + 8)
seen.add(at); out.append((c | (w & 0xff) << 32, at, csz, usz, ec | (ew & 0xff) << 32))
return sorted(out, key=lambda e: e[1])
def hash40(s): # a path's or extension's Hash40: CRC-32 and length
import zlib
return zlib.crc32(s.encode()) | len(s) << 32
def read(m, e):
"""A file's bytes (m: the archive's bytes or mmap; e: an entry of files()), inflated when stored as a zstd frame"""
d = m[e[1]:e[1] + e[2]]
return zstd_mod().decompress(d) if d[:4] == ZSTD_MAGIC else d[:e[3]]
+13 -2
View File
@@ -7,7 +7,7 @@
#include <string.h>
#include "game_profile.h" // constants of the current game profile (generated from dm/games/<id>/game.tsv, chosen by the Makefile's GAME)
#define MAX_MOD 8
#define MAX_MOD 128 // the profile's modules (up to 8) and the NROs a game loads at run time (Super Smash Bros. Ultimate: 98)
#define LOG(...) do { fprintf(stderr, "[dm] " __VA_ARGS__); fputc('\n', stderr); } while (0)
#define die(...) do { LOG("FATAL " __VA_ARGS__); abort(); } while (0)
@@ -16,7 +16,7 @@ typedef struct { int64_t d_tag; union { uint64_t d_val, d_ptr; } d_un; } Elf64_D
typedef struct { uint64_t r_offset, r_info; int64_t r_addend; } Elf64_Rela;
#define ELF64_R_SYM(i) ((i) >> 32)
#define ELF64_R_TYPE(i) ((i) & 0xffffffff)
enum { DT_INIT = 12, DT_RELA = 7, DT_RELASZ = 8, DT_JMPREL = 23, DT_PLTRELSZ = 2, DT_SYMTAB = 6, DT_STRTAB = 5,
enum { DT_INIT = 12, DT_FINI = 13, DT_RELA = 7, DT_RELASZ = 8, DT_JMPREL = 23, DT_PLTRELSZ = 2, DT_SYMTAB = 6, DT_STRTAB = 5,
DT_INIT_ARRAY = 25, DT_INIT_ARRAYSZ = 27, DT_RELRSZ = 35, DT_RELR = 36 };
enum { R_AARCH64_ABS64 = 257, R_AARCH64_GLOB_DAT = 1025, R_AARCH64_JUMP_SLOT = 1026, R_AARCH64_RELATIVE = 1027 };
#define ELF64_ST_BIND(i) ((i) >> 4) // symbol binding: 0 = STB_LOCAL, 2 = STB_WEAK
@@ -40,6 +40,13 @@ typedef struct {
unsigned nsym;
uint32_t *symh; unsigned symh_mask; // Name hash table of defined symbols (open addressing, stores symbol indices; for a duplicated name only the smallest index is kept = the first hit of a linear scan)
uint64_t init, init_array, init_arraysz; // init = DT_INIT (the module's _init: registers the DSO and runs this module's init_array)
uint64_t fini; // DT_FINI (nn::ro::UnloadModule calls it)
// NRO modules (nn::ro, loader.c "NRO modules"): mapped at start like the others, but they take part in symbol lookups only between LoadModule and UnloadModule
int nro, loaded; // loaded: the profile's modules always; an NRO while the game has it loaded
uint8_t sha256[32]; // an NRO's SHA-256 (what the game's NRR registers; LoadModule finds the module by the image it passes)
uint32_t nro_size; // an NRO's file size (header +0x18), the bytes the hash covers
uint8_t *pristine; size_t pristine_len; // an NRO's data and bss as rewritten, restored before each load after the first
unsigned loads; // how many times the game loaded it
} module_t;
extern module_t g_mod[MAX_MOD];
@@ -51,6 +58,10 @@ extern pthread_key_t g_key18, g_keytls, g_keytlr, g_keygate;
void load_modules(const char *dir, const char **names, int n);
void run_module_inits(void);
module_t *nro_find(const uint8_t sha256[32]); // the mapped NRO with this hash (loader.c)
int nro_refused(const uint8_t sha256[32]); // an NRO the pack refused (an online SDK, nro.tsv "refused")
void nro_load(module_t *m); // relocate an NRO against the loaded modules and bind the earlier modules' imports it defines (the caller runs its init)
void nro_unload(module_t *m); // take an NRO out of the lookups and bind the imports that pointed into it again
void *module_sym(module_t *m, const char *name);
int dm_sdk_major(void); // SDK major version from the sdk module's version string (loader.c); 0 = unknown
#define DM_SDK_OLD(major) ((major) > 0 && (major) <= 10) // SDK 10 and older: NVN API 53, the shared-font address points at the TTF itself
+76 -12
View File
@@ -1,10 +1,17 @@
// nn::ro (loading NRO modules at run time): on the real device it connects to the ldr:ro service. The host has no such service; running the SDK's Initialize as is reads a null session in InitializeWithPortName and crashes (Engage).
// Engage (Unity) imports 8 ro interfaces but its RomFS has no .nro / .nrr: at startup it only calls Initialize, and symbol lookups go through LookupSymbol.
// Takeover: Initialize is a no-op; LookupSymbol finds the definition by name in the loaded modules (main / subsdk* / sdk); loading / unloading / registering modules always fails and reports red once (there is nothing to load).
// Only success matters: the game checks IsSuccess only, so the failure code is a non-zero value under the ro module number 22.
// Super Smash Bros. Ultimate loads its fighters' code as NROs (98 in data.arc, registered by .nrr/cross2_*.nrr). The service's only part in the SDK's LoadModule is mapping the image
// (SDK 8.2.1 LoadModuleInternal, sdk+0x347460: ldr:ro LoadNro; then the SDK zeroes bss, relocates through rtld's module lists, calls the module's init and binds the earlier modules'
// variables). The host replaces rtld, so it takes over the whole call: the NROs were rewritten offline and mapped at start (loader.c "NRO modules"); LoadModule finds the one whose
// SHA-256 is the image's (what the NRR registers), relocates it against the modules loaded now and calls its DT_INIT, as the SDK does. The image and buffer the game passes stay unused
// (on Horizon they become the module's memory; the game keeps them until UnloadModule).
// Only success matters for the failures: the game checks IsSuccess only, so a failure code is a non-zero value under the ro module number 22.
#include "hle.h"
#include <CommonCrypto/CommonDigest.h>
#define RO_FAIL ((Result)(22 | (1 << 9))) // a failure under ro module 22
#define RO_NOT_NRO ((Result)0x816) // the SDK's result for an image without NRO0 (sdk+0x3471e8)
static pthread_mutex_t g_ro_mu = PTHREAD_MUTEX_INITIALIZER; // the SDK holds g_RoLock over a load as well
HLE_FORCE("_ZN2nn2ro10InitializeEv", "connect to the ldr:ro service; the host has none, symbol lookups search the loaded modules directly", Result, h_ro_Initialize, void) { LOG("init takeover: ro::Initialize (no-op)"); return 0; }
@@ -16,29 +23,86 @@ HLE_FORCE("_ZN2nn2ro12LookupSymbolEPmPKc", "look up a definition by name in the
return 0;
}
HLE_FORCE("_ZN2nn2ro18LookupModuleSymbolEPmPKNS0_6ModuleEPKc", "no NRO loaded: every per-module lookup finds nothing", Result, h_ro_LookupModuleSymbol, uintptr_t *out, const void *m, const char *name) {
(void)out; (void)m; static int red; if (!red++) LOG("[RED] ro::LookupModuleSymbol %s: no NRO loaded", name ? name : "(null)"); return RO_FAIL;
// nn::ro::Module as the SDK fills it (LoadModuleInternal): +0 the module object (RoModule, inside the NRO on Horizon; the host's module_t here), +8 state (1 = loaded), +0x10 image, +0x18 buffer
typedef struct { void *module; uint32_t state, pad; const void *image; void *buffer; } ro_module;
static module_t *module_from(const ro_module *rm) {
if (!rm || rm->state != 1) return NULL;
for (int i = 0; i < g_nmod; i++) if (rm->module == (void *)&g_mod[i] && g_mod[i].nro && g_mod[i].loaded) return &g_mod[i];
return NULL;
}
HLE_FORCE("_ZN2nn2ro13GetBufferSizeEPmPKv", "NROs are not loaded", Result, h_ro_GetBufferSize, size_t *out, const void *image) {
(void)image; if (out) *out = 0; static int red; if (!red++) LOG("[RED] ro::GetBufferSize: the host does not load NROs"); return RO_FAIL;
HLE_FORCE("_ZN2nn2ro18LookupModuleSymbolEPmPKNS0_6ModuleEPKc", "look up a definition by name in one loaded NRO", Result, h_ro_LookupModuleSymbol, uintptr_t *out, const void *mod, const char *name) {
module_t *m = module_from(mod); void *p = m && name ? module_sym(m, name) : NULL;
if (!p) { static int red; if (!red++) LOG("[RED] ro::LookupModuleSymbol %s: %s", name ? name : "(null)", m ? "not defined in that module" : "not a loaded module"); return RO_FAIL; }
if (out) *out = (uintptr_t)p;
return 0;
}
HLE_FORCE("_ZN2nn2ro10LoadModuleEPNS0_6ModuleEPKvPvmi", "NROs are not loaded", Result, h_ro_LoadModule, void *m, const void *image, void *buf, size_t size, int flag) {
(void)m; (void)image; (void)buf; (void)flag; static int red; if (!red++) LOG("[RED] ro::LoadModule: the host does not load NROs (buffer %#zx)", size); return RO_FAIL;
// As the SDK computes it (sdk+0x3471c0, no service): the bss size from the NRO header
HLE_FORCE("_ZN2nn2ro13GetBufferSizeEPmPKv", "the bss size from the NRO header, as the SDK computes it", Result, h_ro_GetBufferSize, size_t *out, const void *image) {
const uint8_t *h = image;
if (!h || memcmp(h + 0x10, "NRO0", 4)) return RO_NOT_NRO;
if (out) *out = *(const uint32_t *)(h + 0x38);
return 0;
}
HLE_FORCE("_ZN2nn2ro10LoadModuleEPNS0_6ModuleEPKvPvmi", "load an NRO rewritten offline (loader.c): found by the image's SHA-256, relocated against the loaded modules, its init called",
Result, h_ro_LoadModule, void *mod, const void *image, void *buf, size_t size, int flag) {
const uint8_t *h = image;
if (!h || memcmp(h + 0x10, "NRO0", 4)) return RO_NOT_NRO;
uint32_t n = *(const uint32_t *)(h + 0x18);
uint8_t sha[32]; CC_SHA256(h, n, sha);
char hx[17]; for (int k = 0; k < 8; k++) snprintf(hx + 2 * k, 3, "%02x", sha[k]);
// Online SDKs are never loaded, whatever the game (dm/tools/nro_collect.py ONLINE_SDKS: Epic Online Services, known by its export EOS_Platform_Create). Hades and Hades II load
// rom:/nro/EOSSDK-Switch-Shipping.nro for Epic-account cross-save (Auth, Connect, PlayerDataStorage), which the host cannot serve; the real SDK would at best fail its login and at
// worst wait on time-outs or contact Epic's servers. Refused, the game's EOS_Platform_Create stays a stub, it logs "[EOS] Failed to create platform" and plays offline with local
// saves, as before the host loaded NROs. The pack leaves such an NRO out (a "refused" row in nro.tsv); an older nro.tsv that has it is caught by the export below
if (nro_refused(sha)) { LOG("ro::LoadModule: refused %s… (%u bytes): an online SDK (Epic Online Services), not loaded; the game plays offline", hx, n); return RO_FAIL; }
pthread_mutex_lock(&g_ro_mu);
module_t *m = nro_find(sha);
if (!m) { pthread_mutex_unlock(&g_ro_mu); LOG("[RED] ro::LoadModule: no rewritten NRO with SHA-256 %s… (%u bytes): it is not in the patched modules' nro.tsv", hx, n); return RO_FAIL; }
if (module_sym(m, "EOS_Platform_Create")) { pthread_mutex_unlock(&g_ro_mu); LOG("ro::LoadModule: refused %s (%s…): an online SDK (Epic Online Services), not loaded; the game plays offline", m->name, hx); return RO_FAIL; }
if (m->loaded) { pthread_mutex_unlock(&g_ro_mu); LOG("[RED] ro::LoadModule: %s (%s…) is loaded already", m->name, hx); return RO_FAIL; }
if (m->nro_size != n) LOG("[RED] ro::LoadModule: %s is %u bytes, nro.tsv says %u", m->name, n, m->nro_size);
nro_load(m);
ro_module *rm = mod;
if (rm) { rm->module = m; rm->state = 1; rm->image = image; rm->buffer = buf; }
pthread_mutex_unlock(&g_ro_mu);
LOG("ro::LoadModule %s (SHA-256 %s…, %u bytes, buffer %#zx, bind %s): init %s", m->name, hx, n, size, flag & 1 ? "now" : "lazy", m->init ? "called" : "none");
if (m->init) ((void (*)(void))(m->base + m->init))(); // as RoModule::CallInit: the module's _init (registers it and runs its init_array)
return 0;
}
// SDK 19 adds a bool after the bind flag (Hades II imports only this form, for its Epic Online Services NRO)
HLE_FORCE("_ZN2nn2ro10LoadModuleEPNS0_6ModuleEPKvPvmib", "NROs are not loaded", Result, h_ro_LoadModule2, void *m, const void *image, void *buf, size_t size, int flag, int b) {
HLE_FORCE("_ZN2nn2ro10LoadModuleEPNS0_6ModuleEPKvPvmib", "load an NRO rewritten offline (as the form without the bool)", Result, h_ro_LoadModule2, void *m, const void *image, void *buf, size_t size, int flag, int b) {
(void)b; return h_ro_LoadModule(m, image, buf, size, flag);
}
HLE_FORCE("_ZN2nn2ro8FinalizeEv", "Initialize opened no ldr:ro session", void, h_ro_Finalize, void) { }
HLE_FORCE("_ZN2nn2ro12UnloadModuleEPNS0_6ModuleE", "NROs are not loaded", void, h_ro_UnloadModule, void *m) { (void)m; }
HLE_FORCE("_ZN2nn2ro12UnloadModuleEPNS0_6ModuleE", "unload an NRO: its fini, then out of the symbol lookups", void, h_ro_UnloadModule, void *mod) {
module_t *m = module_from(mod);
if (!m || !m->loaded) { LOG("[RED] ro::UnloadModule: not a loaded module (%p)", mod); return; }
if (m->fini) ((void (*)(void))(m->base + m->fini))(); // as RoModule::CallFini
pthread_mutex_lock(&g_ro_mu);
nro_unload(m);
((ro_module *)mod)->state = 0; ((ro_module *)mod)->module = NULL;
pthread_mutex_unlock(&g_ro_mu);
}
HLE_FORCE("_ZN2nn2ro18RegisterModuleInfoEPNS0_16RegistrationInfoEPKv", "NRRs are not registered", Result, h_ro_RegisterModuleInfo, void *info, const void *nrr) {
(void)info; (void)nrr; static int red; if (!red++) LOG("[RED] ro::RegisterModuleInfo: the host does not register NRRs"); return RO_FAIL;
// The NRRs only register hashes with the service, which checks a module's hash against them at load. The host's modules are the ones the NSP script found by those hashes
// (dm/tools/nro_collect.py), so there is nothing to check: success
HLE_FORCE("_ZN2nn2ro18RegisterModuleInfoEPNS0_16RegistrationInfoEPKv", "register an NRR: nothing to check, the host's NROs were collected by its hashes", Result, h_ro_RegisterModuleInfo, void *info, const void *nrr) {
const uint8_t *r = nrr;
if (!r || memcmp(r, "NRR0", 4)) return RO_FAIL;
uint32_t off = *(const uint32_t *)(r + 0x340), cnt = *(const uint32_t *)(r + 0x344), refused = 0;
for (uint32_t i = 0; i < cnt; i++) refused += nro_refused(r + off + 32 * i);
// An NRR that registers only online SDKs the host refuses (Hades and Hades II: game.nrr with the one EOS SDK hash) fails as every registration did before the host loaded NROs,
// so such a game takes the path it took then (it never asked for the module)
if (cnt && refused == cnt) { LOG("ro::RegisterModuleInfo: refused an NRR that registers only online SDKs (%u hashes), as before NROs were loaded; the game plays offline", cnt); return RO_FAIL; }
static int n; if (n++ < 20) LOG("ro::RegisterModuleInfo: NRR with %u hashes", cnt);
(void)info;
return 0;
}
HLE_FORCE("_ZN2nn2ro20UnregisterModuleInfoEPNS0_16RegistrationInfoE", "NRRs are not registered", void, h_ro_UnregisterModuleInfo, void *info) { (void)info; }
+2 -1
View File
@@ -51,7 +51,8 @@ static void mem_init(void) {
// Previously 3 GB was reported here and SetHeapSize never failed: the heap was about 150 MB smaller than on the real device, at the run entry peak the main heap had only about 18 MB left, and under high local load a timing shift made a 128 KB request return null,
// which the guest writes to without a null check (CRASH main+0x1b36138, re-verified after merge with inv_tap).
#define APP_POOL DM_GAME_APP_POOL // game profile app_pool (Mario 0xCD500000)
static size_t image_bytes(void) { size_t n = 0; for (int i = 0; i < g_nmod; i++) n += g_mod[i].size; return (n + 0x1fffff) & ~(size_t)0x1fffff; }
static size_t image_bytes(void) { size_t n = 0; for (int i = 0; i < g_nmod; i++) if (!g_mod[i].nro) n += g_mod[i].size; // an NRO lives in memory the game allocated (its image and bss buffers)
return (n + 0x1fffff) & ~(size_t)0x1fffff; }
// Giant bss (modules with the patch area before the base, patch_nso.py FAR): the image bytes beyond the file part. Used memory was reported as "heap + 64 MB", where the 64 MB covers ordinary modules' code and data (Mario's two modules are about 74 MB together,
// and the heap it opens = pool − 64 MB − 1.75 GB virtual address memory − 1 MB = 0x5be00000, as on hardware); Metroid Dread's main has about 1.8 GB of bss, the engine's static heap, which the Switch counts as used;
// without it the game asks for pool − 64 MB = 3.15 GB of heap
+143 -32
View File
@@ -12,6 +12,9 @@
module_t g_mod[MAX_MOD];
int g_nmod;
// Stable module addresses live in g_mod; lookup order follows activation, including unload/reload.
static module_t *g_loaded[MAX_MOD]; static int g_nloaded;
static pthread_rwlock_t g_modules_mu = PTHREAD_RWLOCK_INITIALIZER;
pthread_key_t g_key18, g_keytls, g_keytlr, g_keygate;
enum { KEY_K18, KEY_KTLS, KEY_KTLR, KEY_KGATE, KEY_N };
@@ -49,6 +52,7 @@ static void parse_dynamic(module_t *m) {
case DT_SYMTAB: m->symtab = (const Elf64_Sym *)(m->base + d->d_un.d_ptr); break;
case DT_STRTAB: m->strtab = (const char *)(m->base + d->d_un.d_ptr); break;
case DT_INIT: m->init = d->d_un.d_ptr; break;
case DT_FINI: m->fini = d->d_un.d_ptr; break;
case DT_INIT_ARRAY: m->init_array = d->d_un.d_ptr; break;
case DT_INIT_ARRAYSZ: m->init_arraysz = d->d_un.d_val; break;
}
@@ -92,10 +96,14 @@ void *module_sym(module_t *m, const char *name) {
if (l != r) { g_symchk_bad++; LOG("[RED] DM_SYMCHECK: %s in %s hash %p ≠ linear %p", name, m->name, r, l); } }
return r;
}
void *guest_sym(const char *name) {
for (int i = 0; i < g_nmod; i++) { void *p = module_sym(&g_mod[i], name); if (p) return p; }
static void *loaded_sym(const char *name) { // caller holds g_modules_mu, or is doing startup relocation before guest threads run
for (int i = 0; i < g_nloaded; i++) { void *p = module_sym(g_loaded[i], name); if (p) return p; }
return NULL;
}
void *guest_sym(const char *name) {
pthread_rwlock_rdlock(&g_modules_mu); void *p = loaded_sym(name); pthread_rwlock_unlock(&g_modules_mu);
return p;
}
// For modules with the patch area before the base (patch_off < 0) addresses in the patch area count too, as for modules whose patch area follows the image (crash reports, the GC pause's "in guest code" test)
module_t *module_of(const void *p) {
for (int i = 0; i < g_nmod; i++) {
@@ -134,7 +142,7 @@ int dm_sdk_major(void) {
// Cross-module symbol resolution: first look for a definition in the loaded modules, and only if not found hand over to HLE / stubs
static uint64_t resolve_sym(module_t *m, const char *name) {
for (int i = 0; i < g_nmod; i++) { void *p = module_sym(&g_mod[i], name); if (p) return (uint64_t)p; }
void *p = loaded_sym(name); if (p) return (uint64_t)p;
return resolve_import(m, name);
}
@@ -260,50 +268,152 @@ static unsigned relr_apply(module_t *m, uint64_t relr, uint64_t relrsz, uint64_t
return n;
}
static void relocate(module_t *m) {
uint64_t rela = 0, relasz = 0, jmprel = 0, pltsz = 0, relr = 0, relrsz = 0;
// The address a symbol relocation of module m binds to, as Horizon's rtld would with the modules loaded now (forced take-over, then the loaded modules in load order, then HLE / stub).
// *other: the module's own definition was taken over by an earlier module's; *weak0: an undefined weak symbol nothing defines, bound to 0
static uint64_t bind_sym(module_t *m, const Elf64_Sym *s, int *other, int *weak0) {
uint64_t v = (uint64_t)force_lookup(m->strtab + s->st_name);
*other = *weak0 = 0;
// Symbols the module defines itself are also looked up globally in load order (the ELF default; Horizon's rtld does the same): the definition of an earlier-loaded module overrides a later one; only local / non-default visibility binds to itself.
// Engage's main defines its own operator new / delete (Unity's memory manager) and sdk has a copy too (forwarding to malloc / free): previously sdk bound its own delete,
// so blocks from main's new were freed by sdk's libc++ string code into the SDK heap, and CentralHeap::Free reported NotNMallocPtr
if (!v && s->st_shndx) {
uint64_t self = (uint64_t)m->base + s->st_value;
v = ELF64_ST_BIND(s->st_info) == STB_LOCAL || ELF64_ST_VISIBILITY(s->st_other) != STV_DEFAULT ? self : resolve_sym(m, m->strtab + s->st_name);
if (!v) v = self;
*other = v != self;
}
// An undefined weak symbol that no module defines and the host does not implement is 0, as Horizon's rtld binds it: the code tests its address before calling it.
// Octopath Traveler 0's main imports the thread_local init function _ZTHN13LowLevelTasks5FTask10ActiveTaskE weakly; a named stub made it look defined and the game called it
const char *nm = m->strtab + s->st_name;
*weak0 = !v && !s->st_shndx && ELF64_ST_BIND(s->st_info) == STB_WEAK && !loaded_sym(nm) && !hle_implements(nm);
if (!v && !*weak0) v = resolve_sym(m, nm);
return v;
}
typedef struct { const Elf64_Rela *tab[2]; uint64_t n[2]; uint64_t relr, relrsz; } relocs_t;
static relocs_t relocs_of(const module_t *m) {
uint64_t rela = 0, relasz = 0, jmprel = 0, pltsz = 0; relocs_t r = {0};
for (const Elf64_Dyn *d = m->dyn; d->d_tag; d++) switch (d->d_tag) {
case DT_RELA: rela = d->d_un.d_ptr; break;
case DT_RELASZ: relasz = d->d_un.d_val; break;
case DT_JMPREL: jmprel = d->d_un.d_ptr; break;
case DT_PLTRELSZ: pltsz = d->d_un.d_val; break;
case DT_RELR: relr = d->d_un.d_ptr; break;
case DT_RELRSZ: relrsz = d->d_un.d_val; break;
case DT_RELR: r.relr = d->d_un.d_ptr; break;
case DT_RELRSZ: r.relrsz = d->d_un.d_val; break;
}
r.tab[0] = (const Elf64_Rela *)(m->base + rela); r.tab[1] = (const Elf64_Rela *)(m->base + jmprel);
r.n[0] = relasz / sizeof(Elf64_Rela); r.n[1] = pltsz / sizeof(Elf64_Rela);
return r;
}
static void relocate(module_t *m) {
relocs_t rl = relocs_of(m);
int cnt[3] = {0}, nint = 0, nweak0 = 0;
unsigned nrelr = relr ? relr_apply(m, relr, relrsz, (uint64_t)m->base) : 0;
const Elf64_Rela *tabs[2] = {(const Elf64_Rela *)(m->base + rela), (const Elf64_Rela *)(m->base + jmprel)};
uint64_t sizes[2] = {relasz, pltsz};
unsigned nrelr = rl.relr ? relr_apply(m, rl.relr, rl.relrsz, (uint64_t)m->base) : 0;
for (int t = 0; t < 2; t++)
for (uint64_t i = 0; i < sizes[t] / sizeof(Elf64_Rela); i++) {
const Elf64_Rela *r = &tabs[t][i];
for (uint64_t i = 0; i < rl.n[t]; i++) {
const Elf64_Rela *r = &rl.tab[t][i];
uint64_t *where = (uint64_t *)(m->base + r->r_offset);
unsigned type = ELF64_R_TYPE(r->r_info), si = ELF64_R_SYM(r->r_info);
if (type == R_AARCH64_RELATIVE) { *where = (uint64_t)m->base + r->r_addend; cnt[0]++; continue; }
const Elf64_Sym *s = &m->symtab[si];
uint64_t v = (uint64_t)force_lookup(m->strtab + s->st_name);
// Symbols the module defines itself are also looked up globally in load order (the ELF default; Horizon's rtld does the same): the definition of an earlier-loaded module overrides a later one; only local / non-default visibility binds to itself.
// Engage's main defines its own operator new / delete (Unity's memory manager) and sdk has a copy too (forwarding to malloc / free): previously sdk bound its own delete,
// so blocks from main's new were freed by sdk's libc++ string code into the SDK heap, and CentralHeap::Free reported NotNMallocPtr
if (!v && s->st_shndx) {
uint64_t self = (uint64_t)m->base + s->st_value;
v = ELF64_ST_BIND(s->st_info) == STB_LOCAL || ELF64_ST_VISIBILITY(s->st_other) != STV_DEFAULT ? self : resolve_sym(m, m->strtab + s->st_name);
if (!v) v = self;
if (v != self && nint++ < 8) LOG("%s: %s bound to an earlier module's definition (%s)", m->name, m->strtab + s->st_name, module_of((void *)v) ? module_of((void *)v)->name : "?");
}
// An undefined weak symbol that no module defines and the host does not implement is 0, as Horizon's rtld binds it: the code tests its address before calling it.
// Octopath Traveler 0's main imports the thread_local init function _ZTHN13LowLevelTasks5FTask10ActiveTaskE weakly; a named stub made it look defined and the game called it
const char *nm = m->strtab + s->st_name;
int weak0 = !v && !s->st_shndx && ELF64_ST_BIND(s->st_info) == STB_WEAK && !guest_sym(nm) && !hle_implements(nm);
if (weak0 && nweak0++ < 8) LOG("%s: undefined weak %s bound to 0", m->name, nm);
if (!v && !weak0) v = resolve_sym(m, nm);
const Elf64_Sym *s = &m->symtab[si]; int other, weak0;
uint64_t v = bind_sym(m, s, &other, &weak0);
if (other && nint++ < 8) LOG("%s: %s bound to an earlier module's definition (%s)", m->name, m->strtab + s->st_name, module_of((void *)v) ? module_of((void *)v)->name : "?");
if (weak0 && nweak0++ < 8) LOG("%s: undefined weak %s bound to 0", m->name, m->strtab + s->st_name);
if (type == R_AARCH64_JUMP_SLOT) { *where = v; cnt[2]++; }
else { *where = v + r->r_addend; cnt[1]++; } // ABS64 / GLOB_DAT
}
LOG("%s: reloc relative=%d relr=%u abs/glob=%d jump=%d%s", m->name, cnt[0], nrelr, cnt[1], cnt[2], nint ? " (some bound to an earlier module's definition)" : "");
if (!m->nro || m->loads <= 1)
LOG("%s: reloc relative=%d relr=%u abs/glob=%d jump=%d%s", m->name, cnt[0], nrelr, cnt[1], cnt[2], nint ? " (some bound to an earlier module's definition)" : "");
if (nint > 8) LOG("%s: %d bound to an earlier module's definition in all", m->name, nint);
}
// ---- NRO modules (nn::ro, hle_ro.c) ----
// A game may load modules at run time: nn::ro::LoadModule hands over an NRO image it read from its files (Super Smash Bros. Ultimate: the fighters' code, 98 NROs in data.arc, whose
// exports main imports: 1,045 lua2cpp::create_agent_* functions). iOS runs no code loaded at run time, so the NROs are rewritten offline like the other modules (dm/tools/nro_collect.py,
// patch_nso.py) and listed in <patched dir>/nro.tsv with their SHA-256. All are mapped at start (code read-only + execute from then on); none takes part in symbol lookups until the game
// loads it. A load restores the NRO's data and bss as rewritten (after the first), relocates it against the modules loaded then, in load order (Horizon's rtld does the same), and binds the
// symbol relocations of the modules loaded before it that it defines and nothing loaded defined (Horizon: BindVariables, and functions bound lazily at their first call): main's lua2cpp
// imports are stubs until then. Only data is written, so the same works with code embedded in the iOS executable. An unload binds every relocation that pointed into the NRO again
static void map_image(module_t *m, const char *dir);
static uint8_t g_refused[16][32]; static int g_nrefused; // NROs the pack refused (online SDKs, nro.tsv rows named "refused")
int nro_refused(const uint8_t sha256[32]) {
for (int i = 0; i < g_nrefused && i < 16; i++) if (!memcmp(g_refused[i], sha256, 32)) return 1;
return 0;
}
static void preload_nros(const char *dir) {
char path[1024], line[512]; snprintf(path, sizeof path, "%s/nro.tsv", dir);
const char *off = getenv("DM_NRO");
if (off && !atoi(off)) { LOG("NRO: loading off (DM_NRO=0): every LoadModule fails"); return; }
FILE *f = fopen(path, "r");
if (!f) return;
int n0 = g_nmod; size_t data = 0;
while (fgets(line, sizeof line, f)) {
if (line[0] == '#' || line[0] == '\n') continue;
char name[16], hex[65]; unsigned size;
if (sscanf(line, "%15s %64s %u", name, hex, &size) != 3 || strlen(hex) != 64) die("%s: bad row %s", path, line);
if (!strcmp(name, "refused")) { // an online SDK the pack left out (dm/tools/nro_collect.py ONLINE_SDKS): LoadModule refuses its hash
if (g_nrefused < (int)(sizeof g_refused / sizeof *g_refused)) for (int k = 0; k < 32; k++) sscanf(hex + 2 * k, "%2hhx", &g_refused[g_nrefused][k]);
g_nrefused++; continue;
}
if (g_nmod >= MAX_MOD) die("%s: more NROs than module slots (MAX_MOD %d)", path, MAX_MOD);
module_t *m = &g_mod[g_nmod];
strlcpy(m->name, name, sizeof m->name); m->nro = 1; m->nro_size = size;
for (int k = 0; k < 32; k++) sscanf(hex + 2 * k, "%2hhx", &m->sha256[k]);
read_layout(m, dir); map_image(m, dir); parse_dynamic(m); register_gates(m, dir);
m->pristine_len = m->key_page_off - m->data_off;
if (!(m->pristine = malloc(m->pristine_len))) die("%s: pristine data copy", m->name);
memcpy(m->pristine, m->base + m->data_off, m->pristine_len); data += m->pristine_len;
g_nmod++;
}
fclose(f);
LOG("NRO: %d modules mapped (%s), loaded when the game asks; data and bss kept as rewritten %.1f MB", g_nmod - n0, path, data / 1048576.0);
}
module_t *nro_find(const uint8_t sha256[32]) {
for (int i = 0; i < g_nmod; i++) if (g_mod[i].nro && !memcmp(g_mod[i].sha256, sha256, 32)) return &g_mod[i];
return NULL;
}
// Bind the symbol relocations of module k again: those whose symbol nm defines (load) or whose slot points into nm (unload). Returns how many changed
static unsigned rebind(module_t *k, module_t *nm, int load) {
relocs_t rl = relocs_of(k); unsigned n = 0;
uint64_t lo = (uint64_t)nm->base, hi = lo + nm->size;
for (int t = 0; t < 2; t++)
for (uint64_t i = 0; i < rl.n[t]; i++) {
const Elf64_Rela *r = &rl.tab[t][i];
unsigned type = ELF64_R_TYPE(r->r_info);
if (type == R_AARCH64_RELATIVE) continue;
const Elf64_Sym *s = &k->symtab[ELF64_R_SYM(r->r_info)];
uint64_t *where = (uint64_t *)(k->base + r->r_offset), add = type == R_AARCH64_JUMP_SLOT ? 0 : (uint64_t)r->r_addend, cur = *where - add;
if (load ? s->st_shndx || !module_sym(nm, k->strtab + s->st_name) : !(cur >= lo && cur < hi)) continue;
int other, weak0; uint64_t v = bind_sym(k, s, &other, &weak0);
if (v != cur) { *where = v + add; n++; }
}
return n;
}
void nro_load(module_t *m) {
pthread_rwlock_wrlock(&g_modules_mu);
if (m->loads) memcpy(m->base + m->data_off, m->pristine, m->pristine_len);
m->loads++; m->loaded = 1; g_loaded[g_nloaded++] = m;
relocate(m);
unsigned n = 0;
for (int i = 0; i < g_nmod; i++) if (g_mod[i].loaded && &g_mod[i] != m) n += rebind(&g_mod[i], m, 1);
pthread_rwlock_unlock(&g_modules_mu);
LOG("NRO %s loaded (load %u, base %p): %u imports of the modules loaded before bound to it", m->name, m->loads, m->base, n);
}
void nro_unload(module_t *m) {
pthread_rwlock_wrlock(&g_modules_mu);
m->loaded = 0;
for (int i = 0; i < g_nloaded; i++) if (g_loaded[i] == m) { memmove(g_loaded + i, g_loaded + i + 1, (size_t)(--g_nloaded - i) * sizeof *g_loaded); break; }
unsigned n = 0;
for (int i = 0; i < g_nmod; i++) if (g_mod[i].loaded) n += rebind(&g_mod[i], m, 0);
pthread_rwlock_unlock(&g_modules_mu);
LOG("NRO %s unloaded: %u imports that pointed into it bound again", m->name, n);
}
// DM_LOADHASH=1 (diagnostics): after loading, output an address-independent sha256 per module, to prove that the load results of "early start / no early start" are byte-identical.
// Module base addresses and host ASLR differ every run, and only the values of the relocation slots change with them: copy the image and, using the same relocation table, replace every slot with an address-independent token --
// RELATIVE → (0, addend); symbol target falls in some module → (1 + module number, offset within the module); otherwise (host forced take-over / host symbol) → (name FNV, addend); all other bytes as is.
@@ -444,11 +554,12 @@ void load_modules(const char *dir, const char **names, int n) {
t = now_ns(); parse_dynamic(m); tp += now_ns() - t;
t = now_ns(); register_gates(m, dir); tg += now_ns() - t;
LOG("%s: base %p size %#zx", names[i], m->base, m->size);
g_nmod++;
m->loaded = 1; g_loaded[g_nloaded++] = m; g_nmod++;
}
force_init();
t = now_ns(); for (int i = 0; i < g_nmod; i++) relocate(&g_mod[i]); tr = now_ns() - t;
force_check();
preload_nros(dir); // mapped now, relocated when the game loads them (hle_ro.c)
{ void hlecount_seal(void); hlecount_seal(); } // DM_HLECOUNT: relocation done, seal the counting stub pages as read-only executable
if (getenv("DM_SYMCHECK")) LOG("DM_SYMCHECK: %lu symbol lookups at load time, hash and linear disagree %lu", g_symchk_n, g_symchk_bad);
if (g_flag.bootprof) LOG("① breakdown: mapping (read + copy + seal pages) %.1f ms, dynamic section parsing %.1f ms, gate registration %.1f ms, relocation + forced takeover %.1f ms, total %.1f ms",
@@ -460,7 +571,7 @@ void load_modules(const char *dir, const char **names, int n) {
void run_module_inits(void) {
for (int i = 0; i < g_nmod; i++) {
module_t *m = &g_mod[i];
if (!m->init) continue;
if (!m->init || m->nro) continue; // an NRO's init runs when the game loads it (hle_ro.c)
LOG("%s: DT_INIT +%#llx (init_array %llu entries)", m->name, m->init, m->init_arraysz / 8);
((void (*)(void))(m->base + m->init))();
}
+7
View File
@@ -26,6 +26,13 @@ run: $(B)/t_touch $(B)/t_gesture $(B)/t_oe $(B)/t_fs $(B)/t_fs_commit_list $(B)/
$(B)/t_hid
$(B)/t_haptics
$(B)/t_haptics_apple
$(B)/t_nro
run: $(B)/t_nro
$(B)/t_nro: t_nro.c ../loader.c ../hle_ro.c ../hle.h ../dm.h $(GAME_H) | $(B)
clang -O1 -g -Wall -Wno-unused-function -Wno-deprecated-declarations -I.. $(GINC) -o $@ t_nro.c
.PHONY: nro
nro: $(B)/t_nro
$(B)/t_nro
$(GAME_H):
$(MAKE) -C .. game_profile GAME=$(GAME)
$(B)/t_touch: t_touch.c ../hle_touch.c ../front.h ../dm.h $(GAME_H) | $(B)
+90
View File
@@ -0,0 +1,90 @@
// Synthetic modules exercise real relocation and nn::ro calls; no game files or executable guest code required.
#include "../loader.c"
#include "../hle_ro.c"
#include <assert.h>
#include <stddef.h>
struct dm_flags g_flag;
uint64_t now_ns(void) { return 0; }
void gate_register(uint64_t a, const char *k, int n, uint64_t o) { (void)a; (void)k; (void)n; (void)o; }
void hlecount_seal(void) {}
void *hlecount_wrap(const char *kind, const char *name, void *fn) { (void)kind; (void)name; return fn; }
static uint64_t missing;
uint64_t resolve_import(module_t *m, const char *name) { (void)m; (void)name; return (uint64_t)&missing; }
int hle_implements(const char *name) { (void)name; return 0; }
typedef struct {
Elf64_Sym sym[4];
char names[32];
Elf64_Dyn dyn[3];
Elf64_Rela rel[3];
uint64_t slots[3], value;
} image;
static image images[3];
static uint8_t raw[2][128];
static ro_module handles[2];
static int inits, finis;
static void init(void) { assert(guest_sym("shared")); inits++; }
static void fini(void) { assert(guest_sym("shared")); finis++; }
static void fixture(int i) {
image *im = &images[i]; module_t *m = &g_mod[i];
m->base = (uint8_t *)im; m->size = sizeof *im;
snprintf(m->name, sizeof m->name, "test%d", i);
memcpy(im->names, "\0shared\0late\0absent\0", 20);
m->symtab = im->sym; m->strtab = im->names; m->nsym = 4; m->dyn = im->dyn;
im->sym[1] = (Elf64_Sym){.st_name = 1, .st_info = 1 << 4, .st_shndx = i ? 1 : 0, .st_value = offsetof(image, value)};
im->sym[2] = (Elf64_Sym){.st_name = 8, .st_info = STB_WEAK << 4, .st_shndx = i == 1 ? 1 : 0, .st_value = offsetof(image, value)};
im->sym[3] = (Elf64_Sym){.st_name = 13, .st_info = STB_WEAK << 4};
im->dyn[0] = (Elf64_Dyn){.d_tag = DT_RELA, .d_un.d_ptr = offsetof(image, rel)};
im->dyn[1] = (Elf64_Dyn){.d_tag = DT_RELASZ, .d_un.d_val = sizeof im->rel};
for (int k = 0; k < 3; k++) im->rel[k] = (Elf64_Rela){.r_offset = offsetof(image, slots) + 8 * k, .r_info = (uint64_t)(k + 1) << 32 | R_AARCH64_ABS64, .r_addend = k == 0 ? 8 : 0};
build_symhash(m);
if (!i) { m->loaded = 1; g_loaded[g_nloaded++] = m; return; }
m->nro = 1; m->data_off = offsetof(image, slots); m->pristine_len = sizeof *im - m->data_off;
im->value = 123; m->pristine = malloc(m->pristine_len); memcpy(m->pristine, m->base + m->data_off, m->pristine_len);
uint8_t *h = raw[i - 1]; memcpy(h + 0x10, "NRO0", 4); *(uint32_t *)(h + 0x18) = sizeof raw[0]; h[0x40] = (uint8_t)i;
*(uint32_t *)(h + 0x38) = 32; m->nro_size = sizeof raw[0]; CC_SHA256(h, sizeof raw[0], m->sha256);
m->init = (uintptr_t)init - (uintptr_t)m->base; m->fini = (uintptr_t)fini - (uintptr_t)m->base;
}
static void load(int i) { assert(h_ro_LoadModule(&handles[i - 1], raw[i - 1], NULL, 32, 1) == 0); }
static void unload(int i) { h_ro_UnloadModule(&handles[i - 1]); assert(!g_mod[i].loaded); }
int main(void) {
g_nmod = 3; for (int i = 0; i < 3; i++) fixture(i);
relocate(&g_mod[0]);
assert(images[0].slots[0] == (uint64_t)&missing + 8);
assert(images[0].slots[1] == 0 && images[0].slots[2] == 0);
assert(!guest_sym("shared"));
size_t size = 0; assert(h_ro_GetBufferSize(&size, raw[0]) == 0 && size == 32);
load(2); load(1); // deliberately opposite to manifest/hash order
uint64_t second = (uint64_t)&images[2].value, first = (uint64_t)&images[1].value;
assert((uint64_t)guest_sym("shared") == second);
assert(images[0].slots[0] == second + 8 && images[1].slots[0] == second + 8);
assert(images[0].slots[1] == first && images[2].slots[1] == first);
assert(images[0].slots[2] == 0);
uintptr_t p; assert(h_ro_LookupModuleSymbol(&p, &handles[0], "shared") == 0 && p == first);
assert(h_ro_LoadModule(&handles[0], raw[0], NULL, 32, 1) != 0);
unload(2);
assert((uint64_t)guest_sym("shared") == first && images[0].slots[0] == first + 8);
assert(h_ro_LookupModuleSymbol(&p, &handles[1], "shared") != 0);
images[2].value = 999; load(2);
assert(images[2].value == 123 && images[2].slots[0] == first + 8);
assert((uint64_t)guest_sym("shared") == first);
unload(1);
assert(images[0].slots[0] == second + 8 && images[0].slots[1] == 0 && images[2].slots[1] == 0);
unload(2);
assert(!guest_sym("shared") && images[0].slots[0] == (uint64_t)&missing + 8);
assert(inits == 3 && finis == 3);
memcpy(g_refused[0], g_mod[1].sha256, 32); g_nrefused = 1;
uint8_t nrr[0x380] = {0}; memcpy(nrr, "NRR0", 4); *(uint32_t *)(nrr + 0x340) = 0x350; *(uint32_t *)(nrr + 0x344) = 1;
memcpy(nrr + 0x350, g_mod[1].sha256, 32);
assert(h_ro_RegisterModuleInfo(NULL, nrr) != 0);
assert(h_ro_LoadModule(&handles[0], raw[0], NULL, 32, 1) != 0 && !g_mod[1].loaded);
for (int i = 0; i < 3; i++) { free(g_mod[i].symh); free(g_mod[i].pristine); }
puts("NRO relocation, load order, lifecycle, weak symbols and refusal: OK");
return 0;
}
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Collect the NRO modules a game registers (nn::ro): every SHA-256 its NRR files list (RomFS .nrr/*.nrr), found as loose files or inside the RomFS's archives.
Usage: nro_collect.py <RomFS> <out dir> [<nrr name>]
Writes <out dir>/<name>.nro for each registered NRO found and <out dir>/nro.tsv (name, SHA-256, size, NRR, where it was found); exits 1 when a registered hash is not found.
An online SDK (ONLINE_SDKS: Epic Online Services, by its export EOS_Platform_Create) is listed with the name 'refused' and not written: the host refuses to load it.
<nrr name> picks one NRR (default: every NRR; Super Smash Bros. Ultimate 13.0.5 ships 14, one per release, cross2_130004.nrr the newest, with the 98 current hashes).
The game passes nn::ro::LoadModule the NRO's bytes; the host finds the rewritten copy by that SHA-256 (hle_ro.c), so the names here are only labels: nro<index> in hash order.
Where: a loose file whose bytes at +0x10 are NRO0 (Hades and Hades II ship their EOS SDK so); the .nro files among the current files of an archive with an index (Super Smash Bros. Ultimate's data.arc,
read by dm/gfx/tools/smash_arc.py: 98 NROs, while the NRRs of every older release also register 666 superseded copies the archive still holds, which are left out); else a scan of the
other RomFS files for stored NROs and zstd frames that inflate to one (on data.arc: all 764 registered hashes in 7 minutes)."""
import hashlib, mmap, os, struct, sys
from multiprocessing import Pool
ZMAGIC = b'\x28\xb5\x2f\xfd'
def zstd_mod(): # compression.zstd from Python 3.14, its backport before (pip install backports.zstd), the same API
try: from compression import zstd
except ImportError:
try: from backports import zstd
except ImportError: sys.exit(f'[RED] Python {sys.version_info[0]}.{sys.version_info[1]} has no compression.zstd (3.14+): pip install backports.zstd')
return zstd
def nrr_hashes(romfs, only=None):
out = {}
for dp, _, fs in os.walk(romfs):
for f in sorted(fs):
if not f.endswith('.nrr') or (only and f != only): continue
d = open(os.path.join(dp, f), 'rb').read()
if d[:4] != b'NRR0': continue
off, cnt = struct.unpack_from('<II', d, 0x340)
for i in range(cnt): out.setdefault(d[off + 32 * i:off + 32 * i + 32], f)
return out
def nro_size(h): # an NRO header's total size, or 0 when the bytes are not one
if len(h) < 0x40 or h[0x10:0x14] != b'NRO0': return 0
n = struct.unpack_from('<I', h, 0x18)[0]
return n if 0x40 < n < 1 << 30 else 0
def frame(mm, p, zstd):
dec = zstd.ZstdDecompressor(); parts, q = [], p
while not dec.eof and q < len(mm) and q - p < (1 << 30):
parts.append(dec.decompress(mm[q:q + (8 << 20)])); q += 8 << 20
return b''.join(parts)
def scan(job):
"""NROs in [a, b) of one file: stored ones (NRO0 at +0x10) and zstd frames whose first block inflates to an NRO header. Returns [(sha256, path, offset, how, bytes)] of wanted ones"""
path, a, b, want = job
zstd = zstd_mod(); out = []
with open(path, 'rb') as f:
mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
p = mm.find(b'NRO0', a, b)
while p != -1:
s = p - 0x10; n = nro_size(mm[s:s + 0x40]) if s >= 0 else 0
if n and s + n <= len(mm):
body = mm[s:s + n]; h = hashlib.sha256(body).digest()
if h in want: out.append((h, path, s, 'stored', body))
p = mm.find(b'NRO0', p + 4, b)
p = mm.find(ZMAGIC, a, b)
while p != -1:
try:
head = zstd.ZstdDecompressor().decompress(mm[p:p + 0x48000], max_length=0x40)
if nro_size(head):
body = frame(mm, p, zstd); h = hashlib.sha256(body).digest()
if h in want: out.append((h, path, p, 'zstd', body))
except Exception:
pass
p = mm.find(ZMAGIC, p + 4, b)
mm.close()
return out
# Online SDKs the host never loads, by what the module exports, whatever the game: Epic Online Services (Hades and Hades II load rom:/nro/EOSSDK-Switch-Shipping.nro; Hades' main
# imports only EOS Auth, Connect and PlayerDataStorage, Epic-account cross-save the host cannot serve). Running it would at best fail its login, at worst wait on time-outs or contact
# Epic's servers with a console identity the host makes up; refused, the game plays offline with local saves, as before the host loaded NROs
ONLINE_SDKS = {'EOS_Platform_Create': 'Epic Online Services SDK'}
def online_sdk(body): # the online SDK an NRO is (by its exports), or None
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import nso_syms
try: names = {n for n, defined, _ in nso_syms.dynsyms(body) if defined}
except Exception: return None
return next((what for sym, what in ONLINE_SDKS.items() if sym in names), None)
def main():
romfs, out = sys.argv[1], sys.argv[2]
want = nrr_hashes(romfs, sys.argv[3] if len(sys.argv) > 3 else None)
if not want: sys.exit(f'no NRR file with hashes in {romfs}')
os.makedirs(out, exist_ok=True)
found, big, indexed = {}, [], 0
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'gfx', 'tools'))
import smash_arc
for dp, _, fs in os.walk(romfs):
for f in sorted(fs):
p = os.path.join(dp, f); sz = os.path.getsize(p)
if f.endswith('.nrr') or sz < 0x40: continue
with open(p, 'rb') as fh: head = fh.read(0x40)
if nro_size(head) == sz:
body = open(p, 'rb').read(); h = hashlib.sha256(body).digest()
if h in want: found[h] = (os.path.relpath(p, romfs), 'loose', body)
elif head[:8] == smash_arc.MAGIC:
# an archive with an index (Super Smash Bros. Ultimate's data.arc): the .nro files among its current files. Its superseded copies, which every older release's NRR still
# registers (764 hashes in 14 NRRs, 98 current), are never loaded and are left out
indexed += 1; nro = smash_arc.hash40('nro')
with open(p, 'rb') as fh, mmap.mmap(fh.fileno(), 0, access=mmap.ACCESS_READ) as m:
for e in smash_arc.files(p):
if e[4] != nro: continue
body = smash_arc.read(m, e); h = hashlib.sha256(body).digest()
if h in want: found[h] = (f'{os.path.relpath(p, romfs)}#{e[0]:010x}', 'index', body)
else: print(f'[RED] {os.path.relpath(p, romfs)}#{e[0]:010x}: an NRO that no NRR registers')
else: big.append((p, sz))
if indexed: # the NROs a game loads are the current files of its archives: the hashes not among them belong to superseded copies
print(f'{len(found)} NROs among the current files of {indexed} indexed archive(s); {len(want) - len(found)} registered hashes are superseded copies, left out')
want = {h: n for h, n in want.items() if h in found}
left = {h for h in want if h not in found}
if left and big:
jobs = []
for p, sz in big:
step = max(64 << 20, (sz + 47) // 48)
jobs += [(p, a, min(sz, a + step + 0x40), left) for a in range(0, sz, step)]
print(f'{len(want)} registered NROs, {len(found)} loose; scanning {len(big)} files ({sum(s for _, s in big) / 2**30:.1f} GB) for the other {len(left)}', flush=True)
with Pool(min(10, os.cpu_count() or 4)) as pool:
for r in pool.imap_unordered(scan, jobs):
for h, path, off, how, body in r:
found.setdefault(h, (f'{os.path.relpath(path, romfs)}+{off:#x}', how, body))
rows, i, refused = [], 0, 0
for h in sorted(found):
where, how, body = found[h]
what = online_sdk(body)
if what: # listed, not written: the host refuses to load it (hle_ro.c)
rows.append(f'refused\t{h.hex()}\t{len(body)}\t{want[h]}\t{how} {where} ({what})'); refused += 1; continue
name = f'nro{i}'; i += 1
open(os.path.join(out, name + '.nro'), 'wb').write(body)
rows.append(f'{name}\t{h.hex()}\t{len(body)}\t{want[h]}\t{how} {where}')
if refused: print(f'{refused} NRO(s) of an online SDK refused, not rewritten (listed as refused in nro.tsv)')
open(os.path.join(out, 'nro.tsv'), 'w').write('# name\tsha256\tsize\tnrr\twhere\n' + '\n'.join(rows) + '\n')
miss = [h.hex() for h in want if h not in found]
print(f'{len(found)} of {len(want)} registered NROs found ({sum(len(found[h][2]) for h in found) / 2**20:.1f} MB) → {out}')
if miss: print(f'[RED] {len(miss)} registered NROs not found, first {miss[0]}'); sys.exit(1)
if __name__ == '__main__': main()
+39 -5
View File
@@ -21,11 +21,18 @@ from nso_asm import * # the instruction encoders (B, ADRP, LDRX, ...), shared
src, outdir, NAME = sys.argv[1], sys.argv[2], sys.argv[3]
d = open(src, 'rb').read()
assert d[:4] == b'NSO0'
flags = struct.unpack_from('<I', d, 0xc)[0]
segs = [struct.unpack_from('<III', d, 0x10 + i * 0x10) for i in range(3)]
bss = struct.unpack_from('<I', d, 0x3c)[0]
csz = struct.unpack_from('<III', d, 0x60)
if d[0x10:0x14] == b'NRO0':
# An NRO (a module the game loads at run time with nn::ro, Super Smash Bros. Ultimate's fighter code): the file is the image itself, each segment stored at its memory offset
# and uncompressed; +0x20 text, +0x28 ro, +0x30 data as {offset, size}, +0x38 bss size, +0x40 the build ID. Rewritten as an NSO of the same layout would be
flags, csz = 0, [struct.unpack_from('<II', d, 0x20 + 8 * i)[1] for i in range(3)]
segs = [(o, o, sz) for o, sz in (struct.unpack_from('<II', d, 0x20 + 8 * i) for i in range(3))]
bss = struct.unpack_from('<I', d, 0x38)[0]
else:
assert d[:4] == b'NSO0'
flags = struct.unpack_from('<I', d, 0xc)[0]
segs = [struct.unpack_from('<III', d, 0x10 + i * 0x10) for i in range(3)]
bss = struct.unpack_from('<I', d, 0x3c)[0]
csz = struct.unpack_from('<III', d, 0x60)
# Every jump between the patch area and the code is a B (±128 MB). A patch area appended after the image (with bss) cannot reach the code when it would start beyond 112 MB (leaving 16 MB for the patch area): Metroid Dread's main has about 1.8 GB of bss (the engine's static heap); Engage's main starts its patch area at about 103 MB and keeps it after the image.
# The patch area then sits FRONT bytes before the module base (PATCH_OFF is negative), and the output file holds only text + ro + data (FILE_IMAGE_LEN) followed by the patch area; the loader supplies zero pages for bss and the key page (IMAGE_SIZE is the whole image).
# The key page stays after bss (the patch area reaches it through ADRP, ±4 GB). Modules below the limit produce output byte-identical to before
@@ -559,10 +566,21 @@ def jump_offsets(insl, j, copy_first=False, far_base=False):
else:
if lo is None and far_base: # nothing writes the base in the window
tb = table_base(insl, k, rb, far=True)
if tb is None: tb = copied_base(insl, k, rb) # built further back and copied on its way (below)
if tb is None or tb + 4 * n > len(IMG): return None
t = [tb + struct.unpack_from('<i', IMG, tb + 4 * i)[0] for i in range(n)]
fa, fb = insl[0].address, insl[-1].address + 4
return t if all(fa <= x < fb and x % 4 == 0 for x in t) else None
if page is None and far_base:
# The base was built further back and passed through other registers on its way (Super Smash Bros. Ultimate 13.0.5 main+0x2b74458: adrp x7 + add x7,x7,#0x40c
# 688 instructions before, then mov x30,x7 ... mov x7,x30 around a loop), so the walk back meets a mov. Follow the copies back to the function's start (copied_base);
# every target must be an instruction of this function. A function-wide search for the one adrp + add pair (table_cands) finds two for x7 there, the other a table of its own
# further on. Only the last attempt (far_base) gets here, i.e. only a site the rewriter could not handle before
tb = copied_base(insl, k, rb)
if tb is None or tb + 4 * n > len(IMG): return None
t = [tb + struct.unpack_from('<i', IMG, tb + 4 * i)[0] for i in range(n)]
fa, fb = insl[0].address, insl[-1].address + 4
return t if all(fa <= x < fb and x % 4 == 0 for x in t) else None
if page is None: return None
tb = page + lo
return [tb + struct.unpack_from('<i', IMG, tb + 4 * i)[0] for i in range(n)]
@@ -579,6 +597,22 @@ def table_base(insl, k, rb, far=False):
if lo is not None and p.mnemonic == 'adrp' and p.op_str.startswith(rb + ','): return p.operands[1].imm + lo
return None
return None
def copied_base(insl, k, rb):
"""Address rb holds at insl[k] when it was built by adrp + add and then only copied between registers (mov xD, xS): the writer chain is walked back in address order to the
function's start, switching to the source register at each copy, through no call while the register followed is caller-saved; None for any other writer"""
canon = {'lr': 'x30', 'fp': 'x29'} # capstone's reg_name calls x30 lr and x29 fp
lo, r = None, canon.get(rb, rb)
for p in reversed(insl[:k]):
if p.mnemonic in ('bl', 'blr') and num(r) <= 18: return None
_, w = p.regs_access()
# A write to wN zeroes the upper half of xN too; it is a clobber, never a copy of the 64-bit base.
if not any(canon.get(p.reg_name(x), p.reg_name(x)).replace('w', 'x', 1) == r for x in w): continue
ops = [canon.get(o, o) for o in p.op_str.split(', ')]
if lo is None and p.mnemonic == 'mov' and len(ops) == 2 and ops[0] == r and re.fullmatch(r'x\d+', ops[1]): r = ops[1]; continue
if lo is None and p.mnemonic == 'add' and len(ops) == 3 and ops[:2] == [r, r] and re.fullmatch(r'#(0x[0-9a-f]+|\d+)', ops[2]): lo = int(ops[2][1:], 0); continue
if lo is not None and p.mnemonic == 'adrp' and ops[0] == r: return p.operands[1].imm + lo
return None
return None
def jump_adr_offsets(insl, j):
"""Entries relative to an adr rather than to the table (Tears of the Kingdom main+0x393bb0): cmp w0,#5; b.hi; ...; adr x18,#L; ldrsw x1,[x17,x0,lsl #2];
add x18,x18,x1; br x18 → target = L + entry, with the table base x17 from adrp + add. Tried only where jump_offsets finds nothing. Returns all targets, or None if not recognized"""
+56
View File
@@ -0,0 +1,56 @@
"""Exercise the rewriter's copied-base recognition on real AArch64 instructions, without game files."""
import ast
from pathlib import Path
import re
import struct
import sys
import unittest
from capstone import Cs, CS_ARCH_ARM64, CS_MODE_ARM
TOOLS = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(TOOLS))
from nso_asm import ADRP, ADDI, BLR, NOP
# patch_nso.py is a command-line program whose top level loads and rewrites an image.
# Compile its actual recognition functions without executing that driver.
tree = ast.parse((TOOLS / 'patch_nso.py').read_text())
scope = {'re': re}
exec(compile(ast.Module(body=[n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in ('num', 'copied_base')], type_ignores=[]), str(TOOLS / 'patch_nso.py'), 'exec'), scope)
def mov(dst, src, wide=True): return (0xaa0003e0 if wide else 0x2a0003e0) | src << 16 | dst
class CopiedBase(unittest.TestCase):
def follow(self, words, reg='x7'):
md = Cs(CS_ARCH_ARM64, CS_MODE_ARM); md.detail = True
ins = list(md.disasm(struct.pack('<' + 'I' * len(words), *words), 0))
self.assertEqual(len(ins), len(words))
return scope['copied_base'](ins, len(ins), reg)
def base(self, reg=7): return [ADRP(reg, 0, 0x4000), ADDI(reg, reg, 0x40c)]
def test_far_copy_through_lr(self):
self.assertEqual(self.follow(self.base() + [mov(30, 7)] + [NOP] * 688 + [mov(7, 30)]), 0x440c)
def test_32_bit_writers_destroy_base(self):
for clobber in (mov(7, 0, False), 0x52800027, ADDI(7, 7, 1) & 0x7fffffff):
with self.subTest(clobber=hex(clobber)):
self.assertIsNone(self.follow(self.base() + [clobber, mov(30, 7), mov(7, 30)]))
self.assertIsNone(self.follow(self.base() + [mov(30, 7), mov(30, 0, False), mov(7, 30)]))
def test_calls_clobber_volatile_and_link_registers(self):
for call in (0x94000000, BLR(0)):
self.assertIsNone(self.follow(self.base() + [call]))
self.assertIsNone(self.follow(self.base() + [mov(30, 7), call, mov(7, 30)]))
self.assertEqual(self.follow(self.base(19) + [call, mov(7, 19)]), 0x440c)
def test_aliases_and_unrelated_writes(self):
self.assertEqual(self.follow(self.base(29) + [mov(7, 29)]), 0x440c)
self.assertEqual(self.follow(self.base(30), 'lr'), 0x440c)
self.assertEqual(self.follow(self.base() + [mov(8, 0, False)]), 0x440c)
self.assertIsNone(self.follow(self.base() + [mov(7, 0)]))
if __name__ == '__main__': unittest.main()
+66
View File
@@ -0,0 +1,66 @@
"""NRO collection/rewrite cache lifecycle, using synthetic manifests and a fake rewriter."""
import importlib.util
from pathlib import Path
import shutil
import sys
import tempfile
import types
import unittest
from unittest import mock
REPO = Path(__file__).resolve().parents[3]
spec = importlib.util.spec_from_file_location('build_ipa_from_nsp', REPO / 'scripts/build_ipa_from_nsp.py')
nsp = importlib.util.module_from_spec(spec); spec.loader.exec_module(nsp)
class NroPack(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp()); self.addCleanup(shutil.rmtree, self.tmp)
self.repo = self.tmp / 'repo'; self.exe = self.tmp / 'game/ExeFS'; self.rom = self.exe.parent / 'RomFS'
self.exe.mkdir(parents=True); self.rom.mkdir(); (self.exe / 'main').write_bytes(b'NSO0' + bytes(0x50))
(self.rom / 'game.nrr').write_bytes(b'registered')
for f in ('dm/tools/nro_collect.py', 'dm/tools/nso_syms.py', 'dm/tools/patch_nso.py', 'dm/tools/nso_code.py', 'dm/tools/nso_asm.py', 'dm/tools/sdk_patches.py', 'dm/gfx/tools/smash_arc.py'):
p = self.repo / f; p.parent.mkdir(parents=True, exist_ok=True); p.write_text('v1')
self.out = self.tmp / 'patched_test'; self.logs = self.tmp / 'logs'; self.logs.mkdir()
self.g = types.SimpleNamespace(words=lambda k: ['main'])
self.row = 'refused\t' + 'ab' * 32 + '\t128\tgame.nrr\tloose sdk.nro\n'
self.collections = self.rewrites = 0
for cm in (mock.patch.object(nsp, 'PROG', mock.Mock()), mock.patch.object(nsp, 'run', self.collect),
mock.patch.object(nsp.subprocess, 'Popen', self.rewrite_process), mock.patch.object(nsp.time, 'sleep')):
cm.__enter__(); self.addCleanup(cm.__exit__, None, None, None)
def collect(self, cmd, log, **kwargs):
self.collections += 1; dst = Path(cmd[3]); dst.mkdir(parents=True)
(dst / 'nro.tsv').write_text('# name\tsha256\tsize\tnrr\twhere\n' + self.row)
def rewrite_process(self, cmd, **kwargs):
self.rewrites += 1; (Path(cmd[3]) / f'{cmd[4]}.patched.bin').write_bytes(b'rewritten')
return types.SimpleNamespace(poll=lambda: 0, returncode=0)
def build(self): nsp.rewrite(sys.executable, self.repo, self.exe, self.out, {}, self.logs, self.g)
def test_refused_only_manifest_survives_and_invalidates_rewrite(self):
self.build()
self.assertIn(self.row, (self.out / 'nro.tsv').read_text())
self.build(); self.assertEqual((self.collections, self.rewrites), (1, 1))
self.row = self.row.replace('ab' * 32, 'cd' * 32)
(self.rom / 'game.nrr').write_bytes(b'new registration')
self.build()
self.assertIn(self.row, (self.out / 'nro.tsv').read_text())
self.assertEqual((self.collections, self.rewrites), (2, 2))
def test_parser_changes_recollect(self):
self.build()
for i, f in enumerate(('dm/gfx/tools/smash_arc.py', 'dm/tools/nso_syms.py'), 2):
(self.repo / f).write_text('v2'); self.build()
self.assertEqual(self.collections, i)
self.assertEqual(self.rewrites, 1) # identical collected bytes do not need another rewrite
def test_removed_registration_drops_stale_manifest(self):
self.build(); (self.rom / 'game.nrr').unlink(); self.build()
self.assertFalse((self.out / 'nro.tsv').exists())
self.assertFalse((self.tmp / 'nro_test').exists())
self.assertEqual(self.rewrites, 2)
if __name__ == '__main__': unittest.main()
+5 -1
View File
@@ -41,6 +41,7 @@ Each entry: symptom → root cause → fix (where the fix lives). Check these fi
- **`jump table <addr>` assertion on a byte table whose `add` is far from the `br`** (Octopath Traveler 0 `main+0x37f9bc8`: `adr x18,#L; ldrb w0,[x17,x15]; add x18,x18,x0,lsl #2`, then 17 register moves before `br x18`) → the byte / halfword search looked 16 instructions back. `jump_bytes_far` looks 32 back, requires that nothing writes x18 between the `add` and the `br`, and runs only when every other form has failed, so sites that rewrote before come out the same. One of the 12 `br x18` sites in this `main`; a census of all of them first (each shape within 16 / 32 / 48 instructions) saves a 12-minute rewrite per site.
- **`jump table <addr>` assertion where the index is a copy made before the check** (DRAGON QUEST XI S `main+0x2dd8cd0`: `mov w0,w18`, then 41 instructions holding two other jump tables on `x0`, then `cmp w18,#4; b.hi; ldrsw x18,[x11,x0,lsl #2]; add x18,x18,x11; br x18`) → every other form follows copies made after the bounds check. `check_before_copy` accepts the `cmp` and `b.hi` right before the load when a `mov wI, wS` up to 64 instructions back is the last writer of both registers; capstone lists a `cmp`'s first operand as written, which the search skips. It runs only when every other form has failed, so modules that rewrote before come out the same. Before fixing one site, a census of all of them saves a run per site: a copy of `patch_nso.py` whose assert prints and continues found this one alone among the 25,508 `x18` sites. That `main` has no unwind entries for its first 70 MB, so the rewriter disassembles them as one function and takes 6 minutes.
- **`jump table <addr>` assertion on an offset table whose base was set far back** (ASTRAL CHAIN 1.0.1 `main+0x6db4a0`, SDK 6: `cmp w18,#5; b.hi; ldrsw x18,[x24,x18,lsl #2]; add x18,x18,x24; br x18`) → the function's first block loads six table bases with `adrp` + `add` into registers that its loop keeps (`x24` 441 instructions before this load), and a later block one more (`x3`, 549 before the load at `+0x6db70c`); `jump_offsets` follows the base's writer chain 256 instructions back. Where nothing writes the base in those 256, its last attempt (`far_base`) follows the chain to the function's start (`table_base` `far`), through no call when the base is caller-saved, and accepts the table only when every target is an instruction of the function. The function-wide search that `jump_adr_offsets` uses for a hoisted base (the one `adrp` + `add` pair that fits) found two for `x3`, the other a local table of a `br x17` further on. Three of that `main`'s five `br x18` sites need it; it runs only where every other form has failed, so modules that rewrote before come out the same (Tears of the Kingdom, Age of Calamity, Cuphead, DARK SOULS: REMASTERED, Luigi's Mansion 3 and Metroid Dread `main`s: byte-identical).
- **The far base passed through other registers** (Super Smash Bros. Ultimate 13.0.5 `main+0x2b74458`, SDK 8.2.1: `ldrb w18,[x14]; sub w18,w18,#1; cmp w18,#6; b.hi; ldrsw x18,[x7,x18,lsl #2]; add x18,x18,x7; br x18`) → `x7` is built by `adrp` + `add` 688 instructions before, then parked in `x30` around a loop (`mov x30,x7` … `mov x7,x30`), so the far chain meets a `mov` and stops; the function-wide search finds a second `adrp x7` + `add` pair (another table further on) whose seven targets also lie in the function. The last attempt now follows register copies back to the `adrp` + `add` (`copied_base`, through no call while the register followed is caller-saved; the same target check). It was that `main`'s only unhandled site; it runs only where the rewriter asserted before, and Mario Kart 8 Deluxe's three modules and Smash's `sdk` and movie module come out byte-identical.
- **`B()` assertion at a spin wait: a branch into the patch area beyond the ±128 MB a `b` reaches** (Animal Crossing: New Horizons 3.0.3 `main`, 63 MB of text) → each halfword jump table (`ldrh wE, [xT, xI, lsl #1]; …; adr x18, #L; add x18, x18, xE, lsl #2; br x18`) got a landing pad for every one of the 65,536 values an entry can hold (768 KB a table), whatever the switch's size: that `main` has 51 of them, 40 MB, and the spin-wait stubs placed after them were out of reach. `half_count` reads the table as `jump_adr_offsets` does (the index's bounds check, following register copies, then the base from `adrp` + `add`, or the one candidate in the function whose every target is an instruction of it) and pads only up to its largest entry; a table it cannot read keeps all 65,536 (7 of the 51). The patch area went from 42 MB to 7.9 MB. In the other titles' modules only such tables changed: one in the `sdk` of several SDK 11–20 builds (936 to 1,320 pads), 1 to 6 in some `main`s (Tears of the Kingdom, Breath of the Wild, Silksong, Monster Hunter Rise, Absolum, STEINS;GATE RE:BOOT) and one in some movie and middleware `subsdk`s.
- **`observation gate neither at a function entry nor after a bl`** (DRAGON QUEST VII Reimagined: the gate on `mov x0, x19` at `main+0x32758e4`, after a cut `bl` and an `adrp`) → the host may change x9–x17 in a gate, so a gate sat only where they carry nothing. A site is also accepted when the straight-line code from it to the next `bl` reads none of x9–x17 before writing it (`scratch_dead`); it is tried only after the other two rules fail. The gate table takes the original instruction as a word value (`aa1303e0`), the cut table as bytes (`e00313aa`).
- **`jump table <addr>` assertion on an ordinary byte table near the start of the text** (FINAL FANTASY RESONANCE 1.0.1 `main`, Unreal Engine 5.6.1: `main+0x219234`, `adr x18; ldrb w0,[x17,x16]; add x18,x18,x0,lsl #2; br x18`) → the module has no unwind entry before `main+0x6a75d4`, and `func_of` made every site before the first FDE a function of one instruction, so no form saw the `adr` in front of the `br`. The text before the first FDE is now one function, from the end of the data block before it (or the text's start), as a gap between two FDEs already was. Of the 120 modules of 33 games on hand, only DRAGON QUEST VII Reimagined's `main` has other `x18` sites there (110 before `+0xb310`, none in a window or a `br x18`), and its rewrite is byte-identical.
@@ -72,6 +73,7 @@ Each entry: symptom → root cause → fix (where the fix lives). Check these fi
- **Unity 6 takes its memory with no StandardAllocator and no heap** (Hollow Knight: Silksong, Unity 6000.0.50f1 on SDK 20.5.8) → `nninitStartup` (`main+0x118cb70`) only calls `QueryMemoryInfo` and `SetMemoryAllocatorForThreadLocal`; Unity's own allocator reserves address regions with `nn::os::AllocateAddressRegion` and commits pages with `AllocateMemoryPages`, so nothing aborts at startup as with 2022.3. `main` imports no `SetMemoryHeapSize`, and the provisional profile took that for a game living in its bss (a 1 GB block, 256 MB alias region) while Unity maps 1.5 GB in the first room; `profile_guess.py` now gives every game with an NPDM system resource the 5 GB block (Absolum and Pokémon Legends: Z-A, heapless too, came out right with it). The node area still sits at the start of the alias region: without the cut the high-water mark was +1.00 GB at `nninitStartup` and +3.00 GB in the first room, with it (`sdk+0x145bfc` / `+0x145c68`) +2.26 GB.
- **`nn::diag` abort from `StandardAllocator::Initialize` long after `nninitStartup`, right after the first Vulkan query pool** (Super Mario Galaxy 2, SDK 18.3.5: `sdk+0x12dad0` ← `main+0x62c52c`) → the movie player initializes its own allocator with virtual address memory, `Initialize(nullptr, 384 MB)` (`main+0x62c280`); Galaxy, on the same SDK, allocates that block from its main allocator first. The guessed 256 MB alias region holds no region after the 1 GB node area. The same node area cut for that `sdk` build (`sdk+0x13be4c` / `+0x13bee0`) and a 512 MB alias region: the region is 32 MB aligned, so it starts 64 MB in.
- **`nn::diag` abort at `sdk+0x12dad0` in `nninitStartup`, called from `main+0x756310`** (Absolum, .NET NativeAOT on SDK 18.3.2) → malloc's `StandardAllocator::Initialize(nullptr, 0xa00000000)`: 40 GB of address space (`main+0x28bff4`). To see what such a game really needs, boot it once on the Mac with a 64 GB alias region (`mem_reserve 0x1030000000`, `mem_alias 0x1008000000`) and probe `VammManager::AllocateAddressRegion`'s success path (`sdk+0x13bf8c`: x21 size, x22 address) with `DM_SVC=1`'s map log. Absolum's regions: malloc's (2.19 GB used into the first level, 1.75 GB of it FNA3D's GPU heap, allocated whole at `FNA3D_CreateDevice`), the .NET GC's region range (3.75 GB) and a few small ones. The GC sizes its range as 5 × its hard limit (768 MB = 75% of the 1 GB its `GetPhysicalMemoryLimit`, `main+0x28bffc`, reports), and it reads `DOTNET_GCRegionRange` through `getenv` first. The host now answers `getenv` from the profile key `game_env` (`hle_os.c`; every other name goes to the SDK's own `getenv`, an empty environment), which sets 1.5 GB. The GC then uses its range from both ends, 756 MB from the start and 748 MB from the end, where it used only the first 1.14 GB of 3.75. A cut on `main` gives malloc 2.5 GB. One more thing kept it from fitting: SDK 18's `AllocateAddressRegion` aligns a region of 1 GB or more to 1 GB in absolute address (`sdk+0x13c03c`; 32 MB from 32 MB up). The two large regions, with a 4 KB one between them, lost up to 1 GB each to alignment, depending on where `mmap` put the guest block. A cut on that build aligns them to 32 MB as well, beside the node-area cut. The regions then end 4.13 GB into the 4.375 GB alias region of Engage's 5 GB block.
- **The game aborts in its static initialisers, before `nnMain`, when its own 62 GB address region fails** (Super Smash Bros. Ultimate 13.0.5, SDK 8.2.1: `main+0x37c9bd0`, `AllocateAddressRegion(&p, 0xf80000000)` at `main+0x37c9a5c`, the allocator behind `nu::VirtualAllocHook`) → a 64 KB page allocator whose own tables fill the region's last 14 MB, so the size, the header offset, the page count and three region-end bounds all follow from 62 GB: the profile's `patches.py` cuts all 11 instructions to a 3 GB region. 3 GB still did not fit: SDK 8 has the same 1 GB node area and 1 GB alignment of regions of 1 GB and more as the newer SDKs (constructor `sdk+0x336b40`, `orr w9, wzr, #0x3fe00000` at `+0x336b50` / `+0x336bf0`; alignment at `+0x336d5c`), cut in `sdk_patches.py` as DRAGON QUEST XI S's SDK 7.3.2 build.
- **Large resources silently skipped, later a null deref where the resource is used** → the game reserves "virtual address memory" (Mario: 1.75 GB in `HeapDef.byml`) and maps it into the alias region with `MapPhysicalMemory` on demand; the alias region left after the node area was too small. Also visible as `SetHeapSize` asking for much less than the pool. → repartition the guest block (`hle_svc.c` `mem_init`): Mario uses alias 2.875 GB, stacks 256 MB, heap 1.625 GB (it asks for 1.47 GB), 5 GB in total. Measure before sizing (`DM_SVC=1`: the `SetHeapSize` request and the highest `MapPhysicalMemory` address). On iOS without the extended-virtual-addressing entitlement a single 6 GB reservation succeeds and 7 GB fails, but Metal and the rest of the process need address space too; 5 GB is the total proven on a phone (D2R).
- **`nn::diag` abort at `sdk+0x11ff34` (`StandardAllocator::Initialize`) in `nninitStartup`, SDK 15.3.2** (Paper Mario: The Thousand-Year Door, called from `main+0x31370c`) → the game's malloc is a StandardAllocator over 62 GB of address space (`Initialize(nullptr, 0xf80000000, false)`, `main+0x3136e8`), out of which it cuts 256 MB, 16 MB and 1 MB allocators. Booted once with a 64 GB alias region, the region came 0.69 GB past the 1 GB node area, at the next 1 GB boundary: this `sdk` build (Celeste's, which has no virtual address memory) aligns regions of 1 GB and more to 1 GB, as Absolum's did. Absolum's three cuts on it (the AddressRegionManager constructor `sdk+0x12e0e0`: `+0x12e0f0` / `+0x12e184`; `AllocateAddressRegion`'s alignment at `+0x12e2e0`) and a cut on `main` to 4 GB of address space, as Pokémon Legends: Z-A's, put the region at alias +62 MB to +4.06 GB in Engage's 5 GB block. The allocator maps 1.5 GB at boot and 2.0 GB by the arrival in Rogueport.
- **`MapPhysicalMemory` faults on part of a page** → round to 16 KB host pages.
@@ -107,13 +109,15 @@ Each entry: symptom → root cause → fix (where the fix lives). Check these fi
- **Absolum (SDK 18.3.2, FNA on .NET NativeAOT with SDL3), one boot each**:
- **SIGSEGV in `vi::CreateLayer(Layer**, Display*, int, int)`** (`sdk+0x3f9bc4`, from SDL3's Switch video driver at `main+0x239304`) → the overload with the layer's size; the host had only the two-argument one. It returns the static layer (`hle_vi.c`). The survey's import table listed every vi call as unhooked: `nso_syms.py host_hooks` read only `dm/host`, and the vi hooks are in `dm/gfx` (it reads `dm/gfx` and `dm/gfx/nvn` now).
- **AudioIn** (SDL3's audio driver lists the input devices when its audio starts) → the host has no microphone. `ListAudioIns` reports none, both opens fail, and the calls that need an open AudioIn answer without touching the game's object (`hle_audio.c`).
- **Epic Online Services in an NRO** (`rom:/nro/EOSSDK-Switch-Shipping.nro`, loaded with `ro::LoadModule`; `main` imports the `EOS_*` functions) → the host loads no NRO, so the imports stay `UNIMPL` stubs and `EOS_Platform_Create` hands back a garbage handle. The game checks it (`[EOS] Failed to create platform`) and plays offline.
- **Epic Online Services in an NRO** (`rom:/nro/EOSSDK-Switch-Shipping.nro`, loaded with `ro::LoadModule`; `main` imports the `EOS_*` functions) → the host loaded no NRO, so the imports stayed `UNIMPL` stubs and `EOS_Platform_Create` handed back a garbage handle. The game checks it (`[EOS] Failed to create platform`) and plays offline. The host now loads NROs (below), but never an online SDK, whatever the game: an NRO that exports `EOS_Platform_Create` is left out of the pack (`dm/tools/nro_collect.py` `ONLINE_SDKS`, a `refused` row in `nro.tsv`) and `ro::LoadModule` refuses it with one log line (`refused …: an online SDK (Epic Online Services), not loaded`), also by the export when an older `nro.tsv` has it. An NRR that registers only such modules fails `RegisterModuleInfo`, as every registration did before the host loaded NROs, so the game takes the path it took then: Hades never asks for the module (checked on 1.0.38233: `refused an NRR that registers only online SDKs (1 hashes)`, no `LoadModule`, its frames as before). Hades' `main` imports only EOS Auth, Connect and PlayerDataStorage, Epic-account cross-save the host cannot serve; the real SDK would at best fail its login and at worst wait on time-outs or contact Epic's servers, with an unknown risk to the player's account. So both games behave as before: offline, local saves. Open question for the maintainer: always load, refuse online SDKs (as now), or decide per profile.
- **The modules**: `main` imports EGL (SDL3's GL support) and `vkGetInstanceProcAddr` (its Vulkan support), but FNA3D renders with NVN (`FNA3D_Driver_NVN.cpp`). NVIDIA's OpenGL and Vulkan drivers (`subsdk1`, `subsdk0`) program the GPU through NvRm, which the host does not have, so they are never loaded (`profile_guess.py` `GPU_DRIVERS`); the EGL imports stay unbound stubs that nothing calls.
- **A library's `free` rejects a pointer (`CentralHeap::Free` → `NotNMallocPtrError`)** → the game's `main` defines its own `operator new` / `delete` (Unity's memory manager) and `sdk` has a copy forwarding to `malloc` / `free`. Symbols are resolved globally in load order (the first module's definition wins, also for a module's references to its own exports); only local / non-default-visibility symbols bind to the module itself (`loader.c`). The loader logs how many references bind to an earlier module.
- **`[RED] fs ReadFile into … failed at +0 (EFAULT): VM region … protection 1`, now and then** (Monster Train 2 at 60 fps: two runs in three as the route map loads, never at 30 fps) → the destination was texture pool memory, which the dirty-page tracker write-protects after each upload (`nvn_dirty.c`). `ReadFile` lifts the protection first (`dm_guest_write_prepare`), but the graphics layer can protect the pages again before `pread` writes them, and a system call gets EFAULT where a plain write would fault into the tracker's handler; at 60 fps uploads come twice as often. On EFAULT the host now prepares the rest of the buffer again (unprotect, mark dirty, so the texture is uploaded again) and reads on, up to eight times (`h_fs_ReadFile`; the first case is logged, `[fs] ReadFile … prepared again`). Four runs at 60 fps: one retry, no failed read.
- **IL2CPP "Could not open …/global-metadata.dat" (then a crash in its logger)** → IL2CPP checks `nn::fs::IsMounted("rom")` before opening; the SDK's own `IsMounted` reads a mount table the host never fills → answer from the host's mount table (`hle_fs.c`).
- **Crash in `nn::ro::InitializeWithPortName`** → `nn::ro` connects to `ldr:ro`; take over `Initialize` and answer `LookupSymbol` from the loaded modules (`hle_ro.c`). Check the mangled names against the new title's imports: SDK 19's `LoadModule` takes one more bool (`_ZN2nn2ro10LoadModuleEPNS0_6ModuleEPKvPvmib`, Hades II), so the hook for the older form did not catch it and the SDK's own code would have run.
- **A jump to pc=0 right after the NVN bootstrap loader** (Super Smash Bros. Ultimate 13.0.5: `lr=main+0x37fe930`, `[RED] NVN address lookup of an unknown name: nvnSetExternalDebuggerEnabledNVX`) → the game fetches an NVX extension of the SDK 8 driver by name and calls it without checking the pointer. Unknown names return null, as the real driver does for a name it lacks; this one the driver has, so it joins `dm/gfx/nvn/names.txt` (thunks regenerated with `gen_protos.py`; no prototype in `nvn.h`, so `NVN_NOPROTO`) as a no-op.
- **The frame loop runs, but the game waits for code it loads itself** (Super Smash Bros. Ultimate 13.0.5: `ro::RegisterModuleInfo` / `ro::GetBufferSize` red lines; `main` imports 1,045 `lua2cpp::create_agent_*` functions, global, that no ExeFS module defines, bound to stubs at start) → the fighters' code is 98 NROs inside `data.arc`, registered by `.nrr/cross2_*.nrr`. The SDK's own `LoadModule` (SDK 8.2.1 `LoadModuleInternal`, `sdk+0x347460`) uses the service only to map the image, then zeroes bss, relocates through rtld's module lists, calls the module's init and binds the variables of the modules loaded before; the host replaces rtld, so it takes over the whole call. The NROs are collected at pack time by the SHA-256s the NRRs list (`dm/tools/nro_collect.py`: loose files, the current files of an indexed archive, or a scan for stored and zstd-framed NROs; Smash's NRRs of older releases also register 666 superseded copies, which `data.arc` still holds and which are left out), rewritten like any module (`patch_nso.py` reads NROs: the flat image, segments at +0x20, build ID at +0x40) and listed in `<patched dir>/nro.tsv`. The host maps them all at start and keeps them out of symbol lookups; `LoadModule` finds one by the image's SHA-256, restores its data and bss (after the first load), relocates it against the modules loaded then in load order, binds the earlier modules' symbol relocations it defines (main's `lua2cpp` stubs), fills `nn::ro::Module` as the SDK does (+0 module, +8 state 1, +0x10 image, +0x18 buffer) and calls its `DT_INIT`; `UnloadModule` calls `DT_FINI` and binds every relocation that pointed into it again (`loader.c` "NRO modules", `hle_ro.c`). `GetBufferSize` returns the bss size from header +0x38 as the SDK does. The startup init pass must skip NROs (their init ran unrelocated and crashed). Smash loads six NROs in its first 30 s and calls none of the stubs; only data is written at a load, so it works with code embedded in the iOS executable once the NROs are embedded too.
- **SDK 12 names** → older SDKs export other names for the same calls (`ListAudioDeviceName` / `GetActiveAudioDeviceName`, singular `hid::GetNpadState`, `OpenDefaultAudioOut`); hook both generations.
- **SDK 11 names** (Metroid Dread) → `fs::CommitSaveData(mount)` is the older name of `fs::Commit`; `fs::DeleteDirectory` (an empty directory) was missing too.
- **`fs::Commit(const char* const*, int)`, the mount-list overload** (Animal Crossing: New Horizons, PR #25) → the SDK's own code opened fsp-srv and aborted, leaving the save writes uncommitted. The host commits the list as one transaction when every name is the same host save; cache and temporary storage in the list are skipped (their writes are already on disk, as for the single-name `Commit`), and a list of two separate saves is refused without committing either.
+4
View File
@@ -17,6 +17,10 @@ x0=… x28=…
- When the frame-pointer backtrace is empty, the stack scan's negative offsets (below `sp`) show the return addresses of the calls the crashing function made last.
- `[dm] *xN:` lines show the first six words each register points at (read safely, so a bad pointer is skipped). A word inside a module prints as `main+…`: the first word of an object is its vtable, which names its class, and comparing it with the class's own vtable shows whether the object was destroyed (a base-class vtable) or rebuilt.
## Pipeline compile waits
Render-pipeline lookahead runs after the demanded pipeline is available and cached. It admits at most two predictions per scan and at most two scheduled or running predictions overall, so speculative attachment variants cannot create an unbounded compiler backlog ahead of real draws. Exact-key results are still recorded only when used; a full pending table may discard completed unused results, never running jobs. The independent startup warmer is not subject to this limit. A new pipeline can still stall on its intrinsic first-use compilation cost; compare pipeline-miss timings and live stacks with asset reads before attributing a hitch to loading. `dm/gfx/tests/t_pso_pending.m` exercises the queue and demand-before-prediction ordering with a gated fake compiler.
## Ghidra / objdump
```bash
+4
View File
@@ -65,6 +65,8 @@ What the script does:
A repeat build skips work whose inputs have not changed. The extraction is kept (`extract/`). The DLC, the rewrite, the shader split and the iOS (or Vulkan) shaders each write a key beside their output: a sha256 of their inputs (the game files, the tools, the profile, Xcode's and spirv-cross's versions; for the DLC, the DLC NSPs' names, sizes and times and nx_extract's sources). When the key matches, the stage line says `reused (inputs unchanged)` and the step does not run. Any change to a tool or the profile runs the step again; deleting the `.key` file forces it. The iOS shaders' inputs are the shader package's content and only the tools `dmsh_build.py` loads (the modules it imports, at any depth): a split that runs again because a reader for another engine changed (a pull that touches `shader_split.py` or `unityfs.py`) writes the same package, and the iOS shaders stay reused.
The rewrite also collects NRO modules registered by the game's NRR files, including modules inside supported archives (`nro_collect.py`). On the Mac, the host maps the rewritten modules at startup and activates them on `nn::ro::LoadModule`, with symbol precedence following activation order; unloading and reloading restores their initial data and bss. `DM_NRO=0` disables this loading. Epic Online Services modules are identified by their exports and refused, preserving offline play. Collection reuse includes the archive reader and symbol parser; the rewrite key includes `nro.tsv`, even when it lists only refused modules. Executable NRO embedding on iOS is not implemented yet. The synthetic loader test (`make -C dm/host/tests GAME=<id> nro`) covers load order, rebinding, weak symbols, init/fini, data reset and refusal without game files.
While it runs, the console shows one line per stage: building nx_extract, extracting each version, the DLC, the rewrite, the run-time shader capture (only for such profiles), the shader split, the iOS shaders, the app build and packing the IPA. Each line shows `[k/n]`, the stage and its current step. Where the work can be counted, it adds a bar with done/total, percent, elapsed time and ETA. Counted work is bytes extracted, modules rewritten, shaders (cache check, translation, compile), files compiled by Xcode, and bytes packed into the IPA. Elsewhere a spinner shows the elapsed time. A total marked `~` is an estimate. A version's first extraction is estimated from the NSPs' size, and later extractions use the previous run's exact size. The IPA is estimated from the previous IPA, or from the files' size before compression. In a terminal or in Spyder the line is redrawn in place. When the stage ends, it becomes a `✓` line with the elapsed time and a summary:
```
@@ -197,6 +199,8 @@ The lines name shaders by content key and hold no compiled code and nothing pers
Record on the game version you ship. At launch the log names each list it read and its line count (`pipeline prewarm list …: N lines`), or says that `pso_user.tsv` does not exist yet.
During rendering, a pipeline miss compiles and caches the demanded pipeline before scanning subsequent draws for speculative work. The scan admits at most two new jobs, with at most two speculative compilations outstanding; completed predictions remain available by exact key. This limit does not apply to the startup warmer. `t_pso_pending` checks scheduling and ownership with a gated compiler; it does not establish that the two-job budget is optimal on every device or remove the cost of compiling an unseen pipeline.
## Power
The game's own work and its frame rate stay as they are; the host avoids work of its own that the Switch never did. Every 300 frames the log has a `power` line (CPU energy in watts and per frame, CPU time, wakeups, thermal state, the threads that yield most) and a `residency set` line; [05](agents/05-debugging.md) explains them.
+48 -22
View File
@@ -533,9 +533,11 @@ def survey(nsp_dir, app, fam, keys, repo, py, env):
major = {v.split('_')[0] for v in F['sdkver']}
if not major: notes.append('no SDK version string found')
elif major != {'16'}: notes.append(f'SDK {"/".join(sorted(major))}, not Mario\'s 16: NVN object sizes, API version and service layouts differ by SDK (the host was converted from D2R\'s SDK 12 to SDK 16 sizes); check them one by one per docs/agents/03')
if any(l.split()[:1] == ['nn::ro'] for l in R): # hle_ro.c answers nn::ro as if no NRO were loaded: enough unless the game ships NRO modules
nro = rom['exts']['.nro']
notes.append(f'imports nn::ro (loads NRO modules at run time): the RomFS has {nro} .nro files; the host loads none (hle_ro.c)' + (', so their code never runs' if nro else ', which is enough'))
if any(l.split()[:1] == ['nn::ro'] for l in R):
nro, nrr = rom['exts']['.nro'], rom['exts']['.nrr']
# NRR files register NROs by hash; with none loose the NROs are inside an archive (Super Smash Bros. Ultimate: 14 NRRs, 98 NROs in data.arc, whose exports main imports)
notes.append(f'imports nn::ro (loads NRO modules at run time): the RomFS has {nro} .nro files and {nrr} .nrr files; registered NROs are collected and rewritten for the Mac host'
+ (' (look inside archives for the registered modules)' if nrr and not nro else '') + '; iOS NRO embedding is not implemented yet')
if fam['dlc']: notes.append('DLC present: it is extracted into the IPA and the host reports the indices as owned and mounts them; check that the host hooks every aoc / fs add-on content call main imports')
R += ['', '## What it means for a port', ''] + [f'- {n}' for n in notes or ['—']]
R += ['', '## Provisional profile keys (dm/games/profile_guess.py)', '', 'What the game files decide; mem_used follows the rewrite, and a game with virtual address memory that sets a heap gets a heap check on the Mac (scripts/build_ipa_from_nsp.py heap_check).', '', '```']
@@ -778,41 +780,65 @@ def patch_files(repo, exe_dir, mods):
return [p for p in [repo / 'dm/tools/sdk_patches.py'] + sorted((repo / 'dm/games').glob('*/patches.py')) if any(i in p.read_text(errors='replace') for i in ids)]
def collect_nros(py, repo, romfs, dst, env, logs):
"""The NROs a game registers in NRR files (RomFS *.nrr) and loads at run time with nn::ro, found by their SHA-256 as loose files or inside the RomFS's archives (dm/tools/nro_collect.py):
{module name: NRO path}, {} for a game without NRRs. The host maps their rewritten copies at start and loads one when the game asks (loader.c "NRO modules"; Super Smash Bros. Ultimate:
98 in data.arc). Reused while the NRRs, RomFS tree and collection tools are unchanged"""
nrrs = sorted(p for p in romfs.rglob('*.nrr') if p.is_file()) # Super Smash Bros. Ultimate keeps them in a folder named .nrr
kf = dst.with_name(dst.name + '.key')
if not nrrs:
rmtree(dst); rmtree(kf) # an update can remove its NRRs: do not carry the previous manifest into the rewrite
return {}
key = fingerprint(('files', nrrs + [repo / f for f in ('dm/tools/nro_collect.py', 'dm/tools/nso_syms.py', 'dm/gfx/tools/smash_arc.py')]), ('tree', romfs))
if not ((dst / 'nro.tsv').is_file() and reused(kf, key) is not None):
tmp = dst.with_name(dst.name + '.partial'); rmtree(tmp)
PROG.set(step=f'finding the NROs {len(nrrs)} NRR files register')
run([py, repo / 'dm/tools/nro_collect.py', romfs, tmp], logs / 'nro_collect.log', env=env)
rmtree(kf); swap_in(tmp, dst); kf.write_text(f'{key}\n')
# rows named 'refused' are online SDKs the host never loads (Epic Online Services): listed for the host's log, not rewritten
return {l.split('\t')[0]: dst / (l.split('\t')[0] + '.nro') for l in (dst / 'nro.tsv').read_text().splitlines() if l and not l.startswith('#') and not l.startswith('refused\t')}
def rewrite(py, repo, exe_dir, out, env, logs, g):
"""patch_nso.py on every code module at once (each writes its own <module>.* files, all the same build_src.sha256 line), each with its log.
Reused when the modules, patch_nso.py (with nso_code.py, nso_asm.py and the patch files of their builds), its switch and capstone are as at the last successful rewrite
(Monster Hunter Rise's main alone takes 5.5 min)"""
"""patch_nso.py on every code module (each writes its own <module>.* files, all the same build_src.sha256 line), each with its log, as many at once as the Mac has cores.
The modules are the profile's and the NROs the game registers (collect_nros; nro.tsv beside them lists those). Reused when the modules, patch_nso.py (with nso_code.py, nso_asm.py
and the patch files of their builds), its switch and capstone are as at the last successful rewrite (Monster Hunter Rise's main alone takes 5.5 min)"""
import capstone
mods = g.words('modules')
PROG.stage('rewrite', step='checking the inputs')
nro_dir = out.with_name('nro_' + out.name.removeprefix('patched_'))
nros = collect_nros(py, repo, exe_dir.parent / 'RomFS', nro_dir, env, logs)
manifest = nro_dir / 'nro.tsv'
src = {m: exe_dir / m for m in mods} | nros
names = list(src)
kf = out.with_name(out.name + '.key')
key = fingerprint(('files', [repo / 'dm/tools' / f for f in ('patch_nso.py', 'nso_code.py', 'nso_asm.py')] + patch_files(repo, exe_dir, mods) + [exe_dir / m for m in mods]), mods, capstone.__version__,
key = fingerprint(('files', [repo / 'dm/tools' / f for f in ('patch_nso.py', 'nso_code.py', 'nso_asm.py')] + patch_files(repo, exe_dir, mods) + list(src.values()) + ([manifest] if manifest.is_file() else [])), names, capstone.__version__,
{k: env.get(k) for k in ('DM_PATCH_FRONT',)})
if all((out / f'{m}.patched.bin').is_file() for m in mods) and reused(kf, key) is not None:
PROG.end(f'{", ".join(mods)}, reused (inputs unchanged)'); return
if all((out / f'{m}.patched.bin').is_file() for m in names) and reused(kf, key) is not None:
PROG.end(f'{", ".join(mods)}{f" and {len(nros)} NROs" if nros else ""}, reused (inputs unchanged)'); return
tmp = out.with_name(out.name + '.partial'); rmtree(tmp); tmp.mkdir(parents=True); logs.mkdir(parents=True, exist_ok=True)
PROG.set(step=', '.join(mods), done=0, total=len(mods), unit='modules')
procs, logf = {}, {}
if manifest.is_file(): shutil.copyfile(manifest, tmp / 'nro.tsv') # refused-only manifests preserve the game's offline registration path too
PROG.set(step=', '.join(mods) + (f' and {len(nros)} NROs' if nros else ''), done=0, total=len(names), unit='modules')
procs, logf, todo, cores = {}, {}, list(names), max(2, os.cpu_count() or 4)
try:
for m in mods:
logf[m] = open(logs / f'rewrite_{m}.log', 'w')
procs[m] = subprocess.Popen([str(py), str(repo / 'dm/tools/patch_nso.py'), str(exe_dir / m), str(tmp), m], env=env, stdout=logf[m], stderr=subprocess.STDOUT)
left = list(mods)
while left:
for m in [m for m in left if procs[m].poll() is not None]:
left.remove(m); PROG.set(done=len(mods) - len(left))
if procs[m].returncode:
log = logs / f'rewrite_{m}.log'; logf[m].close()
while todo or procs:
while todo and len(procs) < cores:
m = todo.pop(0); logf[m] = open(logs / f'rewrite_{m}.log', 'w')
procs[m] = subprocess.Popen([str(py), str(repo / 'dm/tools/patch_nso.py'), str(src[m]), str(tmp), m], env=env, stdout=logf[m], stderr=subprocess.STDOUT)
for m in [m for m in procs if procs[m].poll() is not None]:
p = procs.pop(m); logf[m].close(); PROG.set(done=len(names) - len(todo) - len(procs))
if p.returncode:
log = logs / f'rewrite_{m}.log'
PROG.end(ok=False)
PROG.say(f'---- last lines of {log} ----\n' + ''.join(collections.deque(open(log, errors='replace'), maxlen=40)).rstrip('\n'))
raise BuildError(f'patch_nso.py exit code {procs[m].returncode} on {m} (full output: {log})')
raise BuildError(f'patch_nso.py exit code {p.returncode} on {m} (full output: {log})')
time.sleep(0.2)
finally: # an error or an interrupt stops the other rewrites
for p in procs.values():
if p.poll() is None: p.terminate(); p.wait()
for f in logf.values(): f.close()
rmtree(kf); swap_in(tmp, out); kf.write_text(f'{key}\n')
PROG.end(', '.join(mods))
PROG.end(', '.join(mods) + (f' and {len(nros)} NROs' if nros else ''))
GLSLC_SECS = 30 # the longest capture run: Three Houses compiles its 9 shaders within 1.2 s of boot, and the run stops as soon as every file the profile names is there