From f171b2328936d11f5ac0031c9cbddddafd58288c Mon Sep 17 00:00:00 2001 From: seedds Date: Mon, 26 Oct 2026 23:54:30 +0800 Subject: [PATCH] Discover Homebrew .NET runtimes for standalone build tools Derive DOTNET_ROOT from dotnet's Microsoft.NETCore.App runtime listing and export it to both explicit and inherited build environments. Preserve caller overrides, use the build environment for discovery, and leave discovery unchanged on probe failures instead of treating Homebrew's wrapper directory as a runtime root. Add regression coverage and document the packing failure and its resolution. Based on PR #284. Co-authored-by: Jose Rosendo (cherry picked from commit 89744da75364ba1847dfbd25f5a82a57d8f1ccb1) --- dm/games/0100CD801CE5E000/README.md | 1 + dm/tools/tests/test_nsp_dotnet.py | 70 +++++++++++++++++++++++++++++ docs/agents/symptoms.md | 1 + docs/reference/packing.md | 9 ++++ scripts/build_ipa_from_nsp.py | 18 ++++++++ 5 files changed, 99 insertions(+) create mode 100644 dm/tools/tests/test_nsp_dotnet.py 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)