diff --git a/runtime/gfx.h b/runtime/gfx.h index 805fb83a..15e55e2d 100644 --- a/runtime/gfx.h +++ b/runtime/gfx.h @@ -79,6 +79,10 @@ void gfx_shader_prepare_recorded(int threads, int procs); // nx2navi.exe --prepare-shaders: translates /seed-answers.bin + seed-programs.bin (tools/prepare_shaders.py) and // writes /shader-prepared.bin (stage, priority, input id per variant). 0 on success. int gfx_shader_prepare_seed(const char *dir, int threads, int procs); +// gfx_shader_guess.c: the answers of a prepared default variant, read from its first translation s: vertex inputs the +// shader reads as integers become uint, a compute shader's textures and images take the types its translation +// declares. 1 when *out differs from *a (translate again with *out). +int gfx_shader_guess_refine(const GfxShader *s, int stage, const GfxShaderAnswers *a, GfxShaderAnswers *out); typedef struct { uint8_t iid[32]; uint8_t stage, prio; } GfxWarmEnt; // prio 0: met in play; 1..3: prepared size_t gfx_shader_warm_list(GfxWarmEnt **out); // malloc'd; recorded first, then prepared; needs recording on // Disk-cache load the caller owns (*owned 1: release it), or the memory cache's entry (*owned 0). NULL if not cached. diff --git a/runtime/gfx_shader.c b/runtime/gfx_shader.c index 8b1d7792..ca3be0e8 100644 --- a/runtime/gfx_shader.c +++ b/runtime/gfx_shader.c @@ -2118,7 +2118,10 @@ void gfx_shader_record_flush(int force) { // ---- preparation from record files: the answers met in play (NX2NAVI_TEST_PREPARE_SHADERS=1) or the seed that // tools/prepare_shaders.py writes from its shader list (nx2navi.exe --prepare-shaders). Both use the record // layouts above; a seed record has a zero input id (the use mask, hence the id, is the translator's to find) and its -// pad byte holds the list priority (0 title, 1 warm-up, 2 rest). +// pad byte holds the list priority (0 title, 1 warm-up, 2 rest), with SEED_DEFAULT set on a default variant: its +// answers are read again from its first translation (gfx_shader_guess.c) and the variant it resolves to is the +// translation with those, when they differ. +#define SEED_DEFAULT 0x80 // tools/prepare_shaders.py DEFAULT_FLAG typedef struct { const uint8_t *rec; } PrgIdx; typedef struct { PrgIdx *idx; size_t n; } PrgList; typedef struct { @@ -2126,7 +2129,7 @@ typedef struct { const uint8_t *ans; size_t n_ans; uint8_t (*iid)[32]; // per answers record: the resolved input id, zero on failure - volatile int64_t next, done, missing, failed; + volatile int64_t next, done, missing, failed, refined; } Prep; static int prg_cmp(const void *x, const void *y) { return memcmp(((const PrgIdx *)x)->rec + 12, ((const PrgIdx *)y)->rec + 12, 32); } @@ -2152,9 +2155,12 @@ static void prep_worker(void *arg) { uint32_t st, len; memcpy(&st, e->rec, 4), memcpy(&len, e->rec + 4, 4); const uint8_t *control = e->rec + 44, *code = control + CONTROL_SIZE, *c1 = code + len; - GfxShaderAnswers a; + GfxShaderAnswers a, b; unpack_answers(r + 68, &a); - const GfxShader *s = gfx_shader_get((int)st, control, code, len, c1, &a); + const GfxShader *s = gfx_shader_get((int)st, control, code, len, c1, &a), *t; + if (s && r[ANS_REC - 1] & SEED_DEFAULT && gfx_shader_guess_refine(s, (int)st, &a, &b) && + (t = gfx_shader_get((int)st, control, code, len, c1, &b))) + s = t, plat_inc64(&p->refined); if (!s) plat_inc64(&p->failed); else if (p->iid) memcpy(p->iid[i], s->iid, 32); plat_inc64(&p->done); @@ -2195,8 +2201,9 @@ static long long prepare_files(const char *ans_path, const char *prg_path, int t (unsigned long long)((uint64_t)n_xlated - xl0), (double)plat_now_ms() - t0); svc_set_limit(svc_runtime_limit()); rt_log("gfx_shader: prepare: %zu %s of %zu programs: %llu translated, %lld failed, %lld without a program, the rest " - "cached; %.0f ms", p.n_ans, what, p.progs.n, (unsigned long long)((uint64_t)n_xlated - xl0), (long long)p.failed, - (long long)p.missing, (double)plat_now_ms() - t0); + "cached; %lld default variants with refined answers; %.0f ms", p.n_ans, what, p.progs.n, + (unsigned long long)((uint64_t)n_xlated - xl0), (long long)p.failed, (long long)p.missing, (long long)p.refined, + (double)plat_now_ms() - t0); free(p.progs.idx), free(pb); if (ans_out) *ans_out = ab; else free(ab); @@ -2236,7 +2243,8 @@ int gfx_shader_prepare_seed(const char *dir, int threads, int procs) { if (!memcmp(iid[i], zero, 32) || !set_add(&seen, hash8(iid[i]))) continue; uint8_t *r = recs + u * PRP_REC; const uint8_t *a = ab + (size_t)i * ANS_REC; - r[0] = a[0] < 6 ? a[0] : 0, r[1] = a[ANS_REC - 1] > 2 ? 2 : a[ANS_REC - 1]; + uint8_t prio = a[ANS_REC - 1] & ~SEED_DEFAULT; + r[0] = a[0] < 6 ? a[0] : 0, r[1] = prio > 2 ? 2 : prio; memcpy(r + 4, iid[i], 32); by[r[1]]++, stages[r[0]]++, u++; } diff --git a/runtime/gfx_shader_guess.c b/runtime/gfx_shader_guess.c new file mode 100644 index 00000000..5b3c73ee --- /dev/null +++ b/runtime/gfx_shader_guess.c @@ -0,0 +1,99 @@ +// Bind-state answers for a prepared default variant, read from its first translation (gfx_shader.c prep_worker, for +// seed records tools/prepare_shaders.py flags as default variants). A default variant guesses the state play binds: +// every texture a 2D one, float vertex inputs and colour outputs. Against the 3,093 (program, answers) pairs a play +// session of Ocarina recorded, that guess matched 89.5%: vertex 77.3%, compute 88.1%, fragment 99.6%. With two readings +// of the first translation, one more translation for the programs they change, --prepare-shaders matched 98.3% of them +// (vertex 97.1%, compute 96.0%, fragment 99.6%; last-frame vertex variants, vk_mvprev.c, are not prepared): +// +// - Vertex inputs. A vertex attribute without an answer is declared float, and a shader that reads it as an integer +// then bitcasts the loaded value to an integer type. Such inputs are answered uint: of the 313 integer inputs met +// in play, all were uint and all were bitcast, against 36 of 3,339 float ones. +// - Compute textures and images. Translated without texture answers (the seed gives a compute default variant none), +// the translator declares each texture and image with the type its instructions name; those types become the +// answers, image formats included, and a slot it queries without declaring one keeps the 2D default. Compute misses +// dropped from 43 to 13. A compute shader play meets untranslated is translated and its pipeline built on the replay +// thread (vk_exec_draw.c, the dispatch's shader_for call), so each miss is a stall; the prepare step's driver builds +// of compute pipelines averaged 0.96 s each, 16 at a time, on an RTX 5090. +#include +#include + +#include "gfx.h" + +enum { OP_TYPE_INT = 21, OP_TYPE_VECTOR = 23, OP_VARIABLE = 59, OP_LOAD = 61, OP_ACCESS_CHAIN = 65, + OP_IN_BOUNDS_ACCESS_CHAIN = 66, OP_DECORATE = 71, OP_BITCAST = 124 }; +enum { DEC_LOCATION = 30, SC_INPUT = 1 }; +#define ID_CAP (1u << 20) + +// Input Locations (bit mask) whose loaded value some OpBitcast turns into an integer scalar or vector. +static uint32_t int_read_inputs(const uint32_t *w, uint64_t n) { + if (n < 5) return 0; + uint32_t bound = w[3] < ID_CAP ? w[3] : ID_CAP, found = 0; + int8_t *loc = malloc(bound); // per id: the input Location it reads, -1 none + uint8_t *is_int = calloc(bound, 1), *located = calloc(bound, 1); + int8_t *dec = malloc(bound); // Location decorations + if (!loc || !is_int || !located || !dec) goto out; + memset(loc, -1, bound), memset(dec, -1, bound); + for (uint64_t i = 5; i < n;) { // declarations come before the code that uses them + uint32_t op = w[i] & 0xffff, len = w[i] >> 16; + if (!len || i + len > n) break; + const uint32_t *a = w + i + 1; + if (op == OP_DECORATE && len >= 4 && a[0] < bound && a[1] == DEC_LOCATION && a[2] < 32) dec[a[0]] = (int8_t)a[2]; + else if (op == OP_TYPE_INT && len >= 2 && a[0] < bound) is_int[a[0]] = 1; + else if (op == OP_TYPE_VECTOR && len >= 3 && a[0] < bound && a[1] < bound) is_int[a[0]] = is_int[a[1]]; + else if (op == OP_VARIABLE && len >= 4 && a[1] < bound && a[2] == SC_INPUT && dec[a[1]] >= 0) + loc[a[1]] = dec[a[1]], located[a[1]] = 1; + else if ((op == OP_ACCESS_CHAIN || op == OP_IN_BOUNDS_ACCESS_CHAIN) && len >= 4 && a[1] < bound && a[2] < bound && + located[a[2]]) + loc[a[1]] = loc[a[2]], located[a[1]] = 1; + else if (op == OP_LOAD && len >= 4 && a[1] < bound && a[2] < bound && located[a[2]]) + loc[a[1]] = loc[a[2]]; // a loaded value: not a pointer, so chains do not continue from it + else if (op == OP_BITCAST && len >= 4 && a[0] < bound && a[2] < bound && is_int[a[0]] && !located[a[2]] && + loc[a[2]] >= 0) + found |= 1u << loc[a[2]]; + i += len; + } +out: + free(loc), free(is_int), free(located), free(dec); + return found; +} + +// The translator's SamplerType (GfxShaderRes.type) as a GfxShaderAnswers tex_type / img_type code; 0 unknown. +static uint8_t answer_type(uint16_t t) { + int arr = t & 0x100, ms = t & 0x200; + switch (t & 0xff) { + case 1: return arr ? GFX_TEX_1D_ARRAY : GFX_TEX_1D; + case 2: return GFX_TEX_BUFFER; + case 3: return ms ? (arr ? GFX_TEX_2D_MS_ARRAY : GFX_TEX_2D_MS) : arr ? GFX_TEX_2D_ARRAY : GFX_TEX_2D; + case 4: return GFX_TEX_3D; + case 5: return arr ? GFX_TEX_CUBE_ARRAY : GFX_TEX_CUBE; + default: return 0; + } +} + +int gfx_shader_guess_refine(const GfxShader *s, int stage, const GfxShaderAnswers *a, GfxShaderAnswers *out) { + *out = *a; + if (!s) return 0; + if (stage == GFX_STAGE_VS) { + uint64_t words = 0; + const uint32_t *w = gfx_shader_spirv(s, &words); + GfxShaderIo io[32]; + uint32_t ints = w ? int_read_inputs(w, words) & gfx_shader_inputs(s, io) : 0; + for (int l = 0; l < 32; l++) + if (ints >> l & 1 && out->attr_type[l] == GFX_TYPE_FLOAT) out->attr_type[l] = GFX_TYPE_UINT; + } else if (stage == GFX_STAGE_CS) { + // a slot the translation queries without declaring a texture there (a bindless one) keeps the 2D default + for (int l = 0; l < 32; l++) + if (!out->tex_type[l]) out->tex_type[l] = GFX_TEX_2D; + const GfxShaderRes *r; + int n = gfx_shader_resources(s, GFX_RES_TEX, &r); + for (int i = 0; i < n; i++) + if (r[i].slot >= 0 && r[i].slot < 32 && answer_type(r[i].type)) out->tex_type[r[i].slot] = answer_type(r[i].type); + n = gfx_shader_resources(s, GFX_RES_IMG, &r); + for (int i = 0; i < n; i++) + if (r[i].slot >= 0 && r[i].slot < 32 && answer_type(r[i].type)) { + out->img_type[r[i].slot] = answer_type(r[i].type); + if (r[i].format) out->img_format[r[i].slot] = r[i].format; + } + } + return memcmp(out, a, sizeof *a) != 0; +} diff --git a/tools/prepare_shaders.py b/tools/prepare_shaders.py index e38c6e5d..3b054df2 100644 --- a/tools/prepare_shaders.py +++ b/tools/prepare_shaders.py @@ -13,7 +13,10 @@ build's nx2navi.exe in the record layouts of runtime/gfx_shader.c ("answers met seed-answers.bin "NX2NSANS": stage, code hash, input id (zero: the runtime computes it), answers, priority Priority (the pad byte): 0 shaders the list's warm rows flag as title/menu (t), 1 other warm rows (S, C, R), 2 the -rest; a default variant takes its shader's priority. Then it runs `nx2navi.exe --prepare-shaders`, which translates every record into the build's shader cache, writes +rest; a default variant takes its shader's priority and sets DEFAULT_FLAG (bit 7). The runtime reads a flagged +record's first translation and translates it again with better answers where it reads other ones +(runtime/gfx_shader_guess.c): integer vertex inputs, and the texture and image types a compute shader's translation +declares, for which a compute default variant goes without texture answers. Then it runs `nx2navi.exe --prepare-shaders`, which translates every record into the build's shader cache, writes shader-prepared.bin (one record per variant, in priority order) and builds every variant's stage library or compute pipeline once, so the GPU driver's own disk cache holds them (vk_pipeline.c "stage library prebuild"). The seed files can be deleted afterwards. @@ -59,6 +62,7 @@ TYPES = {'float': 0, 'sint': 1, 'uint': 2} DEFAULT = {'tex': {str(slot): '2d' for slot in range(32)}} # answers outside a shader's use mask are dropped CONTROL = 0x880 ANS_REC = 4 + 32 + 32 + 147 + 1 +DEFAULT_FLAG = 0x80 # pad byte: a default variant, its answers refined from its first translation (gfx_shader_guess.c) JOURNAL = 'shader-prepare-journal.bin' # vk_pipeline.c skip_init: a 64-byte slot per worker, input id first RETRIES = 3 @@ -121,10 +125,12 @@ def answers(deps, stage, control): attr[int(loc)] = TYPES[t] for idx, t in deps.get('fout', {}).items(): fout[int(idx)] = TYPES[t] - local = [0, 0, 0] # vk_exec_draw.c answers_for: zero for graphics stages (they are in the input id) - if stage == 5: # compute: the control section's local size (NVN2 at +0x58), 0 read as 1 + local = [0, 0, 0] # vk_exec_draw.c answers_for: zero for vertex and fragment programs (they are in the input id) + if stage not in (STAGES['vs'], STAGES['ps']): + # compute, task and mesh: the three words at the control section's local size (NVN2 at +0x58), 0 read as 1, + # each cut to 16 bits as answers_for stores them (task and mesh programs keep other fields there) local = struct.unpack_from('= size and c1_end > c1_start and c1_end - size < 1 << 20 else size + if c1_start >= size and c1_end >= c1_start and c1_end <= min(code_size, len(code)): + return c1_start, c1_end + return min(size, len(code)), min(size, len(code)) return struct.unpack_from('/ (RomFS image plus index.json as tools/romfs_index.py reads them, and exefs/). Writes under build//shaders/ only: code/.code translator input (graphics: SPH + instructions, compute: instructions) - jobs.json key -> job, source path, origin (bnsh|sharcb|main) + jobs.json key -> job, source path, origin (bnsh|sharcb|ndsr|main) spv/.spv with --translate failures.txt with --translate, when something failed: groups by message @@ -173,7 +173,7 @@ def sharcb_shaders(data, rejected): if not 32 <= start <= pool or pool + pool_size > len(data): raise ValueError('agl shader package layout outside the file') seen = set() - for size in (b'\x80\x08\x00\x00', b'\x8c\x08\x00\x00'): + for size in (b'\x80\x08\x00\x00', b'\x8c\x08\x00\x00', b'\xe0\x00\x00\x00'): at = start while (at := data.find(size, at, pool)) >= 0: record, at = at - 8, at + 4 @@ -181,6 +181,12 @@ def sharcb_shaders(data, rejected): continue code_off = struct.unpack_from('= pool_size: continue control = data[control_at:control_at + 0x880] @@ -198,6 +204,52 @@ def sharcb_shaders(data, rejected): yield key, stage, control, code +def ndsr_shaders(data): + """NDSR packages (Lib/agl/agl_ndsr*.pck: compute programs). Items of 8 bytes, : + an NVN2 control section is kind 6 with size 0xe0 and the item after it, kind 7, is its code.""" + if data[:4] != b'NDSR': + raise ValueError('expected an NDSR package') + seen = set() + for at in range(4, len(data) - 16, 4): + off, word, code_off, code_word = struct.unpack_from('<4I', data, at) + if word != NVN2_CONTROL << 4 | 6 or code_word & 15 != 7 or off + NVN2_CONTROL > len(data): + continue + control = data[off:off + NVN2_CONTROL] + stage = NVN2_STAGES.get(struct.unpack_from('> 16) + room = code_word >> 4 + if control[:4] != CONTROL_MAGIC or not stage or code_off + room > len(data): + continue + control += bytes(0x880 - NVN2_CONTROL) + size, c1_start, c1_end = struct.unpack_from('= size and c1_end >= c1_start and c1_end <= room else size + if not 0 < length <= room: + continue + code = data[code_off:code_off + length] + key = digest(control + code) + if key not in seen: + seen.add(key) + yield key, stage, control, code + + +def nvn2_sharcb_record(data, start, pool, pool_size, code_off, control_at): + """(key, stage, control, code) of an NVN2 .sharcb record, as nvn2_stages cuts them, or None. A size word of 0xe0 + is common, so a candidate counts only with the control magic and a known stage.""" + if not start <= control_at <= pool - NVN2_CONTROL or code_off >= pool_size: + return None + control = data[control_at:control_at + NVN2_CONTROL] + stage = NVN2_STAGES.get(struct.unpack_from('> 16) + if control[:4] != CONTROL_MAGIC or not stage: + return None + control += bytes(0x880 - NVN2_CONTROL) + size, c1_start, c1_end = struct.unpack_from('= size and c1_end >= c1_start and c1_end <= room else size + if not 0 < length <= room: + return None + code = data[pool + code_off:pool + code_off + length] + return digest(control + code), stage, control, code + + def nso_image(path): """The decompressed text, rodata and data segments of an NSO, concatenated (enough to find embedded BNSH files).""" raw = path.read_bytes() @@ -222,7 +274,8 @@ def startup_package(name): # The RomFS directories that can hold shaders: the agl and gsys packages, the effects, terrain, the UI layout archives # and the game's own shader packs. Every shader of 1.1.0 (27,576) comes from these six; they are 1,996 entries and # 147 MiB, 0.9% of a RomFS whose every file the walk below used to read and decompress first (65 s against 2.8 s). -SHADER_DIRS = ('Shader/', 'Effect/', 'Lib/agl/', 'Lib/Terrain/', 'Lib/gsys/', 'UI/LayoutArchive/') +# Ocarina keeps its UI layout shaders (MergeShader) in Layout2Shader/. +SHADER_DIRS = ('Shader/', 'Effect/', 'Lib/agl/', 'Lib/Terrain/', 'Lib/gsys/', 'UI/LayoutArchive/', 'Layout2Shader/') def inventory(romfs, exefs, everything=True): @@ -268,6 +321,9 @@ def inventory(romfs, exefs, everything=True): if data[:4] == b'BAHS': add('sharcb', name, sharcb_shaders(data, rejected), True) return + if data[:4] == b'NDSR': + add('ndsr', name, ndsr_shaders(data), True) + return if data[:4] == b'SARC': if depth > 4: return @@ -284,7 +340,7 @@ def inventory(romfs, exefs, everything=True): if not everything and not name.startswith(SHADER_DIRS): continue raw = read(name) - if raw[:4] not in (ZSTD, b'SARC', b'FSHA', b'BNSH', b'BAHS') and b'BNSH\0\0\0\0' not in raw: + if raw[:4] not in (ZSTD, b'SARC', b'FSHA', b'BNSH', b'BAHS', b'NDSR') and b'BNSH\0\0\0\0' not in raw: continue visit('bnsh', name, decode(raw) if raw[:4] == ZSTD else raw, startup_package(name)) for module in sorted(exefs.iterdir()): @@ -387,10 +443,10 @@ def main(): out.mkdir(parents=True, exist_ok=True) shaders, rejected = inventory(extracted / 'romfs', extracted / 'exefs') jobs = write_shaders(shaders, out, args.all) - a, b, c = (sum(1 for j in jobs.values() if j['origin'] == o) for o in ('bnsh', 'sharcb', 'main')) + a, b, n, c = (sum(1 for j in jobs.values() if j['origin'] == o) for o in ('bnsh', 'sharcb', 'ndsr', 'main')) print(f'{len(shaders)} unique shaders found in all, {len(jobs)} selected; {rejected} .sharcb record candidates ' f'without a control section rejected', flush=True) - summary = f'shaders {len(jobs)} (bnsh {a}, sharcb {b}, main {c})' + summary = f'shaders {len(jobs)} (bnsh {a}, sharcb {b}, ndsr {n}, main {c})' print(f'extracted {summary} in {time.monotonic() - t0:.1f} s', flush=True) if not args.translate: print(f'{summary}, total {time.monotonic() - t0:.1f} s')