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 <josemrb99@gmail.com>
(cherry picked from commit 89744da753)
This commit is contained in:
1 parent
901aed28d7
commit
f171b23289
5 files changed
+99
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user