diff --git a/dm/games/0100CD801CE5E000/README.md b/dm/games/0100CD801CE5E000/README.md index 785ffeb..d5e3df9 100644 --- a/dm/games/0100CD801CE5E000/README.md +++ b/dm/games/0100CD801CE5E000/README.md @@ -56,6 +56,7 @@ LÖVE's graphics module is its own OpenGL backend, so its choices follow what th General host changes first needed here; each applies to every game: +- **Resolved: Homebrew .NET runtime discovery** for the first extraction and shader tools: [Homebrew .NET is on PATH but apphosts cannot find the runtime](../../../docs/reference/packing.md#homebrew-net-is-on-path-but-apphosts-cannot-find-the-runtime). - **The GL layer's core-profile paths** (`GL_CONTEXT_PROFILE_MASK`, persistently mapped buffers and fences, attribute bindings and current values, uniform queries, texture storage, 3D and array textures, swizzles, mipmaps, base-vertex and instanced draws, blits) in `hle_gl.m`, and **`DM_GL_STUBS=1`**: [The GL core profile and persistently mapped buffers](../../../docs/reference/rendering.md#the-gl-core-profile-and-persistently-mapped-buffers), [GL uniforms, pipelines and attributes](../../../docs/reference/rendering.md#gl-uniforms-pipelines-and-attributes), [GL textures, swizzles and uploads](../../../docs/reference/rendering.md#gl-textures-swizzles-and-uploads), [GL draws and framebuffers](../../../docs/reference/rendering.md#gl-draws-and-framebuffers), [Which GL functions a game calls](../../../docs/reference/rendering.md#which-gl-functions-a-game-calls). - **`loader_census.py` following registers** (LÖVE's glad), and `vkGetInstanceProcAddr` left out of `profile_guess.py`'s Vulkan count: same section. - **GL shaders captured at run time** (`DM_GL_CAPTURE`, `gl_capture.tsv`, the NSP script's `capture_shaders`): [development.md, The packing script in detail](../../../docs/development.md#the-packing-script-in-detail). diff --git a/dm/tools/tests/test_nsp_dotnet.py b/dm/tools/tests/test_nsp_dotnet.py new file mode 100644 index 0000000..f81a641 --- /dev/null +++ b/dm/tools/tests/test_nsp_dotnet.py @@ -0,0 +1,70 @@ +"""NSP script: Homebrew's dotnet wrapper must supply its runtime root to standalone apphosts. +No game files, keys or .NET installation needed.""" +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +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 Dotnet(unittest.TestCase): + def setUp(self): + cm = mock.patch.dict(os.environ, {'PATH': '/tools/bin'}, clear=True) + cm.__enter__(); self.addCleanup(cm.__exit__, None, None, None) + + def test_homebrew_runtime_reaches_explicit_and_inherited_children(self): + root = '/external/Tools With Spaces/Cellar/dotnet/10.0.402/libexec' + env = dict(os.environ, DOTNET_ROOT_X64='/caller/x64') + out = f'Microsoft.AspNetCore.App 10.0.13 [{root}/shared/Microsoft.AspNetCore.App]\n' + \ + f'Microsoft.NETCore.App 10.0.13 [{root}/shared/Microsoft.NETCore.App]\n' + with mock.patch.object(nsp.shutil, 'which', return_value='/tools/bin/dotnet') as which, \ + mock.patch.object(nsp.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, out)) as run: + nsp.configure_dotnet(env) + which.assert_called_once_with('dotnet', path='/tools/bin') + self.assertIs(run.call_args.kwargs['env'], env) + cmd = [sys.executable, '-c', 'import json, os; print(json.dumps(os.environ.get("DOTNET_ROOT")))'] + for child_env in (env, None): + r = subprocess.run(cmd, env=child_env, capture_output=True, text=True, check=True) + self.assertEqual(json.loads(r.stdout), root) + self.assertEqual(env['DOTNET_ROOT_X64'], '/caller/x64') + + def test_caller_root_wins(self): + os.environ.update(DOTNET_ROOT='/caller/runtime', DOTNET_ROOT_ARM64='/caller/arm64') + env = dict(os.environ) + with mock.patch.object(nsp.subprocess, 'run') as run: + nsp.configure_dotnet(env) + run.assert_not_called() + self.assertEqual(env, dict(os.environ)) + self.assertEqual(env['DOTNET_ROOT'], '/caller/runtime') + self.assertEqual(env['DOTNET_ROOT_ARM64'], '/caller/arm64') + + def test_missing_dotnet_leaves_environment_untouched(self): + env = dict(os.environ) + with mock.patch.object(nsp.shutil, 'which', return_value=None), mock.patch.object(nsp.subprocess, 'run') as run: + nsp.configure_dotnet(env) + run.assert_not_called() + self.assertEqual(env, dict(os.environ)) + + def test_failed_or_malformed_probe_does_not_export_wrapper_directory(self): + outputs = ('', 'Microsoft.AspNetCore.App 10.0.13 [/runtime/shared/Microsoft.AspNetCore.App]', + 'Microsoft.NETCore.App 10.0.13 [/runtime/shared/Microsoft.NETCore.App', + 'Microsoft.NETCore.App 10.0.13 [/not/a/runtime]') + failures = (OSError('cannot execute dotnet'), subprocess.TimeoutExpired('dotnet', 10), subprocess.CalledProcessError(1, 'dotnet')) + for result in (*outputs, *failures): + with self.subTest(result=result): + env = dict(os.environ) + kw = {'side_effect': result} if isinstance(result, Exception) else {'return_value': subprocess.CompletedProcess([], 0, result)} + with mock.patch.object(nsp.shutil, 'which', return_value='/homebrew/bin/dotnet'), mock.patch.object(nsp.subprocess, 'run', **kw): + nsp.configure_dotnet(env) + self.assertNotIn('DOTNET_ROOT', env) + self.assertNotIn('DOTNET_ROOT', os.environ) + + +if __name__ == '__main__': unittest.main() diff --git a/docs/agents/symptoms.md b/docs/agents/symptoms.md index ce393d5..7e51441 100644 --- a/docs/agents/symptoms.md +++ b/docs/agents/symptoms.md @@ -4,6 +4,7 @@ One line per symptom in the [subsystem references](../reference/): what you see ## Packing +- the first extraction stops with `nx_extract exit code 131` and `.NET location: Not found`, even though `dotnet` is on `PATH`. The shader translator can fail the same way. → [Homebrew .NET is on PATH but apphosts cannot find the runtime](../reference/packing.md#homebrew-net-is-on-path-but-apphosts-cannot-find-the-runtime) - `decompress NSZ` stops immediately with `nsz: error: unrecognized arguments: --keys …`. → [NSZ decompression rejects `--keys`](../reference/packing.md#nsz-decompression-rejects---keys) - clicking Prepare game stops before any build tools start, with `PermissionError: [Errno 1] Operation not permitted` at `build_session.py` `os.setsid()`. → [Prepare game fails at `os.setsid()`](../reference/packing.md#prepare-game-fails-at-ossetsid) diff --git a/docs/reference/packing.md b/docs/reference/packing.md index fd69e06..7787b90 100644 --- a/docs/reference/packing.md +++ b/docs/reference/packing.md @@ -1,5 +1,14 @@ # Packing +## Build tools + +### Homebrew .NET is on PATH but apphosts cannot find the runtime + +- **Symptom:** the first extraction stops with `nx_extract exit code 131` and `.NET location: Not found`, even though `dotnet` is on `PATH`. The shader translator can fail the same way. +- **Cause:** standalone .NET apphosts search runtime roots and registered installations, not `PATH` ([.NET runtime discovery](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-environment-variables#dotnet_root-dotnet_rootx86-dotnet_root_x86-dotnet_root_x64)). Homebrew's formula installs the runtime under `libexec` and its `bin/dotnet` can be a wrapper that sets `DOTNET_ROOT` only for its own child ([Homebrew caveat](https://formulae.brew.sh/formula/dotnet)). Resolving that wrapper's symlink gives `bin`, not the runtime root. +- **Fix:** `scripts/build_ipa_from_nsp.py` `configure_dotnet` derives the root from the `Microsoft.NETCore.App` entry in `dotnet --list-runtimes`, using the build's environment, before any extraction or survey. It exports `DOTNET_ROOT` both to that environment and to `os.environ`, for tools launched with an explicit environment and those that inherit it. Caller-supplied roots, including architecture-specific overrides, retain precedence. A failed or malformed probe leaves the environment unchanged rather than guessing the wrapper's directory. +- **Found on:** [Balatro](../../dm/games/0100CD801CE5E000/README.md), first extraction with Homebrew .NET ([PR #284](https://github.com/seedds/nxnative/pull/284)). Applies to every game's build on that installation setup. + ## Compressed inputs ### NSZ decompression rejects `--keys` diff --git a/scripts/build_ipa_from_nsp.py b/scripts/build_ipa_from_nsp.py index 3cfa5e5..2a28942 100755 --- a/scripts/build_ipa_from_nsp.py +++ b/scripts/build_ipa_from_nsp.py @@ -378,6 +378,23 @@ def free_gb(p): return shutil.disk_usage(p).free / 2**30 +def configure_dotnet(env): + """Let standalone .NET apphosts find the runtime used by dotnet (Homebrew installs it outside the registered locations).""" + if env.get('DOTNET_ROOT'): return + dn = shutil.which('dotnet', path=env['PATH']) + if not dn: return + try: + out = subprocess.run([dn, '--list-runtimes'], env=env, capture_output=True, text=True, check=True, timeout=10).stdout + except (OSError, subprocess.SubprocessError): return + for line in out.splitlines(): + m = re.fullmatch(r'Microsoft\.NETCore\.App\s+\S+\s+\[(.+)/shared/Microsoft\.NETCore\.App\]', line.strip()) + if m: + # A Homebrew command can be a wrapper in bin, so its resolved directory is not a runtime root. + # Extraction steps without env= inherit os.environ; shader steps receive env explicitly. + env['DOTNET_ROOT'] = os.environ['DOTNET_ROOT'] = m[1] + return + + # ---- NSP identification ---- def pfs0_names(path): # NSP = PFS0: list the file names inside with open(path, 'rb') as f: @@ -1756,6 +1773,7 @@ def main(args=None): if not want_lang: raise BuildError(f'unknown game language {lang_in!r}; give one of {language_list([c for c, _ in nacp.LANGUAGES])}, by code or by name') env = dict(os.environ, PATH=os.pathsep.join(TOOL_PATHS + [os.environ.get('PATH', '/usr/bin:/bin:/usr/sbin:/sbin')])) env.setdefault('USER', getpass.getuser()) # xcodegen fails without USER (Couldn't find current username) + configure_dotnet(env) env.setdefault('J', str(os.cpu_count() or 8)) # shader_split.py's and dmsh_build.py's workers (their default is 8) py = sys.executable app, fam = scan_nsps(nsp_dir)