Update 1.2.1 (v327680): extractor, offset mapper, build IDs, new SDK calls

- dm/tools/nx_extract (C#, LibHac 0.19): hactool stops with "Invalid
  BKTR layout!" on the update's patch NCA. nx_extract reads the NCAs
  straight from both NSPs, imports their common tickets, merges the
  update's RomFS over the base game, prints the display version and
  module build IDs, and writes files identical to a base RomFS as APFS
  clones (the merged 1.2.1 RomFS adds about 114 MB).
- dm/tools/nso_fnmap.py: carries offsets between builds by matching
  eh_frame functions with address bits masked; globals through their
  adrp references.
- patch_nso.py: resource heap +16 KB cut for 1.2.1 main ff773e90...
  (main+0x1cf9b0, mapped from v0 0x1e62fc; the function is identical
  after masking). fixups.tsv now records the module build ID; the
  loader keeps it in module_t.build_id.
- hle_oe.c: the 3D render scale inherited from d2r-mobile writes and
  calls fixed main addresses (0x31e0848, 0x8582f0). In Mario v0
  0x8582f0 is the middle of a static initializer; it only did nothing
  because the pointer read 0. Now enabled only on D2R's build.
- hle_misc.c: owned add-on content defaults to none except on D2R's
  build (DM_AOC_OWNED overrides).
- New imports in 1.2.1 answered as a Switch 1 without amiibo:
  nn::nfp (one NFC device on the connected controller, detection never
  finds a tag; nfp::Initialize at boot used to abort in nn::sm),
  pl::IsRunningOnOunce (false), ec::ShowShopApplicationInformation-
  ForUpgrade (no-op), account::PopOpenUsers (none: the game falls back
  to TryOpenPreselectedUser as in v0). oe::GetDisplayVersion by build
  (1.0.0 / 1.2.1).

Mac: 1.2.1 and v0 both run the scripted opening for 120 s with only
the known SetRenderEnableConditional red line. iPhone: 1.2.1 played
8.5 minutes with no crash.
This commit is contained in:
seedds committed 2026-10-14 01:21:04 +08:00
1 parent dfd016a234
commit 8911232933
12 files changed
+351 -4

No files matched your search

+2
View File
@@ -6,5 +6,7 @@ __pycache__/
# 着色器翻译工具的编译中间产物(dotnet 在源码目录旁生成)
gfx/tools/dmsh_xlate/obj/
gfx/tools/dmsh_xlate/bin/
tools/nx_extract/obj/
tools/nx_extract/bin/
third_party/ryujinx/**/obj/
third_party/ryujinx/**/bin/
+1
View File
@@ -28,6 +28,7 @@ typedef struct {
uint8_t *base; // 映像(含补丁区)在宿主地址空间的首址 | Start address of the image (including the patch area) in the host address space
size_t size;
uint64_t text_end, ro_off, data_off, patch_off, key_page_off, key_page_size; // 来自改写器 fixups 头 | From the rewriter fixups header
char build_id[33]; // NSO 构建号前 16 字节(十六进制);旧产物没有则为空 | First 16 bytes of the NSO build ID (hex); empty for older build outputs
unsigned key_idx[8];
const Elf64_Dyn *dyn;
const Elf64_Sym *symtab;
+5
View File
@@ -22,6 +22,11 @@ HLE_FORCE("_ZN2nn7account13ListOpenUsersEPiPNS0_3UidEi", "已打开用户列表
HLE_FORCE("_ZN2nn7account16ShowUserSelectorEPNS0_3UidERKNS0_21UserSelectionSettingsE", "用户选择小程序;宿主直接选本地用户", Result, h_acc_ShowSel, Uid *out, const void *settings) {
(void)settings; *out = k_uid; return RES_OK;
}
// 取走系统代应用打开的用户(马力欧 1.2.1 起先问它,0 个再走 TryOpenPreselectedUser;游戏只看个数、不看返回值):
// 宿主没有代开的用户,给 0 个,游戏照 v0 的路自己打开预选用户
// Take the users the system opened on the application's behalf (Mario 1.2.1 asks this first and falls back to TryOpenPreselectedUser on 0; the game only reads the count, not the return value):
// the host has no such users and returns 0, so the game opens the preselected user itself, as in v0
HLE_FORCE("_ZN2nn7account12PopOpenUsersEPiPNS0_10UserHandleEi", "系统代开的用户:无(游戏改走预选用户)", void, h_acc_PopOpen, int *n, UserHandle *out, int max) { (void)out; (void)max; *n = 0; }
HLE_FORCE("_ZN2nn7account22TryOpenPreselectedUserEPNS0_10UserHandleE", "启动时预选用户;恒为本地用户(返回 bool)", unsigned, h_acc_TryOpenPre, UserHandle *h) {
h->v[0] = k_uid.v[0]; h->v[1] = k_uid.v[1]; h->v[2] = HANDLE_MAGIC; return 1;
}
+1
View File
@@ -71,6 +71,7 @@ void hid_keys_init(void) {
}
static uint32_t connected_id(void) { return dm_opmode() == 0 ? NPAD_HANDHELD : NPAD_NO1; }
uint32_t hle_hid_connected_npad(void) { return connected_id(); } // nfp 的 NFC 设备挂在这个手柄上(hle_sdk16.c) | nfp's NFC device sits on this controller (hle_sdk16.c)
static uint32_t style_of(uint32_t id) { return id != connected_id() ? 0 : id == NPAD_HANDHELD ? STYLE_HANDHELD : STYLE_FULLKEY; }
static int32_t ax(uint64_t v, int i) { return (int16_t)(v >> (16 * i)); }
static int32_t pick(int32_t a, int32_t b) { return (a < 0 ? -a : a) >= (b < 0 ? -b : b) ? a : b; } // 两路里偏得多的那个 | whichever of the two paths is deflected more
+14 -1
View File
@@ -15,7 +15,20 @@
enum { AOC_WARLOCK = 1 };
// DM_AOC_OWNED=0:测试开关,回到「无 DLC」前提(只用于对拍未装 DLC 时录的真值;缺省 = 拥有)
// DM_AOC_OWNED=0: test switch, back to the "no DLC" premise (only for lockstep comparison against ground truth recorded without the DLC installed; default = owned)
static int aoc_owned(void) { static int v = -1; if (v < 0) { const char *e = getenv("DM_AOC_OWNED"); v = !(e && !strcmp(e, "0")); if (!v) LOG("DM_AOC_OWNED=0:aoc 报告 0 个追加内容(对拍测试用)"); } return v; }
// 已拥有的追加内容(术士君临)只属于 D2R(main 构建 db0507f3…):别的构建(马力欧)缺省 0 个——用户手里没有马力欧的追加内容文件,
// 马力欧 1.2.1 在 Switch 2 上靠它判断有没有升级包。DM_AOC_OWNED=0 / 1 覆盖缺省
// The owned add-on content (the Warlock expansion) belongs to D2R only (main build db0507f3…): other builds (Mario) default to none -- the user has no Mario add-on content files,
// and on a Switch 2 Mario 1.2.1 uses it to tell whether the upgrade pack is owned. DM_AOC_OWNED=0 / 1 overrides the default
static int aoc_owned(void) {
static int v = -1;
if (v < 0) {
const char *e = getenv("DM_AOC_OWNED"); v = 0;
for (int i = 0; i < g_nmod; i++) if (!strcmp(g_mod[i].name, "main")) v = !strcmp(g_mod[i].build_id, "db0507f36b84200ac05f75d76ed5026f");
if (e) v = strcmp(e, "0") != 0;
LOG("aoc:报告 %d 个追加内容(%s)", v, e ? "DM_AOC_OWNED" : "按 main 构建号");
}
return v;
}
HLE_FORCE("_ZN2nn3aoc17CountAddOnContentEv", "追加内容数:已拥有术士君临 1 个(复刻范围必须保留)", int, h_aoc_Count, void) { return aoc_owned() ? 1 : 0; }
// ListAddOnContent(out, 起始下标, 最多个数):按已拥有序号列表 {1} 从起始下标起写出,返回写出个数
// ListAddOnContent(out, start index, max count): writes out from the owned index list {1} starting at the start index, returns the number written
+5
View File
@@ -222,6 +222,11 @@ static void scale3d_tick(void) {
const char *e = getenv("DM_3D_SCALE"); want_cfg = TARGET_OS_IPHONE ? 0.75f : 1.0f;
if (e) { char *end; float v = strtof(e, &end); if (end == e || *end || !(v >= 0.5f && v <= 1.0f)) die("DM_3D_SCALE 非法:%s(要求 0.5–1.0)", e); want_cfg = v; }
for (int i = 0; i < g_nmod; i++) if (!strcmp(g_mod[i].name, "main")) main = &g_mod[i];
// 上面的地址(渲染器全局指针、改分辨率函数、成员偏移)都是 D2R 构建 db0507f3… 的;别的构建(马力欧各版)同一地址是不相干的数据与代码
// (马力欧 v0 的 main+0x8582f0 在一个静态初始化函数中间),照写照调会改坏内存。只在这个构建上开
// The addresses above (renderer global pointer, resolution change function, member offsets) all belong to D2R build db0507f3…; in other builds (every Mario version) the same addresses are unrelated data and code
// (Mario v0's main+0x8582f0 is in the middle of a static initializer), so writing and calling them would corrupt memory. Only enabled on that build
if (main && strcmp(main->build_id, "db0507f36b84200ac05f75d76ed5026f")) { LOG("3D 渲染比例:main 构建 %s 没有对应地址,不调(只认 D2R db0507f3…)", main->build_id[0] ? main->build_id : "(产物无构建号)"); main = NULL; }
}
int lv = atomic_load(&g_scale3d_level); // 动态级:0 = 配置值,1 = 0.72,2 = 0.70(都不高于配置值) | dynamic level: 0 = configured value, 1 = 0.72, 2 = 0.70 (none higher than the configured value)
float dyn = lv == 0 ? want_cfg : lv == 1 ? SCALE3D_MID : SCALE3D_MIN; if (dyn > want_cfg) dyn = want_cfg;
+67 -3
View File
@@ -5,6 +5,7 @@
#include "hle.h"
#include <fcntl.h>
#include <pthread.h>
#include <stdatomic.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <time.h>
@@ -12,9 +13,18 @@
// ---------- oe ----------
HLE_FORCE("_ZN2nn2oe15SetCpuBoostModeENS0_12CpuBoostModeE", "CPU 加速档;宿主不调频", void, h_oe_CpuBoost, int m) { (void)m; }
// DisplayVersion = char[16](应用的显示版本号)
// DisplayVersion = char[16] (the application's display version)
HLE_FORCE("_ZN2nn2oe17GetDisplayVersionEPNS0_14DisplayVersionE", "显示版本号;宿主给 1.0.0", void, h_oe_DispVer, char *v) { memset(v, 0, 16); strlcpy(v, "1.0.0", 16); }
// DisplayVersion = char[16](应用的显示版本号):按 main 的构建号取该版控制数据(NACP)里的值,认不出的构建给 1.0.0
// DisplayVersion = char[16] (the application's display version): the value from that version's control data (NACP), chosen by main's build ID; unknown builds get 1.0.0
HLE_FORCE("_ZN2nn2oe17GetDisplayVersionEPNS0_14DisplayVersionE", "显示版本号(按 main 构建号)", void, h_oe_DispVer, char *v) {
static const struct { const char *build, *ver; } k[] = {
{"cd6e42aee7934f4d8393cd43893ae25e", "1.0.0"}, // 马力欧 v0 | Mario v0
{"ff773e90972d544eb79406eaa65396d5", "1.2.1"}, // 马力欧更新 v327680 | Mario update v327680
};
const char *ver = "1.0.0";
for (int i = 0; i < g_nmod; i++) if (!strcmp(g_mod[i].name, "main"))
for (unsigned j = 0; j < sizeof k / sizeof *k; j++) if (!strcmp(g_mod[i].build_id, k[j].build)) ver = k[j].ver;
memset(v, 0, 16); strlcpy(v, ver, 16);
}
HLE_FORCE("_ZN2nn2oe24GetExpectedVolumeBalanceEPfS1_", "系统 / 应用音量平衡;宿主恒 1:1", void, h_oe_GetVolBal, float *sys, float *app) { *sys = 1.0f; *app = 1.0f; }
HLE_FORCE("_ZN2nn2oe24SetExpectedVolumeBalanceEff", "音量平衡;宿主忽略", void, h_oe_SetVolBal, float sys, float app) { (void)sys; (void)app; }
// 程序累计运行时长(nn::TimeSpan,纳秒):取宿主启动以来的单调时间
@@ -105,6 +115,60 @@ static font_t font_get(int type) {
HLE_FORCE("_ZN2nn2pl22GetSharedFontLoadStateENS0_14SharedFontTypeE", "共享字体装载状态:已装(1)", int, h_pl_LoadState, int type) { return font_get(type).p ? 1 : 0; }
HLE_FORCE("_ZN2nn2pl20GetSharedFontAddressENS0_14SharedFontTypeE", "共享字体地址 → BFTTF 头(宿主替身字体)", const void *, h_pl_Addr, int type) { return font_get(type).p; }
HLE_FORCE("_ZN2nn2pl17GetSharedFontSizeENS0_14SharedFontTypeE", "共享字体大小 → 头 8 字节 + 替身字体文件大小", size_t, h_pl_Size, int type) { return font_get(type).n; }
// 是否跑在 Switch 2(代号 Ounce)上(返回 bool):宿主按 Switch 1 报假。马力欧 1.2.1 据此决定要不要数追加内容(升级包)、提示升级到 Switch 2 版
// Whether running on a Switch 2 (codename Ounce) (returns bool): the host reports false, as a Switch 1. Mario 1.2.1 uses it to decide whether to count add-on content (the upgrade pack) and suggest the Switch 2 Edition
HLE_FORCE("_ZN2nn2pl16IsRunningOnOunceEv", "是否 Switch 2:否", unsigned, h_pl_IsOunce, void) { return 0; }
// ---------- ec:商店小程序(升级到 Switch 2 版的页面)----------
// 商店属排除范围:空操作。游戏不看返回值(两个重载都按无返回值处理)
// ---------- ec: shop applet (the upgrade-to-Switch-2-Edition page) ----------
// The shop is out of scope: no-op. The game does not look at the return value (both overloads are handled as returning nothing)
HLE_FORCE("_ZN2nn2ec40ShowShopApplicationInformationForUpgradeENS0_14UpgradeEditionE", "商店升级页,排除范围;空操作", void, h_ec_ShowUpg, int ed) { LOG("ec::ShowShopApplicationInformationForUpgrade(%d)(剪)", ed); }
HLE_FORCE("_ZN2nn2ec40ShowShopApplicationInformationForUpgradeENS0_14UpgradeEditionERKNS_7account10UserHandleE", "商店升级页(指定用户),排除范围;空操作", void, h_ec_ShowUpgU, int ed, const void *user) { (void)user; LOG("ec::ShowShopApplicationInformationForUpgrade(%d, 用户)(剪)", ed); }
// ---------- nfp:amiibo ----------
// 马力欧 1.2.1 起启动即初始化 nfp(v0 不用)。真 SDK 要连 sm: 服务,宿主没有 → 中止,所以这一族全部接管。
// 宿主没有 NFC 读卡器:报一台 NFC 设备,挂在当前接着的手柄上(同 hle_hid.c),但找标签永远找不到——与真机手边没有 amiibo 时一样:
// 游戏的「请碰 amiibo」界面照常出现,取消即退出。要标签才能做的操作(挂载、读写应用区、amiibo 设置小程序)一律回「没找到标签」。
// 游戏不挂激活 / 失活事件,靠轮询 GetDeviceState 看有没有标签。
// ---------- nfp: amiibo ----------
// From Mario 1.2.1 the game initializes nfp at boot (v0 did not use it). The real SDK would connect to the sm: service, which the host lacks → abort, so the whole family is taken over.
// The host has no NFC reader: it reports one NFC device on the currently connected controller (same as hle_hid.c) whose detection never finds a tag -- the same as real hardware with no amiibo at hand:
// the game's "touch an amiibo" screen appears as usual and cancelling leaves it. Operations that need a tag (mount, application area read / write, amiibo settings applets) all return "tag not found".
// The game attaches no activate / deactivate events; it polls GetDeviceState to see whether a tag is there.
#define NFP_RES_TAG_NOT_FOUND (115u | 97u << 9) // 模块 115(nfp)描述 97 | module 115 (nfp), description 97
enum { NFP_STATE_INIT = 0, NFP_STATE_SEARCH = 1 }; // nn::nfp::DeviceState:0 初始、1 找标签中(2 有标签,宿主不会出现) | 0 initialized, 1 searching for a tag (2 = tag found, never on the host)
typedef struct { uint32_t id; uint8_t reserved[4]; } NfpDeviceHandle;
#define NFP_DEVICE_ID 0x4e465030u // 'NFP0'
static _Atomic int g_nfp_state;
uint32_t hle_hid_connected_npad(void);
static Result nfp_no_tag(const char *what) {
static _Atomic int logged; if (!atomic_exchange(&logged, 1)) LOG("nfp:%s 等要 amiibo 标签的操作 → 没找到标签(宿主无 NFC)", what);
return NFP_RES_TAG_NOT_FOUND;
}
HLE_FORCE("_ZN2nn3nfp10InitializeEv", "amiibo 库初始化;宿主一台 NFC 设备、永远找不到标签", void, h_nfp_Init, void) { LOG("nfp::Initialize:宿主一台 NFC 设备,找不到 amiibo 标签"); }
HLE_FORCE("_ZN2nn3nfp29AttachAvailabilityChangeEventEPNS_2os15SystemEventTypeE", "NFC 可用性变化事件;绑内核事件、恒不触发", void, h_nfp_AttachAvail, void *ev) { hle_bind_system_event(ev, 0); }
HLE_FORCE("_ZN2nn3nfp11ListDevicesEPNS0_12DeviceHandleEPii", "NFC 设备列表:一台", Result, h_nfp_List, NfpDeviceHandle *out, int *n, int max) {
*n = 0; if (max >= 1) { memset(&out[0], 0, sizeof out[0]); out[0].id = NFP_DEVICE_ID; *n = 1; }
return RES_OK;
}
HLE_FORCE("_ZN2nn3nfp9GetNpadIdEPjRKNS0_12DeviceHandleE", "NFC 设备所在手柄:当前接着的那个", Result, h_nfp_NpadId, uint32_t *out, const NfpDeviceHandle *h) { (void)h; *out = hle_hid_connected_npad(); return RES_OK; }
HLE_FORCE("_ZN2nn3nfp14StartDetectionERKNS0_12DeviceHandleE", "开始找标签(永远找不到)", Result, h_nfp_Start, const NfpDeviceHandle *h) { (void)h; atomic_store(&g_nfp_state, NFP_STATE_SEARCH); return RES_OK; }
HLE_FORCE("_ZN2nn3nfp13StopDetectionERKNS0_12DeviceHandleE", "停止找标签", Result, h_nfp_Stop, const NfpDeviceHandle *h) { (void)h; atomic_store(&g_nfp_state, NFP_STATE_INIT); return RES_OK; }
HLE_FORCE("_ZN2nn3nfp14GetDeviceStateERKNS0_12DeviceHandleE", "设备状态:初始 / 找标签中", int, h_nfp_State, const NfpDeviceHandle *h) { (void)h; return atomic_load(&g_nfp_state); }
HLE_FORCE("_ZN2nn3nfp10GetTagInfoEPNS0_7TagInfoERKNS0_12DeviceHandleE", "标签信息:没有标签", Result, h_nfp_TagInfo, void *out, const NfpDeviceHandle *h) { (void)out; (void)h; return nfp_no_tag("GetTagInfo"); }
HLE_FORCE("_ZN2nn3nfp12GetModelInfoEPNS0_9ModelInfoERKNS0_12DeviceHandleE", "型号信息:没有标签", Result, h_nfp_ModelInfo, void *out, const NfpDeviceHandle *h) { (void)out; (void)h; return nfp_no_tag("GetModelInfo"); }
HLE_FORCE("_ZN2nn3nfp5MountERKNS0_12DeviceHandleENS0_9ModelTypeENS0_11MountTargetE", "挂载标签:没有标签", Result, h_nfp_Mount, const NfpDeviceHandle *h, int model, int target) { (void)h; (void)model; (void)target; return nfp_no_tag("Mount"); }
HLE_FORCE("_ZN2nn3nfp5FlushERKNS0_12DeviceHandleE", "写回标签:没有标签", Result, h_nfp_Flush, const NfpDeviceHandle *h) { (void)h; return nfp_no_tag("Flush"); }
HLE_FORCE("_ZN2nn3nfp19OpenApplicationAreaERKNS0_12DeviceHandleEj", "打开应用区:没有标签", Result, h_nfp_OpenArea, const NfpDeviceHandle *h, uint32_t id) { (void)h; (void)id; return nfp_no_tag("OpenApplicationArea"); }
HLE_FORCE("_ZN2nn3nfp18GetApplicationAreaEPvPmRKNS0_12DeviceHandleEm", "读应用区:没有标签", Result, h_nfp_GetArea, void *buf, uint64_t *n, const NfpDeviceHandle *h, uint64_t sz) { (void)buf; (void)h; (void)sz; if (n) *n = 0; return nfp_no_tag("GetApplicationArea"); }
HLE_FORCE("_ZN2nn3nfp18SetApplicationAreaERKNS0_12DeviceHandleEPKvm", "写应用区:没有标签", Result, h_nfp_SetArea, const NfpDeviceHandle *h, const void *buf, uint64_t sz) { (void)h; (void)buf; (void)sz; return nfp_no_tag("SetApplicationArea"); }
HLE_FORCE("_ZN2nn3nfp21CreateApplicationAreaERKNS0_12DeviceHandleERKNS0_25ApplicationAreaCreateInfoE", "建应用区:没有标签", Result, h_nfp_CreateArea, const NfpDeviceHandle *h, const void *info) { (void)h; (void)info; return nfp_no_tag("CreateApplicationArea"); }
HLE_FORCE("_ZN2nn3nfp23RecreateApplicationAreaERKNS0_12DeviceHandleERKNS0_25ApplicationAreaCreateInfoE", "重建应用区:没有标签", Result, h_nfp_RecreateArea, const NfpDeviceHandle *h, const void *info) { (void)h; (void)info; return nfp_no_tag("RecreateApplicationArea"); }
HLE_FORCE("_ZN2nn3nfp13StartRestorerEPNS0_12DeviceHandleERKNS0_24AmiiboSettingsStartParamERKNS0_7TagInfoE", "amiibo 修复小程序:没有标签", Result, h_nfp_Restorer, NfpDeviceHandle *h, const void *p, const void *tag) { (void)h; (void)p; (void)tag; return nfp_no_tag("StartRestorer"); }
HLE_FORCE("_ZN2nn3nfp19StartGameDataEraserEPNS0_12DeviceHandleERKNS0_24AmiiboSettingsStartParamERKNS0_7TagInfoE", "amiibo 游戏数据删除小程序:没有标签", Result, h_nfp_Eraser, NfpDeviceHandle *h, const void *p, const void *tag) { (void)h; (void)p; (void)tag; return nfp_no_tag("StartGameDataEraser"); }
HLE_FORCE("_ZN2nn3nfp29StartNicknameAndOwnerSettingsEPNS0_12DeviceHandleEPbPNS0_12RegisterInfoERKNS0_24AmiiboSettingsStartParamERKNS0_7TagInfoE", "amiibo 昵称与主人设置小程序:没有标签", Result, h_nfp_Nick, NfpDeviceHandle *h, uint8_t *changed, void *reg, const void *p, const void *tag) { (void)h; (void)reg; (void)p; (void)tag; if (changed) *changed = 0; return nfp_no_tag("StartNicknameAndOwnerSettings"); }
HLE_FORCE("_ZN2nn3nfp29StartNicknameAndOwnerSettingsEPNS0_12DeviceHandleEPbPNS0_12RegisterInfoERKNS0_24AmiiboSettingsStartParamERKNS0_7TagInfoERKS4_", "amiibo 昵称与主人设置小程序(带原登记信息):没有标签", Result, h_nfp_Nick2, NfpDeviceHandle *h, uint8_t *changed, void *reg, const void *p, const void *tag, const void *old) { (void)h; (void)reg; (void)p; (void)tag; (void)old; if (changed) *changed = 0; return nfp_no_tag("StartNicknameAndOwnerSettings"); }
// ---------- vi:垂直同步事件 ----------
// 游戏给一个 SystemEventType,宿主绑定后由一条宿主线程按 60 Hz 触发(DM_VSYNC_HZ 可改)
+1
View File
@@ -165,6 +165,7 @@ static void read_layout(module_t *m, const char *dir) {
else if (!strcmp(key, "TEXT_END")) m->text_end = v;
else if (!strcmp(key, "RO_OFF")) m->ro_off = v;
else if (!strcmp(key, "DATA_OFF")) m->data_off = v;
else if (!strcmp(key, "BUILD_ID")) sscanf(line, "# %*s %32s", m->build_id);
else for (int k = 0; k < 8; k++) {
size_t n = strlen(kn[k]);
if (!strncmp(key, "KEY_", 4) && !strncmp(key + 4, kn[k], n) && !strcmp(key + 4 + n, "_IDX")) {
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
# 两个构建之间搬偏移(游戏更新后找回宿主 / 改写器里写死的地址):nso_fnmap.py <旧 .orig.bin> <新 .orig.bin> 0x偏移...
# 函数表取自 eh_frame(起点 + FDE 长度)。每个函数按「屏蔽与地址有关的位」后的指令串求哈希:b / bl / b.cond / cbz / tbz / adr / adrp / 字面量 ldr 的立即数,
# 以及紧跟 adrp、以其目的寄存器为基址的 add / ldr / str 的低 12 位(数据段整体挪了位置,页内偏移也跟着变)。结构体成员偏移、常数照常参与比较。
# 代码偏移:所在函数在新构建里哈希唯一相同 → 同一函数内偏移(「全同」);否则在前后最近的全同函数之间按指令相似度挑(「近似」,报相似度)。
# 数据偏移(全局变量):旧构建里用 adrp + add/ldr/str 正好算出该地址的指令对,所在函数全同时在新构建同位置解码出新地址;多处引用须一致。
# 输出每个偏移一行:旧 → 新、方法、旧 / 新所在函数;新位置的指令与旧的(屏蔽后)不同时报红。
# .orig.bin 由 dm/tools/nso.py 解出(整块内存映像)。
# Carry offsets across two builds (recover addresses hard-coded in the host / rewriter after a game update): nso_fnmap.py <old .orig.bin> <new .orig.bin> 0xoffset...
# The function table comes from eh_frame (starts + FDE lengths). Each function is hashed over its instructions with address-dependent bits masked: the immediates of b / bl / b.cond / cbz / tbz / adr / adrp / literal ldr,
# and the low 12 bits of an add / ldr / str that follows an adrp and uses its destination register as the base (the data segments moved as a whole, so in-page offsets change too). Struct member offsets and constants still take part.
# Code offsets: if the containing function has a unique identical hash in the new build → same offset within that function ("exact"); otherwise pick by instruction similarity between the nearest exact matches before / after ("near", similarity reported).
# Data offsets (globals): the adrp + add/ldr/str pairs in the old build that compute exactly that address are decoded at the same place in the new build when their function is an exact match; several references must agree.
# One line per offset: old → new, method, old / new containing function; reported in red when the instruction at the new place differs (after masking) from the old one.
# The .orig.bin files are produced by dm/tools/nso.py (the whole memory image).
import bisect, collections, difflib, hashlib, struct, sys
import numpy as np
class Img:
def __init__(self, path):
self.b = open(path, 'rb').read()
self.w = np.frombuffer(self.b[:len(self.b) // 4 * 4], dtype='<u4')
m0 = struct.unpack_from('<I', self.b, 4)[0]
ehs = m0 + struct.unpack_from('<i', self.b, m0 + 16)[0]
_, penc, cenc, tenc = self.b[ehs:ehs + 4]
assert (penc, cenc, tenc) == (0x1b, 0x03, 0x3b), f'eh_frame_hdr 编码 {penc:#x} {cenc:#x} {tenc:#x}'
n = struct.unpack_from('<I', self.b, ehs + 8)[0]
fn = {}
for i in range(n):
loc, fde = struct.unpack_from('<ii', self.b, ehs + 12 + i * 8); loc += ehs; fde += ehs
pc0 = fde + 8 + struct.unpack_from('<i', self.b, fde + 8)[0]; size = struct.unpack_from('<I', self.b, fde + 12)[0]
if pc0 != loc: continue # FDE 编码不是 pcrel sdata4 的略过 | skip FDEs whose encoding is not pcrel sdata4
fn[loc] = max(fn.get(loc, 0), size)
self.starts = sorted(fn); self.size = fn
self.mask = masked(self.w, min(self.starts), max(s + fn[s] for s in self.starts))
self.hash = {s: hashlib.sha1(self.mask[s // 4:(s + fn[s]) // 4].tobytes()).digest() for s in self.starts}
def func(self, off):
i = bisect.bisect_right(self.starts, off) - 1
return self.starts[i] if i >= 0 and off < self.starts[i] + self.size[self.starts[i]] else None
def u32(self, off): return int(self.w[off // 4])
def masked(w, lo, hi):
"""lo..hi 范围内逐字屏蔽地址相关位(之外原样) | mask address-dependent bits word by word within lo..hi (unchanged outside)"""
m = w.copy(); s = slice(lo // 4, hi // 4); x = m[s]
for msk, val, keep in ((0x7c000000, 0x14000000, 0xfc000000), # b / bl
(0xff000010, 0x54000000, 0xff00001f), # b.cond
(0x7e000000, 0x34000000, 0xff00001f), # cbz / cbnz
(0x7e000000, 0x36000000, 0xfff8001f), # tbz / tbnz
(0x1f000000, 0x10000000, 0x9f00001f), # adr / adrp
(0x3b000000, 0x18000000, 0xff00001f)): # ldr 字面量 | literal ldr
sel = (x & msk) == val; x[sel] &= keep
# adrp 之后 8 条内、以其目的寄存器为基址的 add(64 位立即数)/ ldr / str(无符号偏移):屏蔽低 12 位
# add (64-bit immediate) / ldr / str (unsigned offset) within 8 instructions after an adrp, based on its destination register: mask the low 12 bits
raw = w[s]; adrp = np.nonzero((raw & 0x9f000000) == 0x90000000)[0]
for i in adrp:
rd = int(raw[i]) & 31
for j in range(i + 1, min(i + 9, len(raw))):
v = int(raw[j])
if ((v & 0xff800000) == 0x91000000 or (v & 0x3b000000) == 0x39000000) and (v >> 5) & 31 == rd: x[j] &= ~np.uint32(0x3ffc00); break
if (v & 31) == rd and (v & 0x9f000000) != 0x90000000 and (v & 0x3b000000) != 0x39000000: break # 目的寄存器被别的指令改写 | destination register rewritten by another instruction
return m
def adrp_refs(img, target):
"""img 里 adrp + add/ldr/str 正好算出 target 的指令对 [(adrp 偏移, 配对指令偏移)] | instruction pairs in img whose adrp + add/ldr/str compute exactly target"""
out = []; w = img.w; page = target & ~0xfff
cand = np.nonzero((w & 0x9f000000) == 0x90000000)[0]
for i in cand:
v = int(w[i]); imm = ((v >> 29) & 3) | (((v >> 5) & 0x7ffff) << 2)
if imm & (1 << 20): imm -= 1 << 21
if ((i * 4) & ~0xfff) + (imm << 12) != page: continue
rd = v & 31
for j in range(i + 1, min(i + 9, len(w))):
u = int(w[j])
if (u >> 5) & 31 != rd: continue
if (u & 0xff800000) == 0x91000000: lo = (u >> 10) & 0xfff
elif (u & 0x3b000000) == 0x39000000: lo = ((u >> 10) & 0xfff) << (u >> 30 if not (u & 0x04800000) == 0x04800000 else 4)
else: continue
if page + lo == target: out.append((i * 4, j * 4))
break
return out
def pair_addr(img, a, b):
v, u = img.u32(a), img.u32(b)
imm = ((v >> 29) & 3) | (((v >> 5) & 0x7ffff) << 2)
if imm & (1 << 20): imm -= 1 << 21
lo = (u >> 10) & 0xfff
if (u & 0x3b000000) == 0x39000000: lo <<= (u >> 30 if not (u & 0x04800000) == 0x04800000 else 4)
return (a & ~0xfff) + (imm << 12) + lo
def main():
old, new = Img(sys.argv[1]), Img(sys.argv[2])
by_new = collections.defaultdict(list)
for s, h in new.hash.items(): by_new[h].append(s)
by_old = collections.Counter(old.hash.values())
exact = {s: by_new[h][0] for s, h in old.hash.items() if by_old[h] == 1 and len(by_new.get(h, ())) == 1}
print(f'函数 旧 {len(old.starts)} 新 {len(new.starts)},全同且唯一 {len(exact)} | functions old {len(old.starts)} new {len(new.starts)}, unique exact matches {len(exact)}')
ex_old = sorted(exact)
def near(f):
if f in exact: return exact[f], 1.0, '全同 | exact'
i = bisect.bisect_left(ex_old, f); lo = exact[ex_old[i - 1]] if i else 0; hi = exact[ex_old[i]] if i < len(ex_old) else 1 << 62
a = old.mask[f // 4:(f + old.size[f]) // 4].tolist(); best = (0, None)
for g in new.starts[bisect.bisect_right(new.starts, lo):bisect.bisect_left(new.starts, hi)]:
if abs(new.size[g] - old.size[f]) > max(64, old.size[f] // 4): continue
r = difflib.SequenceMatcher(None, a, new.mask[g // 4:(g + new.size[g]) // 4].tolist(), autojunk=False).ratio()
if r > best[0]: best = (r, g)
return best[1], best[0], f'近似 | near {best[0]:.3f}'
for arg in sys.argv[3:]:
off = int(arg, 16); f = old.func(off)
if f is not None:
g, r, how = near(f)
if g is None: print(f'【红】{off:#x}:函数 {f:#x} 在新构建里找不到 | function not found in the new build'); continue
n = g + off - f; same = old.mask[off // 4] == new.mask[n // 4]
print(f'{"" if same and r == 1.0 else "【红】" if not same else ""}{off:#x} → {n:#x} 代码 | code {how} 旧函数 | old fn {f:#x}+{off - f:#x} → 新 | new {g:#x}' + ('' if same else f' 指令不同 | instruction differs {old.u32(off):08x} / {new.u32(n):08x}'))
continue
refs = adrp_refs(old, off); got = collections.Counter(); used = 0
for a, b in refs:
fa = old.func(a)
if fa is None or fa not in exact: continue
g = exact[fa]; got[pair_addr(new, g + a - fa, g + b - fa)] += 1; used += 1
if not got: print(f'【红】{off:#x}:数据,旧构建引用 {len(refs)} 处,都不在全同函数里 | data, {len(refs)} references in the old build, none in an exact-match function'); continue
(n, k), = got.most_common(1)
print(f'{"" if len(got) == 1 else "【红】"}{off:#x} → {n:#x} 数据 | data {k}/{used} 处引用一致(旧构建共 {len(refs)} 处) | references agree ({len(refs)} in the old build)' + ('' if len(got) == 1 else f' 不一致 | disagree {dict(got)}'))
if __name__ == '__main__': main()
+112
View File
@@ -0,0 +1,112 @@
// NSP 解包:nx_extract <prod.keys> <基础 NSP> <更新 NSP 或 -> <输出目录> [比对用的基础 RomFS 目录]
// 输出 <输出目录>/ExeFS 与 <输出目录>/RomFS(有更新包时是合并后的 RomFS);给了基础 RomFS 目录时,内容与之相同的文件做成 APFS 克隆(不占新空间)。
// 标题密钥取自 NSP 里的通用票据(.tik),只需 prod.keys;密钥值一律不打印。
// NSP extraction: nx_extract <prod.keys> <base NSP> <update NSP or -> <output dir> [base RomFS dir to compare against]
// Writes <output dir>/ExeFS and <output dir>/RomFS (the merged RomFS when there is an update); with a base RomFS dir, files whose content matches it become APFS clones (no new space).
// Title keys come from the common tickets (.tik) inside the NSPs, so only prod.keys is needed; key values are never printed.
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using LibHac.Common;
using LibHac.Common.Keys;
using LibHac.Fs;
using LibHac.Fs.Fsa;
using LibHac.FsSystem;
using LibHac.Spl;
using LibHac.Tools.Fs;
using LibHac.Tools.FsSystem;
using LibHac.Tools.FsSystem.NcaUtils;
using LibHac.Tools.FsSystem.RomFs;
using Path = System.IO.Path;
static class P {
[DllImport("libc", SetLastError = true)] static extern int clonefile(string src, string dst, uint flags);
static int Main(string[] a) {
if (a.Length < 4) { Console.Error.WriteLine("用法 | usage: nx_extract <prod.keys> <base.nsp> <update.nsp|-> <out dir> [base RomFS dir]"); return 2; }
KeySet ks = ExternalKeyReader.ReadKeyFile(a[0]);
var basePkg = OpenNsp(ks, a[1]); var updPkg = a[2] == "-" ? null : OpenNsp(ks, a[2]);
Nca baseProg = Pick(basePkg, NcaContentType.Program) ?? throw new Exception("基础 NSP 里没有程序 NCA | no Program NCA in the base NSP");
Nca updProg = updPkg == null ? null : Pick(updPkg, NcaContentType.Program) ?? throw new Exception("更新 NSP 里没有程序 NCA | no Program NCA in the update NSP");
Nca ctrl = Pick(updPkg ?? basePkg, NcaContentType.Control);
Console.WriteLine($"标题 | title {baseProg.Header.TitleId:x16}" + (updProg != null ? $",更新 | update {updProg.Header.TitleId:x16}" : ""));
if (ctrl != null) Console.WriteLine($"显示版本 | display version {DisplayVersion(ctrl)}");
string outDir = a[3], cmpDir = a.Length > 4 ? a[4] : null;
// 代码区:更新包的 ExeFS 是完整的,叠在基础上打开也一样 | Code section: the update's ExeFS is complete; opening it layered over the base gives the same result
IFileSystem exefs = updProg != null ? baseProg.OpenFileSystemWithPatch(updProg, NcaSectionType.Code, IntegrityCheckLevel.ErrorOnInvalid) : baseProg.OpenFileSystem(NcaSectionType.Code, IntegrityCheckLevel.ErrorOnInvalid);
Copy(exefs, Path.Combine(outDir, "ExeFS"), null);
foreach (string m in new[] { "main", "sdk" }) {
string p = Path.Combine(outDir, "ExeFS", m);
if (File.Exists(p)) { byte[] h = new byte[0x20]; using (var f = File.OpenRead(p)) { f.Seek(0x40, SeekOrigin.Begin); f.ReadExactly(h); } Console.WriteLine($"{m} 构建号 | build id {Convert.ToHexString(h, 0, 16).ToLowerInvariant()}"); }
}
IStorage romfs = updProg != null ? baseProg.OpenStorageWithPatch(updProg, NcaSectionType.Data, IntegrityCheckLevel.ErrorOnInvalid) : baseProg.OpenStorage(NcaSectionType.Data, IntegrityCheckLevel.ErrorOnInvalid);
Copy(new RomFsFileSystem(romfs), Path.Combine(outDir, "RomFS"), cmpDir);
return 0;
}
// 打开 NSP(PFS0),导入其中的通用票据,列出所有 NCA | Open an NSP (PFS0), import its common tickets, list all NCAs
static List<Nca> OpenNsp(KeySet ks, string path) {
var pfs = new PartitionFileSystem();
pfs.Initialize(new LocalStorage(path, FileAccess.Read)).ThrowIfFailure();
var ncas = new List<Nca>();
foreach (DirectoryEntryEx e in pfs.EnumerateEntries("*", SearchOptions.Default)) {
using var f = new UniqueRef<IFile>();
pfs.OpenFile(ref f.Ref, e.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
IStorage st = f.Release().AsStorage();
if (e.Name.EndsWith(".tik", StringComparison.OrdinalIgnoreCase)) {
byte[] t = new byte[0x2c0]; st.Read(0, t).ThrowIfFailure();
if (t[0x281] != 0) { Console.Error.WriteLine($"【红】{e.Name} 不是通用票据,标题密钥要另给 | is not a common ticket; supply the title key separately"); continue; }
ks.ExternalKeySet.Add(new RightsId(t.AsSpan(0x2a0, 0x10)), new AccessKey(t.AsSpan(0x180, 0x10))).ThrowIfFailure();
} else if (e.Name.EndsWith(".nca", StringComparison.OrdinalIgnoreCase)) ncas.Add(new Nca(ks, st));
}
return ncas;
}
static Nca Pick(List<Nca> l, NcaContentType t) { foreach (Nca n in l) if (n.Header.ContentType == t) return n; return null; }
static string DisplayVersion(Nca ctrl) {
IFileSystem fs = ctrl.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.ErrorOnInvalid);
using var f = new UniqueRef<IFile>();
fs.OpenFile(ref f.Ref, "/control.nacp".ToU8Span(), OpenMode.Read).ThrowIfFailure();
byte[] v = new byte[0x10]; f.Get.Read(out _, 0x3060, v, ReadOption.None).ThrowIfFailure(); // NACP DisplayVersion @0x3060
return System.Text.Encoding.ASCII.GetString(v).TrimEnd('\0');
}
// 逐文件拷出;与比对目录同路径、同内容的文件改做 APFS 克隆 | Copy out file by file; files identical to the same path in the compare dir become APFS clones instead
static void Copy(IFileSystem fs, string dst, string cmpDir) {
long n = 0, cloned = 0, bytes = 0, written = 0; byte[] buf = new byte[4 << 20], cb = new byte[4 << 20];
foreach (DirectoryEntryEx e in fs.EnumerateEntries("*", SearchOptions.RecurseSubdirectories)) {
string rel = e.FullPath.TrimStart('/'), op = Path.Combine(dst, rel);
if (e.Type == DirectoryEntryType.Directory) { Directory.CreateDirectory(op); continue; }
Directory.CreateDirectory(Path.GetDirectoryName(op)); n++; bytes += e.Size;
using var f = new UniqueRef<IFile>();
fs.OpenFile(ref f.Ref, e.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
string cp = cmpDir == null ? null : Path.Combine(cmpDir, rel);
if (cp != null && File.Exists(cp) && new FileInfo(cp).Length == e.Size && Same(f.Get, cp, e.Size, buf, cb)) {
if (File.Exists(op)) File.Delete(op);
if (clonefile(cp, op, 0) != 0) throw new IOException($"clonefile {cp} → {op} 失败 | failed: errno {Marshal.GetLastWin32Error()}");
cloned++; continue;
}
using var o = File.Create(op);
for (long off = 0; off < e.Size;) {
f.Get.Read(out long got, off, buf.AsSpan(0, (int)Math.Min(buf.Length, e.Size - off)), ReadOption.None).ThrowIfFailure();
if (got <= 0) throw new IOException($"读不出 | cannot read {e.FullPath} @{off}");
o.Write(buf, 0, (int)got); off += got;
}
written += e.Size;
}
Console.WriteLine($"{dst}:{n} 个文件 {bytes / 1048576.0:F1} MB,其中 {cloned} 个与比对目录相同(克隆),新写 {written / 1048576.0:F1} MB | {n} files {bytes / 1048576.0:F1} MB, {cloned} identical to the compare dir (cloned), {written / 1048576.0:F1} MB newly written");
}
static bool Same(IFile f, string path, long size, byte[] buf, byte[] cb) {
using var c = File.OpenRead(path);
for (long off = 0; off < size;) {
int want = (int)Math.Min(buf.Length, size - off);
f.Read(out long got, off, buf.AsSpan(0, want), ReadOption.None).ThrowIfFailure();
c.ReadExactly(cb, 0, (int)got);
if (!buf.AsSpan(0, (int)got).SequenceEqual(cb.AsSpan(0, (int)got))) return false;
off += got;
}
return true;
}
}
+13
View File
@@ -0,0 +1,13 @@
<!-- NSP 解包工具(含更新包合并):LibHac 读 NCA,补丁 NCA 叠在基础 NCA 上取合并后的 RomFS。hactool 读不了新式补丁 NCA(报 Invalid BKTR layout!)。 -->
<!-- NSP extraction tool (including update merging): LibHac reads the NCAs and layers the patch NCA over the base NCA to get the merged RomFS. hactool cannot read newer patch NCAs (it reports Invalid BKTR layout!). -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LibHac" Version="0.19.0" />
</ItemGroup>
</Project>
+6
View File
@@ -62,6 +62,11 @@ CUTS = {
# Where this differs from hardware is not known (every allocation size before this block is determined by game code and data); for now every resource heap gets +16 KB.
(0x1e62fc, 'e00317aa', 'e0124091', '资源堆尺寸:mov x0,x23 → add x0,x23,#0x4000(ExpHeap::create 的尺寸参数 +16 KB)'),
],
# Super Mario Bros. Wonder 1.2.1(v327680)main:同上。位置由 dm/tools/nso_fnmap.py 从 v0 搬来(所在函数 0x1e603c → 0x1cf6f0 屏蔽地址后全同,ExpHeap::create 0x4aac38 → 0x4c4a18)
# Super Mario Bros. Wonder 1.2.1 (v327680) main: same as above. Location carried over from v0 by dm/tools/nso_fnmap.py (containing function 0x1e603c → 0x1cf6f0 identical after address masking, ExpHeap::create 0x4aac38 → 0x4c4a18)
'ff773e90972d544eb79406eaa65396d5': [
(0x1cf9b0, 'e00317aa', 'e0124091', '资源堆尺寸:mov x0,x23 → add x0,x23,#0x4000(ExpHeap::create 的尺寸参数 +16 KB)'),
],
'db0507f36b84200ac05f75d76ed5026f': [
# (放进所有产物共用的基础改写):游戏显存分配器预算 260 MB → 512 MB
# (Placed in the base rewrite shared by all build outputs): game video memory allocator budget 260 MB → 512 MB
@@ -867,6 +872,7 @@ elif os.path.exists(P('rules.tsv')):
with open(P('fixups.tsv'), 'w') as f:
f.write(f'# PATCH_OFF\t{PATCH_OFF:#x}\n# KEY_PAGE_OFF\t{KEY_PAGE_OFF:#x}\n# KEY_PAGE_SIZE\t0x4000\n# TEXT_END\t{TEXT_END:#x}\n# RO_OFF\t{RO_OFF:#x}\n# DATA_OFF\t{DATA_OFF:#x}\n')
for k in KEY_INDEX: f.write(f'# KEY_{k}_IDX\t{KEY_INDEX[k]}\n')
f.write(f'# BUILD_ID\t{BUILD_ID}\n') # 宿主据此认构建:写死地址的功能只在它们所属的构建上开 | the host identifies the build by it: features with hard-coded addresses only run on the build they belong to
with open(P('gates.tsv'), 'w') as f:
for ret, k, arg, orig in gates: f.write(f'{ret:#x}\t{k}\t{arg}\t{orig:#x}\n')
with open(P('sites.tsv'), 'w') as f: