diff --git a/changes/r3d-fault-loud.md b/changes/r3d-fault-loud.md new file mode 100644 index 00000000..9d13a0ff --- /dev/null +++ b/changes/r3d-fault-loud.md @@ -0,0 +1,11 @@ +bump: minor +type: fix +**A renderer that cannot draw says so and stops.** A `LUDIC_HOME` handed over with its `~` unexpanded +(`env LUDIC_HOME=~/x`) left render3d without its shaders: "vulkan cannot start" was printed, and the run +went on, logging "no SPIR-V variant" for every program - a world with no terrain, shadows or impostors. +Now `r3d_home` puts a leading `~` under `$HOME`; the SPIR-V manifest ends with `E ` (written by +`ludic-dev shaders`) and one without it, or miscounted, is refused; and every such failure is a fault +(`gpu_fault.ludic`): `r3d_open` and each `r3d_load_step` return false on one, a program with no variant is +listed, and a game asks `r3d_fault()` and ends with `r3d_fault_exit(code)`, which prints the reason and +every missing program. `tests/manifest_test.ludic` holds the parse, the refusals, `r3d_tilde` and that +every program in variants.list resolves in the shipped manifest. diff --git a/packages/ludic.render3d/env.ludic b/packages/ludic.render3d/env.ludic index e924d5c2..bf8c28a8 100644 --- a/packages/ludic.render3d/env.ludic +++ b/packages/ludic.render3d/env.ludic @@ -120,6 +120,8 @@ export state Render3dState { gpu_kind: int = 0 # the backend running; 0 until gpu_select gpu_wanted: int = 0 # what was asked for (setting or R3D_GFX) gpu_fallback_reason: string = null # why the wanted backend is not the one running + r3d_fault: string = "" # why the renderer cannot draw what it was asked to (gpu_fault.ludic) + gvk_missing: []string = null # the programs asked for with no SPIR-V variant gpu_s_depth_test: int = -1 gpu_s_depth_func: int = -1 gpu_s_depth_write: int = -1 diff --git a/packages/ludic.render3d/gpu.ludic b/packages/ludic.render3d/gpu.ludic index c37a508b..e1d6e307 100644 --- a/packages/ludic.render3d/gpu.ludic +++ b/packages/ludic.render3d/gpu.ludic @@ -43,7 +43,7 @@ function gpu_select(render3d_st: mut Render3dState) -> int { else if not gvk_init(render3d_st) { render3d_st.gpu_fallback_reason = render3d_st.gvk_why } else if not gvk_manifest(render3d_st) { render3d_st.gpu_fallback_reason = "the renderer's SPIR-V manifest is missing" } else { render3d_st.gpu_kind = GPU_VK } - if render3d_st.gpu_kind != GPU_VK { print(`r3d: vulkan cannot start: {render3d_st.gpu_fallback_reason}`) } + if render3d_st.gpu_kind != GPU_VK { gpu_fault_set(render3d_st, `vulkan cannot start: {render3d_st.gpu_fallback_reason}`) } return render3d_st.gpu_kind } function gpu_backend(render3d_st: Render3dState) -> string { return "vulkan" } diff --git a/packages/ludic.render3d/gpu_fault.ludic b/packages/ludic.render3d/gpu_fault.ludic new file mode 100644 index 00000000..f5a7106b --- /dev/null +++ b/packages/ludic.render3d/gpu_fault.ludic @@ -0,0 +1,39 @@ +# gpu_fault.ludic - the renderer at fault: its files not found, its manifest missing or cut short, a +# program the game asked for with no SPIR-V variant. A fault is never a quiet start on nothing - a +# world with no terrain, shadows or impostors that only logged it. The game asks r3d_fault() and +# stops: r3d_fault_exit in a headless or dev build, its own failure screen in a player's. + +# the first fault's reason, printed once; later ones only add to the list of missing programs +@alloc_ok("once a run: a fault ends the run") +function gpu_fault_set(render3d_st: mut Render3dState, why: string) -> void { + if len(render3d_st.r3d_fault) > 0 { return } + render3d_st.r3d_fault = why + print(`r3d: FAULT: {why}`) +} +# a program asked for that the manifest has no variant of +@alloc_ok("once a run: a fault ends the run") +function gpu_variant_missing(render3d_st: mut Render3dState, key: string) -> void { + if render3d_st.gvk_missing == null { render3d_st.gvk_missing = new []string } + push(render3d_st.gvk_missing, key) + print(`r3d: vulkan: no SPIR-V variant for {key}`) + gpu_fault_set(render3d_st, "a program the game asked for has no SPIR-V variant (ludic-dev shaders, and its variants.list)") +} + +# why the renderer cannot draw what it was asked to, or "" when it can +export function r3d_fault(render3d_st: Render3dState) -> string { return render3d_st.r3d_fault } +# how many programs had no variant +export function r3d_fault_missing(render3d_st: Render3dState) -> int { + if render3d_st.gvk_missing == null { return 0 } + return len(render3d_st.gvk_missing) +} +# the i-th of them, as `vertex|fragment|defines` +export function r3d_fault_missing_at(render3d_st: Render3dState, i: int) -> string { return render3d_st.gvk_missing[i] } + +# the fault and every missing program printed, then the run ends with `code` (never 0) +export function r3d_fault_exit(render3d_st: Render3dState, code: int) -> void { + print(`r3d: the renderer cannot run: {render3d_st.r3d_fault}`) + for i in 0 .. r3d_fault_missing(render3d_st) { print(`r3d: missing {r3d_fault_missing_at(render3d_st, i)}`) } + var c = code + if c == 0 { c = 1 } + Os.exit(c) +} diff --git a/packages/ludic.render3d/gpu_manifest.ludic b/packages/ludic.render3d/gpu_manifest.ludic index 034f410c..4f403b9e 100644 --- a/packages/ludic.render3d/gpu_manifest.ludic +++ b/packages/ludic.render3d/gpu_manifest.ludic @@ -10,6 +10,7 @@ # U # T a sampler, one binding across both stages # I a vertex input +# E the end: how many P lines came before it # This reads it, and answers the questions a backend asks while drawing: which variant a # program is, where a uniform lives in its stage's block, which binding a sampler has. # ============================================================================ @@ -46,47 +47,74 @@ function gpu_variant_new(id: string, vs: string, fs: string, defs: string) -> Gp return v } -# Read a manifest; returns how many programs it holds (0: none, or no file). +# Read a manifest; returns how many programs it holds (0: none, no file, or refused - and then +# the renderer is at fault, so nothing starts on a partial list) @alloc_ok("start-up: the device, its tables, the programs, the passes and the world's first textures are made once, before play") function gpu_manifest_load(render3d_st: mut Render3dState, path: string) -> int { render3d_st.gpu_variants = new []GpuVariant let text = Fs.read_text(path) - if text == null { return 0 } + if text == null { gpu_fault_set(render3d_st, `no SPIR-V manifest at {path}`); return 0 } + let vs = gpu_manifest_parse(text) + if vs == null { gpu_fault_set(render3d_st, `the SPIR-V manifest at {path} is cut short or not a manifest (no E line, or its count is not the programs')`); return 0 } + render3d_st.gpu_variants = vs + return len(vs) +} + +# A manifest's programs, or null when it is refused: `ludic-dev shaders` ends one with `E `, +# so a file cut off anywhere (a torn copy, a short read) is told apart from one with fewer programs. +@alloc_ok("start-up: the device, its tables, the programs, the passes and the world's first textures are made once, before play") +function gpu_manifest_parse(text: string) -> []GpuVariant { + let out = new []GpuVariant let lines = Text.split(text, "\n") var cur: GpuVariant = null + var ended = -1 for li in 0 .. len(lines) { let ln = lines[li] let lp: pointer = ln if len(lp) < 2 or lp[0] == '#' { continue } + if ended >= 0 { return null } # nothing may follow the end line let f = Text.split(ln, " ") - if lp[0] == 'P' and len(f) >= 4 { - # the defines are the rest of the line, spaces and all - var defs = "" - var k = 4 - while k < len(f) { - if k > 4 { defs = defs + " " } - defs = defs + f[k] - k += 1 - } - cur = gpu_variant_new(f[1], f[2], f[3], defs) - push(render3d_st.gpu_variants, cur) + if lp[0] == 'E' and len(f) == 2 { + ended = Text.to_int(f[1]) continue } - if cur == null { continue } - if lp[0] == 'B' and len(f) >= 5 { - if f[2] == "vert" { cur.vblock = Text.to_int(f[4]) } else { cur.fblock = Text.to_int(f[4]) } - } else if lp[0] == 'U' and len(f) >= 8 { - var stage = 1 - if f[2] == "vert" { stage = 0 } - push(cur.u_stage, stage); push(cur.u_name, f[3]); push(cur.u_off, Text.to_int(f[4])) - push(cur.u_type, f[5]); push(cur.u_count, Text.to_int(f[6])); push(cur.u_stride, Text.to_int(f[7])) - } else if lp[0] == 'T' and len(f) >= 4 { - push(cur.t_name, f[2]); push(cur.t_bind, Text.to_int(f[3])) - } else if lp[0] == 'I' and len(f) >= 5 { - push(cur.i_name, f[2]); push(cur.i_loc, Text.to_int(f[3])); push(cur.i_type, f[4]) + if lp[0] == 'P' and len(f) >= 4 { + cur = gpu_variant_new(f[1], f[2], f[3], gpu_manifest_defs(f)) + push(out, cur) + continue } + if cur != null { gpu_manifest_row(cur, lp[0], f) } + } + if ended < 0 or ended != len(out) { return null } + return out +} +# a P line's defines: the rest of the line, spaces and all +@alloc_ok("start-up: the device, its tables, the programs, the passes and the world's first textures are made once, before play") +function gpu_manifest_defs(f: []string) -> string { + var defs = "" + var k = 4 + while k < len(f) { + if k > 4 { defs = defs + " " } + defs = defs + f[k] + k += 1 + } + return defs +} +# one B / U / T / I line into the variant it follows +@alloc_ok("start-up: the device, its tables, the programs, the passes and the world's first textures are made once, before play") +function gpu_manifest_row(cur: GpuVariant, kind: int, f: []string) -> void { + if kind == 'B' and len(f) >= 5 { + if f[2] == "vert" { cur.vblock = Text.to_int(f[4]) } else { cur.fblock = Text.to_int(f[4]) } + } else if kind == 'U' and len(f) >= 8 { + var stage = 1 + if f[2] == "vert" { stage = 0 } + push(cur.u_stage, stage); push(cur.u_name, f[3]); push(cur.u_off, Text.to_int(f[4])) + push(cur.u_type, f[5]); push(cur.u_count, Text.to_int(f[6])); push(cur.u_stride, Text.to_int(f[7])) + } else if kind == 'T' and len(f) >= 4 { + push(cur.t_name, f[2]); push(cur.t_bind, Text.to_int(f[3])) + } else if kind == 'I' and len(f) >= 5 { + push(cur.i_name, f[2]); push(cur.i_loc, Text.to_int(f[3])); push(cur.i_type, f[4]) } - return len(render3d_st.gpu_variants) } # the variant for a program as r3d_program names it (defines with real newlines) @@ -95,9 +123,13 @@ function gpu_variant_find(render3d_st: Render3dState, vs: string, fs: string, de } # ... or as variants.list writes it (';' for newlines) function gpu_variant_find_key(render3d_st: Render3dState, vs: string, fs: string, defs: string) -> GpuVariant { - if render3d_st.gpu_variants == null { return null } - for i in 0 .. len(render3d_st.gpu_variants) { - let v = render3d_st.gpu_variants[i] + return gpu_variant_in(render3d_st.gpu_variants, vs, fs, defs) +} +# ... in a list of variants (a parsed manifest), or null +function gpu_variant_in(vars: []GpuVariant, vs: string, fs: string, defs: string) -> GpuVariant { + if vars == null { return null } + for i in 0 .. len(vars) { + let v = vars[i] if v.vs == vs and v.fs == fs and v.defs == defs { return v } } return null diff --git a/packages/ludic.render3d/gpu_vk_draw.ludic b/packages/ludic.render3d/gpu_vk_draw.ludic index 1625907b..76fb1ae6 100644 --- a/packages/ludic.render3d/gpu_vk_draw.ludic +++ b/packages/ludic.render3d/gpu_vk_draw.ludic @@ -66,8 +66,10 @@ function gvk_program(render3d_st: mut Render3dState, p: int, key: string, spv_di let parts = Text.split(key, "|") var defs = "" if len(parts) > 2 { defs = parts[2] } + # no manifest at all is one fault already said, not a line for every program + if render3d_st.gpu_variants == null or len(render3d_st.gpu_variants) == 0 { return false } let v = gpu_variant_find_key(render3d_st, parts[0], parts[1], defs) - if v == null { print(`r3d: vulkan: no SPIR-V variant for {key}`); return false } + if v == null { gpu_variant_missing(render3d_st, key); return false } for q in 0 .. len(parts) { free(parts[q]) } free(parts) let vpath = `{spv_dir}/{v.id}.vert.spv` diff --git a/packages/ludic.render3d/programs.ludic b/packages/ludic.render3d/programs.ludic index f4db2a03..f236ca1e 100644 --- a/packages/ludic.render3d/programs.ludic +++ b/packages/ludic.render3d/programs.ludic @@ -14,28 +14,6 @@ function r3d_set_paths(render3d_st: mut Render3dState, root: string, assets: string) -> void { render3d_st.r3d_root = root; render3d_st.r3d_assets = assets } -# The install root, as the compiler computes it: $LUDIC_HOME, else the directory of the -# `ludic` on PATH. A game running from its own tree finds this package there. -@alloc_ok("once a run: the install root, asked only when the shaders are not beside the program") -function r3d_home() -> string { - let env = Os.env("LUDIC_HOME") - if env != null and env != "" { return env } - return `{Os.env("HOME")}/.ludic` -} - -# Settle r3d_root on first use: the package as checked out beside the project, else the -# copy that ships with the toolchain. -@alloc_ok("start-up: the device, its tables, the programs, the passes and the world's first textures are made once, before play") -function r3d_find_root(render3d_st: mut Render3dState) -> void { - if render3d_st.r3d_root_found { return } - render3d_st.r3d_root_found = true - if Fs.exists(`{render3d_st.r3d_root}/shaders/lighting.glsl`) { return } - let home = r3d_home() - let alt = `{home}/packages/ludic.render3d` - if Fs.exists(`{alt}/shaders/lighting.glsl`) { render3d_st.r3d_root = alt; return } - print(`r3d: cannot find the renderer's shaders (looked in {render3d_st.r3d_root}/shaders and {alt}/shaders)`) -} - # Build a program from a vertex + fragment file pair (defines apply to both). # R3D_PROGRAMS_LOG=: append every program built (vertex, fragment, defines) - the list a # backend that cannot compile shaders at run time (Vulkan: SPIR-V is built ahead) must cover diff --git a/packages/ludic.render3d/r3d.ludic b/packages/ludic.render3d/r3d.ludic index f4e52a9a..b0aac9aa 100644 --- a/packages/ludic.render3d/r3d.ludic +++ b/packages/ludic.render3d/r3d.ludic @@ -8,6 +8,7 @@ import "env.ludic" import "hooks.ludic" import "fmath.ludic" import "gpu.ludic" +import "gpu_fault.ludic" import "gpu_manifest.ludic" import "gpu_vk.ludic" import "vkmem_report.ludic" @@ -18,6 +19,7 @@ import "gpu_vk_readback.ludic" import "texture_dds.ludic" import "prof.ludic" import "drawstats.ludic" +import "r3d_root.ludic" import "programs.ludic" import "texture.ludic" import "baked_tex.ludic" diff --git a/packages/ludic.render3d/r3d_root.ludic b/packages/ludic.render3d/r3d_root.ludic new file mode 100644 index 00000000..ee221f70 --- /dev/null +++ b/packages/ludic.render3d/r3d_root.ludic @@ -0,0 +1,33 @@ +# r3d_root.ludic - where the renderer's own files are: the package checked out beside the project, +# else the copy in the toolchain ($LUDIC_HOME, else ~/.ludic). Not finding them is a fault +# (gpu_fault.ludic), never a quiet start on nothing. + +# The install root, as the compiler computes it: $LUDIC_HOME, else ~/.ludic. A leading "~" is +# the home directory: `env LUDIC_HOME=~/x` hands one over unexpanded, and nothing was found there. +@alloc_ok("once a run: the install root, asked only when the shaders are not beside the program") +function r3d_home() -> string { + let env = Os.env("LUDIC_HOME") + if env != null and env != "" { return r3d_tilde(env, Os.env("HOME")) } + return `{Os.env("HOME")}/.ludic` +} +# a path with a leading "~" or "~/" put under `home`; any other path as it is +@alloc_ok("once a run: the install root, asked only when the shaders are not beside the program") +function r3d_tilde(path: string, home: string) -> string { + if home == null or len(path) == 0 or path[0] != '~' { return path } + if len(path) == 1 { return home } + if path[1] != '/' { return path } # ~user is another user's, not ours to guess + return home + path[1 .. len(path)] +} + +# Settle r3d_root on first use: the package as checked out beside the project, else the +# copy that ships with the toolchain. +@alloc_ok("start-up: the device, its tables, the programs, the passes and the world's first textures are made once, before play") +function r3d_find_root(render3d_st: mut Render3dState) -> void { + if render3d_st.r3d_root_found { return } + render3d_st.r3d_root_found = true + if Fs.exists(`{render3d_st.r3d_root}/shaders/lighting.glsl`) { return } + let home = r3d_home() + let alt = `{home}/packages/ludic.render3d` + if Fs.exists(`{alt}/shaders/lighting.glsl`) { render3d_st.r3d_root = alt; return } + gpu_fault_set(render3d_st, `cannot find the renderer's shaders (looked in {render3d_st.r3d_root}/shaders and {alt}/shaders)`) +} diff --git a/packages/ludic.render3d/render.ludic b/packages/ludic.render3d/render.ludic index 942e7a9f..804c1445 100644 --- a/packages/ludic.render3d/render.ludic +++ b/packages/ludic.render3d/render.ludic @@ -119,7 +119,7 @@ function r3d_open(render3d_st: mut Render3dState, w: int, h: int, title: string) print(`r3d: {gl_width()}x{gl_height()} on {renderer}`) prof_init(render3d_st) cam_init(render3d_st, float(gl_width()) / float(gl_height())) - return true + return len(render3d_st.r3d_fault) == 0 } # The load, as steps a loading screen can show between: 0 the sky and its image-based light, @@ -128,6 +128,10 @@ function r3d_open(render3d_st: mut Render3dState, w: int, h: int, title: string) # grass and the sky pass. The order is r3d_init's. function r3d_load_count() -> int { return 4 + TERRAIN_INIT_STEPS } function r3d_load_step(render3d_st: mut Render3dState, i: int) -> bool { + if not r3d_load_step_run(render3d_st, i) { return false } + return len(render3d_st.r3d_fault) == 0 # a step that made a program with no variant fails +} +function r3d_load_step_run(render3d_st: mut Render3dState, i: int) -> bool { let t = i - 1 if i == 0 { # the default sky's path is made only when no path was given, and goes once the sky is read diff --git a/packages/ludic.render3d/shaders/spv/manifest.txt b/packages/ludic.render3d/shaders/spv/manifest.txt index ac8d72d4..ce1f66b0 100644 --- a/packages/ludic.render3d/shaders/spv/manifest.txt +++ b/packages/ludic.render3d/shaders/spv/manifest.txt @@ -3995,3 +3995,4 @@ T b20fdcb5 u_shadow 9 T b20fdcb5 u_tershadow 10 T b20fdcb5 u_tershadow_aux 11 T b20fdcb5 u_ts_height 12 +E 59 diff --git a/packages/ludic.render3d/tests/manifest_test.ludic b/packages/ludic.render3d/tests/manifest_test.ludic new file mode 100644 index 00000000..3b602d0e --- /dev/null +++ b/packages/ludic.render3d/tests/manifest_test.ludic @@ -0,0 +1,67 @@ +# manifest_test.ludic - the SPIR-V manifest read as the renderer reads it, with no device: a whole one +# parses, one cut short or miscounted is refused, every program variants.list names resolves in the +# shipped manifest, and a LUDIC_HOME with a leading "~" is found (it once started a run on nothing) +import "ludic.render3d/r3d.ludic" +program ManifestTest { + # never called: naming Input links the engine's runtime, which render3d's textures stand on + function links_runtime() -> bool { return Input.key_pressed(0) } + + function two() -> string { + var t = "# a manifest\nP aa fullscreen.vert sky.frag \nB aa frag 1 64\nU aa frag u_sun 0 vec3 1 0\n" + t = t + "P bb model.vert shadow.frag #define SHADOW_PASS;#define WIND;\nT bb u_tex 2\nI bb a_pos 0 vec3\n" + return t + } + + test "a whole manifest parses, and a program is found by its key" { + let vs = gpu_manifest_parse(two() + "E 2\n") + expect(vs != null) + expect_eq(len(vs), 2) + expect(gpu_variant_in(vs, "fullscreen.vert", "sky.frag", "") != null) + let sh = gpu_variant_in(vs, "model.vert", "shadow.frag", "#define SHADOW_PASS;#define WIND;") + expect(sh != null) + expect_eq(gpu_variant_sampler(sh, "u_tex"), 2) + expect(gpu_variant_in(vs, "model.vert", "shadow.frag", "#define SHADOW_PASS;") == null) + } + + test "a manifest cut short is refused" { + expect(gpu_manifest_parse(two()) == null) # no end line + let whole = two() + "E 2\n" + expect(gpu_manifest_parse(whole[0 .. len(whole) - 30]) == null) # cut mid-program + expect(gpu_manifest_parse("") == null) + } + + test "an end line that miscounts, or anything after it, is refused" { + expect(gpu_manifest_parse(two() + "E 3\n") == null) + expect(gpu_manifest_parse(two() + "E 1\n") == null) + expect(gpu_manifest_parse(two() + "E 2\nP cc a.vert b.frag \n") == null) + } + + test "every program variants.list names has a variant in the shipped manifest" { + let root = "packages/ludic.render3d/shaders" + let text = Fs.read_text(`{root}/spv/manifest.txt`) + expect(text != null) + let vs = gpu_manifest_parse(text) + expect(vs != null) + let list = Text.split(Fs.read_text(`{root}/variants.list`), "\n") + var asked = 0 + for i in 0 .. len(list) { + let ln = Text.trim(list[i]) + if len(ln) == 0 or ln[0] == '#' { continue } + let p = Text.split(ln, "|") + var defs = "" + if len(p) > 2 { defs = p[2] } + if gpu_variant_in(vs, p[0], p[1], defs) == null { print(`no variant for {ln}`) } + expect(gpu_variant_in(vs, p[0], p[1], defs) != null) + asked += 1 + } + expect_eq(asked, len(vs)) + } + + test "a leading tilde is the home directory" { + expect_eq(r3d_tilde("~/workspace/gpp-lang", "/Users/me"), "/Users/me/workspace/gpp-lang") + expect_eq(r3d_tilde("~", "/Users/me"), "/Users/me") + expect_eq(r3d_tilde("/opt/ludic", "/Users/me"), "/opt/ludic") + expect_eq(r3d_tilde("~other/x", "/Users/me"), "~other/x") + expect_eq(r3d_tilde("~/x", null), "~/x") + } +} diff --git a/tools/ludic-cli/shaders.ludic b/tools/ludic-cli/shaders.ludic index a27cf2ff..95cf8bd5 100644 --- a/tools/ludic-cli/shaders.ludic +++ b/tools/ludic-cli/shaders.ludic @@ -319,6 +319,8 @@ function cmd_shaders() -> int { made_c += 1 } } + # the end line: the renderer refuses a manifest without it, or whose count is not its programs' + sb_puts(mf, `E {string(made)}\n`) if not write_file(`{outdir}/manifest.txt`, sb_str(mf)) { err("shaders: cannot write manifest.txt\n"); return 1 } if check { let real = "packages/ludic.render3d/shaders/spv"