diff --git a/.porting/discovery-report.md b/.porting/discovery-report.md new file mode 100644 index 000000000..10732b9d4 --- /dev/null +++ b/.porting/discovery-report.md @@ -0,0 +1,228 @@ +# Codebase Discovery Report: Xenia (Xbox 360 Emulator) — iOS/Metal Port + +> Generated by the `porting-discover` methodology (Game Porting Toolkit 4 skills). +> **Framing caveat:** This is **not a greenfield port**. Xenia already has a mature, largely +> complete Metal backend on Metal 3 (metal-cpp). This report is therefore a **Metal 4 / GPTK-4 +> modernization readiness** assessment, not a from-scratch porting discovery. A completed discovery +> should have no unresolved [PENDING] entries. + +## Summary + +Xenia-edge-ios-msl is an Xbox 360 emulator with a substantial, working Metal backend (~41 files in +`src/xenia/gpu/metal/`) written in **metal-cpp** + Obj-C++ (`.mm`) glue, targeting **iOS 18.0** / +macOS 15.0. It already uses several Metal 4-era APIs (`MTLResidencySet`, `MTLSharedEvent`, bindless +argument buffers) on the Metal 3 command model, with a dual-path shader translator (Metal Shader +Converter DXBC→DXIL→metallib, plus a native MSL emitter). + +Most of the immediately useful GPTK-4 surface is **version-agnostic** and ships on the current +iOS 18 / Metal 3 build with **no deployment-target change**: Metal validation, GPU capture/inspection, +the rendering-diagnosis playbook, Metal Shader Converter tuning, metal-cpp lifetime hygiene, and +un-gating the (already-wired) MetalFX **spatial** scaler on iOS. The **Metal 4 command-model** +adoption (flexible PSOs, explicit barriers, `MTL4ArgumentTable`) is a separate, medium-term track, +gated on iOS 26 and scoped as a **runtime-gated dual backend** (decided — path C, no device loss). + +## Recommended Work Tracks + +**Track 1 — Version-agnostic, ships at iOS 18 / Metal 3 (do first; highest ROI):** +1. Enable Metal validation (env-vars) — catches memory errors in generated shaders. +2. Capture a `.gputrace` (you have none) — rendering ground truth for correctness work. +3. Apply the `debugging-rendering-issues` symptom router to current visual bugs. +4. Metal Shader Converter tuning (minimum compatibility flags, min GPU/OS target, layout choice). +5. Un-gate the MetalFX spatial scaler on iOS (`metal_presenter.mm:42`). +6. metal-cpp lifetime audit at the `.mm`/metal-cpp boundary; frame-pacing review. + +**Track 2 — Metal 4, runtime-gated iOS 26+ (dual backend, decided; medium-term):** +1. Finish the Metal 3 explicit hazard model + run under `_validate` on trace titles (prereq). +2. `MTL4Compiler` flexible PSOs + color-attachment mapping → collapse format/blend PSO permutations. +3. Promote the hazard model to Metal 4 queue/intra-pass barriers. +4. `MTL4ArgumentTable` + queue-level residency; replace Metal-4-removed APIs (`setVertexBytes`, per-encoder binds). + +## Skills Loaded + +- `porting-methodology` — milestone/goal framing for the recommendations below. +- `translating-to-metal4-api` — Metal 3 → Metal 4 mapping; TBDR considerations; removed APIs. +- `creating-metal4-shader-pipelines` — assessed pipeline cache against `MTL4Compiler`, flexible PSOs, `MTL4Archive`. +- `managing-metal4-synchronization` — assessed current fence/event/useResource model vs. Metal 4 explicit barriers. +- `using-metal-validation` — API/shader validation env-vars and per-pipeline scoping (version-agnostic). +- `debugging-rendering-issues` — symptom→cause router for visual bugs (largely version-agnostic). +- `using-gpucapture` / `using-gpudebug` — CLI `.gputrace` capture + inspection. +- `managing-metal-cpp-lifetimes` — `NS::TransferPtr`/`RetainPtr` ownership at the ARC boundary. +- `using-metalfx-temporal-upscaler` / `using-metalfx-frame-interpolation` — MetalFX availability + applicability. +- Metal Shader Converter `docs/metal-shader-converter/performance.md` — codegen tuning knobs. +- MetalFX + `MTL4CommandQueue` availability cross-checked against Apple reference (sosumi.ai). + +## Game Porting Toolkit Evaluation Findings + +Not directly applicable — this is an emulator, not a Windows game running under the GPTK evaluation +environment. The relevant "source" is the guest Xbox 360 GPU (Xenos) command stream, already handled +by Xenia's existing D3D12/Vulkan/Metal backends. + +## Trace Analysis + +Instruments traces are present in `scratch/` and are usable for performance planning. The MSC perf +guide recommends Metal System Trace to evaluate shader-execution overlap / TLAB serialization, so +these can be mined directly: + +| Trace | Type | Use | +|---|---|---| +| `mirrors-edge-time-profiler.trace` | Time Profiler | CPU hotspots | +| `mw2-ios-msl.trace` | Metal System Trace (iOS, MSL path) | CPU/GPU timing on device | +| `halo3-odst.trace` | Metal System Trace | per-pass GPU timing | +| `ios-runs.trace` | Metal System Trace | device run timing | +| `xctrace_exports/` | xctrace CSV/exports | scripted analysis | + +**Gap:** no `.gputrace` (GPU frame capture) found — that is the rendering ground truth for +correctness validation. Recommend capturing one via the `using-gpucapture` workflow (`gpucapture` CLI, +macOS 27) for any rendering-correctness milestone. This is a **Track 1** item. + +## Reference Artifacts + +| Artifact Type | Path | Notes | +|---|---|---| +| Metal System Trace (.trace) | `scratch/*.trace` | timing only, no frame capture | +| xctrace exports | `scratch/xctrace_exports/` | derived timing data | +| MSL AIR repro | `scratch/msl_air_repro/` | shader compile repro case | +| Hazard-model design doc | `scratch/metal_hazard_model_design.md` | in-progress explicit-sync design | +| GPU frame capture (.gputrace) | — | **not collected — recommend capturing** | + +## Platform Readiness + +| Area | Status | Notes | +|------|--------|-------| +| macOS build | Ready | macOS 15.0 target; Metal + MetalKit linked | +| iOS build | Ready | `build-ios-xcode/`, CMake presets; iOS 18.0 target | +| Platform layer (windowing) | Ready | `ui/ios/` (UIKit + CAMetalLayer), `ui/metal/metal_presenter.mm` | +| Content pipeline | Ready | emulator consumes guest assets at runtime; n/a | +| Required libraries | Ready | metal-cpp + metal-shader-converter vendored in `third_party/` | +| Deployment target | Keep 18.0 (dual backend) | Floor stays iOS 18.0; Metal 4 path (iOS 26+) added behind a runtime `supports…()`/OS gate — **path C chosen**, no device loss | + +## Graphics Backend Analysis + +> "Opportunity" column covers both version-agnostic (T1) and Metal-4 (T2) work. + +| # | Category | Finding | Apple-platform opportunity | +|---|----------|---------|----------------------------| +| 1 | Existing backends | `d3d12`, `vulkan`, `metal`, `null` under `src/xenia/gpu/` | Metal is the iOS target; others unaffected | +| 2 | API abstraction | Strong: `CommandProcessor`/`GraphicsSystem` abstraction with per-backend impls | Any Metal 4 work is contained to the metal backend; a runtime gate fits the existing seams | +| 3 | Shader language | Dual: MSC (DXBC→DXIL→metallib) + native MSL emitter (`msl_shader_translator.cc`) | **T1:** MSC tuning + DXBC→DXIL intermediate-transform caveat argues for native MSL on hot shaders. **T2:** MSC path already Metal-4-ABI-compatible | +| 4 | Shader pipeline | `metal_pipeline_cache` (Metal 3 `MTL::RenderPipelineState`, async pool, `MTL::BinaryArchive`) + `metal_stage_compile_cache` (persistent MSC cache) | **T1:** async + caching already solved; tune MSC codegen. **T2:** flexible PSOs + color-attachment mapping collapse format/blend permutations | +| 5 | Existing Metal backend | Mature: command processor, RT cache, texture cache, shared-memory/EDRAM, geometry/tessellation emulation | Modernization + hardening, not bring-up | +| 6 | Binding model | Bindless argument buffers — MSC top-level argument buffer + native MSL texture/sampler heaps (65536/2048 slots) | **T1:** linear-layout vs root-signature tradeoff (occupancy). **T2:** maps to `MTL4ArgumentTable` | +| 7 | Render graph | None formal — imperative command processor translating guest draws | Explicit barriers (T2) must be inserted at the command-processor level | +| 8 | Synchronization | Metal 3: `MTL::Fence` (`shared_memory_fence_`), `MTL::SharedEvent`, `useResource`/`useResources`/`useHeap`; **experimental explicit hazard model** behind `metal_backend_hazard_model` (+ `_validate`) | **T1:** finish + validate the hazard model on Metal 3. **T2:** direct on-ramp to Metal 4 queue/intra-pass barriers | +| 9 | Threading | Async pipeline-compile thread pool (`creation_threads_`, priority queue); Xenia command-processor thread | `MTL4Compiler` is multithreaded by default; existing pool may be simplified (T2) | +| 10 | Memory management | `MTL::Heap` pools, `MTLResidencySet` (`AddResidencySetHeap`), shared-memory buffers for EDRAM | Already Metal-4-style residency; extend to queue-level residency sets (T2) | +| 11 | Language conventions | metal-cpp (manual ref-counting) + `.mm` ARC glue (`metal_presenter.mm`, `ir_runtime_impl.mm`) | **T1:** `managing-metal-cpp-lifetimes` audit at the bridge boundary | +| 12 | ObjC/ARC | Mixed ARC (`.mm`) and metal-cpp (`NS::`); CAMetalLayer/drawable handling in ObjC | **T1:** watch `NS::TransferPtr` vs `RetainPtr` ownership at boundaries | + +## Platform Gap Analysis + +- **Debugging / correctness tooling** → biggest under-used surface: Metal validation (`using-metal-validation`), GPU capture/inspect (`using-gpucapture` / `using-gpudebug`), rendering diagnosis (`debugging-rendering-issues`). All version-agnostic, all Track 1. +- **Windowing/presentation** → already on `CAMetalLayer` (iOS via `ui/ios/`). Frame pacing/vsync/EDR review available without Metal 4. Skill: `presenting-metal-drawables`. +- **Input** → `GameController` framework already linked (`ui/ios/CMakeLists.txt`); touch model present. Skill: `using-game-controller`. +- **Graphics (Metal 4 deltas)** → flexible PSOs, explicit barriers, `MTL4ArgumentTable` — Track 2 only. Skills: `translating-to-metal4-api`, `managing-metal4-synchronization`, `creating-metal4-shader-pipelines`, `managing-metal4-resources`. +- **Upscaling** → MetalFX **spatial** present but disabled on iOS (Track 1 un-gate); temporal/frame-gen need motion vectors the emulator doesn't produce. Skills: `using-metalfx-temporal-upscaler`. +- **Audio / file system / threading** → handled by Xenia's existing cross-platform base layers; no Apple-specific gap surfaced. + +## Feature Coverage Matrix + +> Function counts not enumerated; status reflects presence/maturity in the Metal backend. + +| Feature Domain | Status | Essential | Notes | +|----------------|--------|-----------|-------| +| Device / Init | Present | Yes | `metal_provider`, `metal_command_processor` | +| Buffers | Present | Yes | shared-memory / upload pools | +| Textures | Present | Yes | texture cache w/ format conversion | +| Render Targets | Present | Yes | `metal_render_target_cache` (EDRAM emulation) | +| Samplers | Present | Yes | bindless sampler heap | +| Pipeline State | Present | Yes | Metal 3 PSOs; **flexible-PSO opportunity** | +| Descriptor Binding | Present | Yes | bindless argument buffers (MSC TLAB + native heaps) | +| Command Encoding (Draw) | Present | Yes | standard / geometry / tessellation / native-mesh paths | +| Command Encoding (Dispatch) | Present | Depends | compute used for emulation passes | +| Resource Copy (Blit) | Present | Yes | shared-memory upload, gamma compute | +| Synchronization | Present (transitional) | Yes | fences/events/useResource + experimental hazard model | +| Presentation / Swap Chain | Present | Yes | CAMetalLayer; SharedEvent-gated mailbox | +| Queries (Timestamp/Occlusion) | Partial | Optional | verify counter sampling coverage | +| Debug Markers / Labels | Present | Yes (early) | labels used for capture readability | +| Compute | Present | Depends | emulation + gamma/upscale | +| Raytracing | Absent | Optional | not used by Xenos emulation | +| Indirect Draw/Dispatch | Partial | Optional | confirm against guest indirect paths | + +## Practical Porting Advice (tailored) + +**Correctness tooling (Track 1):** +- **Metal validation** — enable `MTL_DEBUG_LAYER=1` broadly (cheap, CPU-side). Add `MTL_SHADER_VALIDATION=1` selectively for GPU-side memory errors in generated shaders — and **pair it with `MTL_SHADER_VALIDATION_REPORT_TO_STDERR=1`** or errors only hit the system log. For this pre-Metal-4 build, per-pipeline scoping is the env allow/deny list `MTL_SHADER_VALIDATION_ENABLE_PIPELINES` / `_DISABLE_PIPELINES` (the `MTL4PipelineOptions.shaderValidation` route is Metal-4-only). +- **Rendering diagnosis** — the `debugging-rendering-issues` symptom router maps onto classic Xenos→Metal traps: BGRA↔RGBA channel order + `MTLTextureSwizzleChannels`; sRGB/gamma double-encode (you have gamma-ramp compute); reversed-depth z-fighting (clear value + compare must change together); premultiplied alpha on the drawable; Y-flip/winding; `D24_UNORM_S8_UINT` not universal on Apple GPUs → prefer `Depth32Float_Stencil8`; `COLOR_WRITE_ENABLE`→`MTLColorWriteMask` bit-layout reversal. + +**Metal Shader Converter tuning (Track 1, not Metal 4):** the perf guide maps onto knobs already in `MetalStageCompileKey` (`compatibility_flags`, `minimum_gpu_family`, `minimum_os`, `validation_flags`): +- Use the **minimum** compatibility flags (`IRCompilerSetCompatibilityFlags`) — each costs runtime perf. +- Set `IRCompilerSetMinimumGPUFamily` / `SetMinimumDeploymentTarget` — newer targets let MSC emit more optimal IR. +- Favor the **linear resource layout** over root-signature bindless for shaders that don't need bindless (single indirection, lower register pressure). +- The guide warns against **intermediate IR transforms** — your DXBC→DXIL(dxilconv)→MSC chain is exactly that, a concrete argument for maturing the **native-MSL path** on hot shaders. + +**Pipeline / sync (Track 2):** +- PSO compile stutter is the canonical emulator problem; you've already attacked it (async pool + binary archive + persistent stage cache). The remaining lever is **reducing PSO count** via flexible PSOs / color-attachment mapping — your `MetalPipelineDescription` bakes `color_formats[4]`, `depth/stencil_format`, `sample_count`, `normalized_color_mask`, `blendcontrol[4]` into the key, exactly the axes flexible PSOs defer. +- The experimental `metal_backend_hazard_model` is effectively a Metal 4 barrier model in Metal 3 clothing; finish it now so the Metal 4 port is a primitive swap, not a redesign. + +## Stub Candidates + +Not applicable in the usual sense (mature port). Equivalent "feature flags already present": MetalFX +(`metal_presenter_use_metalfx`), hazard model (`metal_backend_hazard_model`), native-MSL vs MSC backend +selection (`MetalShader::Backend`). These gate experimental paths and are the natural milestone toggles. + +## Quick Wins + +Ordered by ROI; all of Track 1, none require a deployment-target change: + +1. **Enable Metal validation** — env-vars only, no code. Catches memory errors in generated MSL/metallib (`MTL_DEBUG_LAYER`, `MTL_SHADER_VALIDATION` + `…_REPORT_TO_STDERR`). +2. **Capture a `.gputrace`** via `gpucapture` — you currently have none; this is rendering ground truth for any correctness work. +3. **Un-gate MetalFX spatial on iOS** — `metal_presenter.mm:42` (`#if !XE_PLATFORM_IOS`) compiles it out, but `MTLFXSpatialScaler`/`MTLFXTemporalScaler` are iOS **16.0+** (verified). The spatial path is already wired; un-gating lights up internal-res→display upscaling. First checkpoint: it compiles clean on the iOS path (never built there before). +4. **MSC codegen tuning** — audit compatibility flags down to the minimum; tune min GPU family/OS. +5. **MSC programmable blending / framebuffer fetch in HLSL** for EDRAM on TBDR tile memory — reduces render-target round-trips. Skill: `compiling-with-metal-shaderconverter`. +6. **Refresh vendored metal-cpp** to the GPTK-4 (iOS 27) drop for Apple10 GPU family + Metal 4 surface. + +## Parallel Work Opportunities + +- Mine the existing `.trace` captures (MW2, Halo 3 ODST, Mirror's Edge) to rank the heaviest GPU passes and to evaluate MSC TLAB-induced shader serialization (per the MSC perf guide) before investing in flexible PSOs or upscaling. +- Run the hazard model under `_validate` on those titles to find missed hazards on Metal 3 — de-risks the eventual Metal 4 barrier swap independent of the deployment-target decision. +- Stand up the validation env-vars in the dev launch path so every run surfaces shader/API misuse. + +## Risk Assessment + +- **Correctness regressions are hard to see without ground truth:** no `.gputrace` collected. Mitigation: capture one (Track 1) and enable Metal validation before correctness-sensitive changes. +- **Explicit sync correctness (T2):** Metal 4 has no driver hazard tracking — under-barriering → GPU faults, over-barriering → perf loss. The skill calls this the longest phase of a port. Mitigation: finish + `_validate` the Metal 3 hazard model first (Track 1). +- **metal-cpp/ARC boundary:** mixed ownership models across `.mm`/metal-cpp risk use-after-free; audit with `managing-metal-cpp-lifetimes`. Mitigation: Metal validation + leak instruments (Track 1). See Known Blockers for the specific sites. +- **Deployment-target tension (T2):** Metal 4 APIs are iOS 26+ (`MTL4CommandQueue` verified). Resolved via the dual-backend gate (path C) — no device loss, at the cost of maintaining two paths. +- **Native MSL emitter maintenance:** owning an MSL emitter means Metal version/behavior changes land on you, not on MSC. + +## Dependency Graph + +- Track 1 items (validation, `.gputrace`, rendering diagnosis, MSC tuning, MetalFX un-gate, lifetime audit): **all independent**, ship at iOS 18. +- Hazard-model completion: **independent** (Metal 3), and a **prerequisite** for Metal 4 sync. +- Flexible PSOs / Metal 4 command model: **blocked on** the iOS 26 runtime gate (dual backend). +- Full Metal 4 command model: **depends on** explicit sync being complete (no implicit hazard tracking) and on replacing Metal-4-removed APIs (`setVertexBytes`, per-encoder binds). + +## Key Files + +- `src/xenia/gpu/metal/metal_command_processor.{h,cc}` — submission, sync, residency, encoders. +- `src/xenia/gpu/metal/metal_pipeline_cache.{h,cc}` — PSO creation/caching/async pool/binary archive. +- `src/xenia/gpu/metal/metal_stage_compile_cache.{h,cc}` — persistent MSC metallib cache. +- `src/xenia/gpu/msl_shader_translator.{h,cc}` — native MSL emitter. +- `src/xenia/ui/metal/metal_presenter.mm` — CAMetalLayer presentation + MetalFX (gated, line 42). +- `src/xenia/ui/metal/metal_immediate_drawer.mm` — UI drawing; `__bridge`/`CFRelease` buffer lifetime site. +- `scratch/metal_hazard_model_design.md` — explicit-sync design in progress. + +## Scope Decisions (from user) + +- **Language:** metal-cpp + `.mm` glue. Confirmed by ownership model in `src/xenia/ui/metal/` and `src/xenia/gpu/metal/`. +- **Off-limits:** None stated by the user yet. Suggested local policy for this milestone: treat `third_party/` and external SDK vendoring as out-of-scope unless the user explicitly asks for version/ABI updates. +- **Abstraction strategy:** Existing abstraction (multi-backend `CommandProcessor`). No new layer needed. +- **Metal 4 adoption path:** **C — runtime-gated dual backend** (decided). Metal 3 path serves iOS 18–25; a Metal 4 path (flexible PSOs + explicit barriers + `MTL4ArgumentTable`) is added behind a runtime iOS 26+ / `supports…()` gate. No device loss. +- **Reference available:** Yes. Behavioral parity for graphics behavior is already encoded in the existing Xenia D3D12 path (`src/xenia/gpu/d3d12/`) and mirrored by this mature Metal path; also has platform trace references in `scratch/*.trace` and export logs. + +## Known Blockers / Constraints + +- **`metal-cpp` / ARC ownership mismatch risk:** runtime-safe but fragile boundaries exist where Objective-C ownership crosses into metal-cpp raw pointers. In `metal_presenter.mm`, the queue is intentionally non-owning (`__bridge` from provider) and never released there. In `metal_immediate_drawer.mm`, temporary `MTLBuffer` values are stored as `void*` via `__bridge` and released with `CFRelease` in `EndDrawBatch()`, which is correct only if retain counts are consistent and should be audited carefully in future Metal4 changes. +- **No `gputrace` capture yet:** only timing traces exist in `scratch/`; render-correctness and exact object-lifetime confirmation is incomplete until a `.gputrace` is collected. +- **API-migration blocker for full Metal 4 parity:** several renderer paths still use APIs removed in Metal 4 (for example `setVertexBytes` and per-encoder resource binding); converting these is a hard requirement for true MTL4 path parity. +- **Target-floor constraint:** `iOS 18.0` and legacy devices are only covered by Metal 3 path; Metal 4 features remain runtime-gated to `iOS 26+`. diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/Contents.json b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 29a27d154..000000000 --- a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,176 +0,0 @@ -{ - "images" : [ - { - "idiom" : "iphone", - "size" : "20x20", - "scale" : "2x", - "filename" : "iphone-notification-20@2x.png" - }, - { - "idiom" : "iphone", - "size" : "20x20", - "scale" : "3x", - "filename" : "iphone-notification-20@3x.png" - }, - { - "idiom" : "iphone", - "size" : "29x29", - "scale" : "2x", - "filename" : "iphone-settings-29@2x.png" - }, - { - "idiom" : "iphone", - "size" : "29x29", - "scale" : "3x", - "filename" : "iphone-settings-29@3x.png" - }, - { - "idiom" : "iphone", - "size" : "40x40", - "scale" : "2x", - "filename" : "iphone-spotlight-40@2x.png" - }, - { - "idiom" : "iphone", - "size" : "40x40", - "scale" : "3x", - "filename" : "iphone-spotlight-40@3x.png" - }, - { - "idiom" : "iphone", - "size" : "60x60", - "scale" : "2x", - "filename" : "iphone-app-60@2x.png" - }, - { - "idiom" : "iphone", - "size" : "60x60", - "scale" : "3x", - "filename" : "iphone-app-60@3x.png" - }, - { - "idiom" : "ipad", - "size" : "20x20", - "scale" : "1x", - "filename" : "ipad-notification-20.png" - }, - { - "idiom" : "ipad", - "size" : "20x20", - "scale" : "2x", - "filename" : "ipad-notification-20@2x.png" - }, - { - "idiom" : "ipad", - "size" : "29x29", - "scale" : "1x", - "filename" : "ipad-settings-29.png" - }, - { - "idiom" : "ipad", - "size" : "29x29", - "scale" : "2x", - "filename" : "ipad-settings-29@2x.png" - }, - { - "idiom" : "ipad", - "size" : "40x40", - "scale" : "1x", - "filename" : "ipad-spotlight-40.png" - }, - { - "idiom" : "ipad", - "size" : "40x40", - "scale" : "2x", - "filename" : "ipad-spotlight-40@2x.png" - }, - { - "idiom" : "ipad", - "size" : "76x76", - "scale" : "1x", - "filename" : "ipad-app-76.png" - }, - { - "idiom" : "ipad", - "size" : "76x76", - "scale" : "2x", - "filename" : "ipad-app-76@2x.png" - }, - { - "idiom" : "ipad", - "size" : "83.5x83.5", - "scale" : "2x", - "filename" : "ipad-pro-app-83.5@2x.png" - }, - { - "idiom" : "ios-marketing", - "size" : "1024x1024", - "scale" : "1x", - "filename" : "ios-marketing-1024.png" - }, - { - "idiom" : "mac", - "size" : "16x16", - "scale" : "1x", - "filename" : "mac-16.png" - }, - { - "idiom" : "mac", - "size" : "16x16", - "scale" : "2x", - "filename" : "mac-16@2x.png" - }, - { - "idiom" : "mac", - "size" : "32x32", - "scale" : "1x", - "filename" : "mac-32.png" - }, - { - "idiom" : "mac", - "size" : "32x32", - "scale" : "2x", - "filename" : "mac-32@2x.png" - }, - { - "idiom" : "mac", - "size" : "128x128", - "scale" : "1x", - "filename" : "mac-128.png" - }, - { - "idiom" : "mac", - "size" : "128x128", - "scale" : "2x", - "filename" : "mac-128@2x.png" - }, - { - "idiom" : "mac", - "size" : "256x256", - "scale" : "1x", - "filename" : "mac-256.png" - }, - { - "idiom" : "mac", - "size" : "256x256", - "scale" : "2x", - "filename" : "mac-256@2x.png" - }, - { - "idiom" : "mac", - "size" : "512x512", - "scale" : "1x", - "filename" : "mac-512.png" - }, - { - "idiom" : "mac", - "size" : "512x512", - "scale" : "2x", - "filename" : "mac-512@2x.png" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ios-marketing-1024.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ios-marketing-1024.png deleted file mode 100644 index 93d6effab..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ios-marketing-1024.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-app-76.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-app-76.png deleted file mode 100644 index 70186d3d1..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-app-76.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-app-76@2x.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-app-76@2x.png deleted file mode 100644 index 52af97bca..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-app-76@2x.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-notification-20.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-notification-20.png deleted file mode 100644 index 1f388a9ce..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-notification-20.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-notification-20@2x.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-notification-20@2x.png deleted file mode 100644 index 35e7d369b..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-notification-20@2x.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-pro-app-83.5@2x.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-pro-app-83.5@2x.png deleted file mode 100644 index f7a55df9f..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-pro-app-83.5@2x.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-settings-29.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-settings-29.png deleted file mode 100644 index 524186aa1..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-settings-29.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-settings-29@2x.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-settings-29@2x.png deleted file mode 100644 index 15b165db3..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-settings-29@2x.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-spotlight-40.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-spotlight-40.png deleted file mode 100644 index 35e7d369b..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-spotlight-40.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-spotlight-40@2x.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-spotlight-40@2x.png deleted file mode 100644 index 7986c539d..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/ipad-spotlight-40@2x.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-app-60@2x.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-app-60@2x.png deleted file mode 100644 index 5b208ccf8..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-app-60@2x.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-app-60@3x.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-app-60@3x.png deleted file mode 100644 index 7cad412cb..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-app-60@3x.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-notification-20@2x.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-notification-20@2x.png deleted file mode 100644 index 35e7d369b..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-notification-20@2x.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-notification-20@3x.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-notification-20@3x.png deleted file mode 100644 index c45ecd610..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-notification-20@3x.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-settings-29@2x.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-settings-29@2x.png deleted file mode 100644 index 15b165db3..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-settings-29@2x.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-settings-29@3x.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-settings-29@3x.png deleted file mode 100644 index 13e849799..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-settings-29@3x.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-spotlight-40@2x.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-spotlight-40@2x.png deleted file mode 100644 index 7986c539d..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-spotlight-40@2x.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-spotlight-40@3x.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-spotlight-40@3x.png deleted file mode 100644 index 5b208ccf8..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/iphone-spotlight-40@3x.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-128.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-128.png deleted file mode 100644 index 1365a29b9..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-128.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-128@2x.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-128@2x.png deleted file mode 100644 index 5a063b17c..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-128@2x.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-16.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-16.png deleted file mode 100644 index 0e599a83c..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-16.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-16@2x.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-16@2x.png deleted file mode 100644 index ab6b71e7b..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-16@2x.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-256.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-256.png deleted file mode 100644 index 5a063b17c..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-256.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-256@2x.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-256@2x.png deleted file mode 100644 index c83b256fc..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-256@2x.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-32.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-32.png deleted file mode 100644 index ab6b71e7b..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-32.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-32@2x.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-32@2x.png deleted file mode 100644 index 389f57912..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-32@2x.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-512.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-512.png deleted file mode 100644 index c83b256fc..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-512.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-512@2x.png b/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-512@2x.png deleted file mode 100644 index 93d6effab..000000000 Binary files a/assets/apple/AppIcon.xcassets/AppIcon.appiconset/mac-512@2x.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/Contents.json b/assets/apple/AppIcon.xcassets/Contents.json deleted file mode 100644 index 73c00596a..000000000 --- a/assets/apple/AppIcon.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/assets/apple/AppIcon.xcassets/SettingsLinkDiscord.imageset/Contents.json b/assets/apple/AppIcon.xcassets/SettingsLinkDiscord.imageset/Contents.json deleted file mode 100644 index f91edecfd..000000000 --- a/assets/apple/AppIcon.xcassets/SettingsLinkDiscord.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "discord.png", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/assets/apple/AppIcon.xcassets/SettingsLinkDiscord.imageset/discord.png b/assets/apple/AppIcon.xcassets/SettingsLinkDiscord.imageset/discord.png deleted file mode 100644 index 716d7a603..000000000 Binary files a/assets/apple/AppIcon.xcassets/SettingsLinkDiscord.imageset/discord.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/SettingsLinkGitHub.imageset/Contents.json b/assets/apple/AppIcon.xcassets/SettingsLinkGitHub.imageset/Contents.json deleted file mode 100644 index a9abf4426..000000000 --- a/assets/apple/AppIcon.xcassets/SettingsLinkGitHub.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "github.png", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/assets/apple/AppIcon.xcassets/SettingsLinkGitHub.imageset/github.png b/assets/apple/AppIcon.xcassets/SettingsLinkGitHub.imageset/github.png deleted file mode 100644 index 21d44b5a9..000000000 Binary files a/assets/apple/AppIcon.xcassets/SettingsLinkGitHub.imageset/github.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/SettingsLinkKoFi.imageset/Contents.json b/assets/apple/AppIcon.xcassets/SettingsLinkKoFi.imageset/Contents.json deleted file mode 100644 index e92729071..000000000 --- a/assets/apple/AppIcon.xcassets/SettingsLinkKoFi.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "kofi.png", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/assets/apple/AppIcon.xcassets/SettingsLinkKoFi.imageset/kofi.png b/assets/apple/AppIcon.xcassets/SettingsLinkKoFi.imageset/kofi.png deleted file mode 100644 index c904dfd58..000000000 Binary files a/assets/apple/AppIcon.xcassets/SettingsLinkKoFi.imageset/kofi.png and /dev/null differ diff --git a/assets/apple/AppIcon.xcassets/SettingsLinkWebsite.imageset/Contents.json b/assets/apple/AppIcon.xcassets/SettingsLinkWebsite.imageset/Contents.json deleted file mode 100644 index ff96b3737..000000000 --- a/assets/apple/AppIcon.xcassets/SettingsLinkWebsite.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "website-icon.png", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/assets/apple/AppIcon.xcassets/SettingsLinkWebsite.imageset/website-icon.png b/assets/apple/AppIcon.xcassets/SettingsLinkWebsite.imageset/website-icon.png deleted file mode 100644 index 93d6effab..000000000 Binary files a/assets/apple/AppIcon.xcassets/SettingsLinkWebsite.imageset/website-icon.png and /dev/null differ diff --git a/assets/apple/AppIconDark.icon/Assets/0_X1.svg b/assets/apple/AppIconDark.icon/Assets/0_X1.svg deleted file mode 100644 index d8a423b70..000000000 --- a/assets/apple/AppIconDark.icon/Assets/0_X1.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/assets/apple/AppIconDark.icon/Assets/0_X2.svg b/assets/apple/AppIconDark.icon/Assets/0_X2.svg deleted file mode 100644 index 5d42e3411..000000000 --- a/assets/apple/AppIconDark.icon/Assets/0_X2.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/assets/apple/AppIconDark.icon/Assets/1_Top 2.svg b/assets/apple/AppIconDark.icon/Assets/1_Top 2.svg deleted file mode 100644 index d577ab1fc..000000000 --- a/assets/apple/AppIconDark.icon/Assets/1_Top 2.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/apple/AppIconDark.icon/Assets/2_Middle.svg b/assets/apple/AppIconDark.icon/Assets/2_Middle.svg deleted file mode 100644 index 84686221f..000000000 --- a/assets/apple/AppIconDark.icon/Assets/2_Middle.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/assets/apple/AppIconDark.icon/Assets/3_Base.svg b/assets/apple/AppIconDark.icon/Assets/3_Base.svg deleted file mode 100644 index 5a0cdff0e..000000000 --- a/assets/apple/AppIconDark.icon/Assets/3_Base.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/apple/AppIconDark.icon/icon.json b/assets/apple/AppIconDark.icon/icon.json deleted file mode 100644 index fbd7250b1..000000000 --- a/assets/apple/AppIconDark.icon/icon.json +++ /dev/null @@ -1,185 +0,0 @@ -{ - "fill": { - "linear-gradient": [ - "srgb:0.19200,0.19200,0.19200,1.00000", - "srgb:0.07800,0.07800,0.07800,1.00000" - ] - }, - "groups": [ - { - "blur-material": 0.5, - "layers": [ - { - "fill-specializations": [ - { - "appearance": "tinted", - "value": { - "solid": "extended-gray:0.25000,1.00000" - } - } - ], - "image-name": "0_X1.svg", - "name": "0_X1", - "position": { - "scale": 4, - "translation-in-points": [ - 6, - 0 - ] - } - }, - { - "fill-specializations": [ - { - "appearance": "tinted", - "value": { - "solid": "extended-gray:0.25000,1.00000" - } - } - ], - "image-name": "0_X2.svg", - "name": "0_X2", - "position": { - "scale": 4, - "translation-in-points": [ - 6, - 0 - ] - } - } - ], - "shadow": { - "kind": "layer-color", - "opacity": 0.5 - }, - "translucency": { - "enabled": true, - "value": 0.5 - } - }, - { - "blur-material": 0.3, - "layers": [ - { - "fill-specializations": [ - { - "appearance": "tinted", - "value": { - "solid": "extended-gray:1.00000,1.00000" - } - } - ], - "image-name": "1_Top 2.svg", - "name": "1_Top 2", - "position": { - "scale": 4, - "translation-in-points": [ - 4.000499999999988, - -0.0010000010208841559 - ] - } - } - ], - "shadow": { - "kind": "neutral", - "opacity": 0.5 - }, - "translucency-specializations": [ - { - "value": { - "enabled": true, - "value": 0.6 - } - }, - { - "appearance": "tinted", - "value": { - "enabled": true, - "value": 0.9 - } - } - ] - }, - { - "blur-material": 0.3, - "layers": [ - { - "fill-specializations": [ - { - "appearance": "tinted", - "value": { - "solid": "extended-gray:1.00000,1.00000" - } - } - ], - "image-name": "2_Middle.svg", - "name": "2_Middle", - "position": { - "scale": 4, - "translation-in-points": [ - 4.120499999999993, - 0 - ] - } - } - ], - "shadow": { - "kind": "neutral", - "opacity": 0.5 - }, - "translucency-specializations": [ - { - "value": { - "enabled": true, - "value": 0.5 - } - }, - { - "appearance": "tinted", - "value": { - "enabled": true, - "value": 0.7 - } - } - ] - }, - { - "blur-material": 0.5, - "layers": [ - { - "fill-specializations": [ - { - "appearance": "tinted", - "value": { - "solid": "extended-gray:1.00000,0.65101" - } - } - ], - "image-name": "3_Base.svg", - "name": "3_Base", - "position": { - "scale": 4, - "translation-in-points": [ - 1.3445738206343094, - 0 - ] - } - } - ], - "shadow": { - "kind": "neutral", - "opacity": 0.5 - }, - "translucency": { - "enabled": true, - "value": 0.7 - } - } - ], - "supported-platforms": { - "circles": [ - "watchOS" - ], - "squares": "shared" - } -} diff --git a/assets/apple/AppIconLight.icon/Assets/0_X1.svg b/assets/apple/AppIconLight.icon/Assets/0_X1.svg deleted file mode 100644 index d8a423b70..000000000 --- a/assets/apple/AppIconLight.icon/Assets/0_X1.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/assets/apple/AppIconLight.icon/Assets/0_X2.svg b/assets/apple/AppIconLight.icon/Assets/0_X2.svg deleted file mode 100644 index 5d42e3411..000000000 --- a/assets/apple/AppIconLight.icon/Assets/0_X2.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/assets/apple/AppIconLight.icon/Assets/1_Top 2.svg b/assets/apple/AppIconLight.icon/Assets/1_Top 2.svg deleted file mode 100644 index d577ab1fc..000000000 --- a/assets/apple/AppIconLight.icon/Assets/1_Top 2.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/apple/AppIconLight.icon/Assets/2_Middle.svg b/assets/apple/AppIconLight.icon/Assets/2_Middle.svg deleted file mode 100644 index 84686221f..000000000 --- a/assets/apple/AppIconLight.icon/Assets/2_Middle.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/assets/apple/AppIconLight.icon/Assets/3_Base.svg b/assets/apple/AppIconLight.icon/Assets/3_Base.svg deleted file mode 100644 index 5a0cdff0e..000000000 --- a/assets/apple/AppIconLight.icon/Assets/3_Base.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/apple/AppIconLight.icon/icon.json b/assets/apple/AppIconLight.icon/icon.json deleted file mode 100644 index 3e6ed0687..000000000 --- a/assets/apple/AppIconLight.icon/icon.json +++ /dev/null @@ -1,185 +0,0 @@ -{ - "fill": { - "linear-gradient": [ - "srgb:1.00000,1.00000,1.00000,1.00000", - "srgb:0.92500,0.92500,0.92500,1.00000" - ] - }, - "groups": [ - { - "blur-material": 0.5, - "layers": [ - { - "fill-specializations": [ - { - "appearance": "tinted", - "value": { - "solid": "extended-gray:0.25000,1.00000" - } - } - ], - "image-name": "0_X1.svg", - "name": "0_X1", - "position": { - "scale": 4, - "translation-in-points": [ - 6, - 0 - ] - } - }, - { - "fill-specializations": [ - { - "appearance": "tinted", - "value": { - "solid": "extended-gray:0.25000,1.00000" - } - } - ], - "image-name": "0_X2.svg", - "name": "0_X2", - "position": { - "scale": 4, - "translation-in-points": [ - 6, - 0 - ] - } - } - ], - "shadow": { - "kind": "layer-color", - "opacity": 0.5 - }, - "translucency": { - "enabled": true, - "value": 0.5 - } - }, - { - "blur-material": 0.3, - "layers": [ - { - "fill-specializations": [ - { - "appearance": "tinted", - "value": { - "solid": "extended-gray:1.00000,1.00000" - } - } - ], - "image-name": "1_Top 2.svg", - "name": "1_Top 2", - "position": { - "scale": 4, - "translation-in-points": [ - 4.000499999999988, - -0.0010000010208841559 - ] - } - } - ], - "shadow": { - "kind": "neutral", - "opacity": 0.5 - }, - "translucency-specializations": [ - { - "value": { - "enabled": true, - "value": 0.6 - } - }, - { - "appearance": "tinted", - "value": { - "enabled": true, - "value": 0.9 - } - } - ] - }, - { - "blur-material": 0.3, - "layers": [ - { - "fill-specializations": [ - { - "appearance": "tinted", - "value": { - "solid": "extended-gray:1.00000,1.00000" - } - } - ], - "image-name": "2_Middle.svg", - "name": "2_Middle", - "position": { - "scale": 4, - "translation-in-points": [ - 4.120499999999993, - 0 - ] - } - } - ], - "shadow": { - "kind": "neutral", - "opacity": 0.5 - }, - "translucency-specializations": [ - { - "value": { - "enabled": true, - "value": 0.5 - } - }, - { - "appearance": "tinted", - "value": { - "enabled": true, - "value": 0.7 - } - } - ] - }, - { - "blur-material": 0.5, - "layers": [ - { - "fill-specializations": [ - { - "appearance": "tinted", - "value": { - "solid": "extended-gray:1.00000,0.65101" - } - } - ], - "image-name": "3_Base.svg", - "name": "3_Base", - "position": { - "scale": 4, - "translation-in-points": [ - 1.3445738206343094, - 0 - ] - } - } - ], - "shadow": { - "kind": "neutral", - "opacity": 0.5 - }, - "translucency": { - "enabled": true, - "value": 0.7 - } - } - ], - "supported-platforms": { - "circles": [ - "watchOS" - ], - "squares": "shared" - } -} diff --git a/assets/apple/XeniOSAssets.xcassets/AppIcon.appiconset/Contents.json b/assets/apple/XeniOSAssets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..b00e4bb98 --- /dev/null +++ b/assets/apple/XeniOSAssets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "filename": "AppIcon-Default-1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "AppIcon-Dark-1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/src/xenia/CMakeLists.txt b/src/xenia/CMakeLists.txt index 30ec2b441..b66e7a3d8 100644 --- a/src/xenia/CMakeLists.txt +++ b/src/xenia/CMakeLists.txt @@ -20,6 +20,9 @@ endif() add_subdirectory(apu) add_subdirectory(apu/nop) add_subdirectory(apu/sdl) +if(XE_PLATFORM_IOS OR XE_PLATFORM_MACOS) + add_subdirectory(apu/phase) +endif() add_subdirectory(gpu) add_subdirectory(gpu/null) add_subdirectory(gpu/vulkan) diff --git a/src/xenia/app/CMakeLists.txt b/src/xenia/app/CMakeLists.txt index 4b23899ba..095ff7c23 100644 --- a/src/xenia/app/CMakeLists.txt +++ b/src/xenia/app/CMakeLists.txt @@ -1,8 +1,13 @@ if(XE_PLATFORM_IOS) set(_xe_ios_entitlements "${PROJECT_SOURCE_DIR}/xenia_ios.entitlements") - set(_xe_ios_app_icon "${PROJECT_SOURCE_DIR}/assets/apple/AppIcon.icon") - set(_xe_ios_asset_catalog - "${PROJECT_SOURCE_DIR}/assets/apple/XeniOSAssets.xcassets") + # NOTE: The iOS asset catalog (XeniOSAssets.xcassets) is intentionally NOT + # added to the build. actool's AssetCatalogAgent crashes compiling ANY image + # (even a single plain PNG) under the current Xcode 27 beta / macOS 26.4 + # toolchain ("AVFCore ... missing from an installed root"), which fails the + # CompileAssetCatalogVariant build phase. Compiling the catalog is disabled to + # keep the build green; the app falls back to SF Symbols for the SettingsLink + # icons and uses the default app icon. Re-add the catalog and the app-icon + # settings below once the toolchain is fixed (released Xcode / newer macOS). add_executable(xenia-app MACOSX_BUNDLE ${CMAKE_CURRENT_SOURCE_DIR}/xenia_main_ios.mm ${PROJECT_SOURCE_DIR}/src/xenia/ui/ios/app/windowed_app_main_ios.mm @@ -16,28 +21,19 @@ if(XE_PLATFORM_IOS) MACOSX_BUNDLE_SHORT_VERSION_STRING "1.0" XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "com.xenios" XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1,2" - XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_APPICON_NAME "AppIcon" - XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES - "AppIconLight AppIconDark" - XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS YES + # App-icon rendering is intentionally disabled: actool's AssetCatalogAgent + # crashes rendering any app icon (PNG or Icon Composer .icon) under the + # current Xcode/macOS toolchain. With no APPICON_NAME/ALTERNATE_APPICON_NAMES + # designated, actool compiles the catalog without invoking that renderer, so + # the build succeeds. Restore these settings once the toolchain is fixed: + # XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_APPICON_NAME "AppIcon" + # XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES "AppIconLight AppIconDark" + # XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS YES XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${_xe_ios_entitlements}") target_sources(xenia-app PRIVATE "${_xe_ios_entitlements}") - if(XCODE) - set_source_files_properties( - "${_xe_ios_app_icon}" - PROPERTIES - XCODE_LAST_KNOWN_FILE_TYPE "folder.iconcomposer.icon" - MACOSX_PACKAGE_LOCATION Resources) - set_source_files_properties( - "${_xe_ios_asset_catalog}" - PROPERTIES - XCODE_LAST_KNOWN_FILE_TYPE "folder.assetcatalog" - MACOSX_PACKAGE_LOCATION Resources) - target_sources(xenia-app PRIVATE - "${_xe_ios_app_icon}" - "${_xe_ios_asset_catalog}") - endif() + # Asset catalog compilation disabled (see note above) — the catalog is not + # added to xenia-app sources, so actool/CompileAssetCatalogVariant never runs. target_compile_definitions(xenia-app PRIVATE XBYAK_NO_OP_NAMES XBYAK_ENABLE_OMITTED_OPERAND @@ -45,6 +41,7 @@ if(XE_PLATFORM_IOS) target_link_libraries(xenia-app PRIVATE xenia-apu xenia-apu-nop + xenia-apu-phase xenia-apu-sdl xenia-base xenia-core @@ -286,6 +283,7 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") ) elseif(APPLE) target_link_libraries(xenia-app PRIVATE + xenia-apu-phase xenia-gpu-metal xenia-hid-keyboard xenia-ui-metal diff --git a/src/xenia/app/Info.plist b/src/xenia/app/Info.plist index 6bb6c1386..990b3ae63 100644 --- a/src/xenia/app/Info.plist +++ b/src/xenia/app/Info.plist @@ -26,6 +26,8 @@ NSApplication NSHighResolutionCapable + NSMotionUsageDescription + Head tracking adjusts spatial audio to your head movement when using the PHASE audio backend with supported headphones. LSSupportsGameMode diff --git a/src/xenia/app/Info_ios.plist b/src/xenia/app/Info_ios.plist index 3b802f7a6..4cf5df92c 100644 --- a/src/xenia/app/Info_ios.plist +++ b/src/xenia/app/Info_ios.plist @@ -70,10 +70,14 @@ + LSApplicationCategoryType + public.app-category.games LSApplicationQueriesSchemes stikjit + NSMotionUsageDescription + Head tracking adjusts spatial audio to your head movement when using the PHASE audio backend with supported headphones. LSSupportsGameMode LSSupportsOpeningDocumentsInPlace diff --git a/src/xenia/app/xenia_main.cc b/src/xenia/app/xenia_main.cc index da1947bdc..519a9f3ef 100644 --- a/src/xenia/app/xenia_main.cc +++ b/src/xenia/app/xenia_main.cc @@ -40,6 +40,9 @@ #if !XE_PLATFORM_ANDROID #include "xenia/apu/sdl/sdl_audio_system.h" #endif // !XE_PLATFORM_ANDROID +#if XE_PLATFORM_MAC +#include "xenia/apu/phase/phase_audio_system.h" +#endif // XE_PLATFORM_MAC #if XE_PLATFORM_WIN32 #include "xenia/apu/xaudio2/xaudio2_audio_system.h" #endif // XE_PLATFORM_WIN32 @@ -76,10 +79,10 @@ DEFINE_string(apu, "sdl", "Audio system. Use: " APU_OPTIONS, "APU"); DEFINE_string(gpu, "vulkan", "Graphics system. Use: " GPU_OPTIONS, "GPU"); DEFINE_string(hid, "sdl", "Input system. Use: " HID_OPTIONS, "HID"); #elif XE_PLATFORM_MAC -#define APU_OPTIONS "[sdl, nop]" +#define APU_OPTIONS "[sdl, phase, nop]" #define GPU_OPTIONS "[metal, vulkan, null]" #define HID_OPTIONS "[sdl, nop]" -DEFINE_string(apu, "sdl", "Audio system. Use: " APU_OPTIONS, "APU"); +DEFINE_string(apu, "phase", "Audio system. Use: " APU_OPTIONS, "APU"); DEFINE_string(gpu, "metal", "Graphics system. Use: " GPU_OPTIONS, "GPU"); DEFINE_string(hid, "sdl", "Input system. Use: " HID_OPTIONS, "HID"); #else @@ -403,6 +406,9 @@ std::unique_ptr EmulatorApp::CreateAudioSystem( #if !XE_PLATFORM_ANDROID factory.Add("sdl"); #endif // !XE_PLATFORM_ANDROID +#if XE_PLATFORM_MAC + factory.Add("phase"); +#endif // XE_PLATFORM_MAC factory.Add("nop"); return factory.Create(cvars::apu, processor); } diff --git a/src/xenia/app/xenia_main_ios.mm b/src/xenia/app/xenia_main_ios.mm index f5b7ec064..8d1e4ca74 100644 --- a/src/xenia/app/xenia_main_ios.mm +++ b/src/xenia/app/xenia_main_ios.mm @@ -50,6 +50,7 @@ #endif // Audio systems. +#include "xenia/apu/phase/phase_audio_system.h" #include "xenia/apu/sdl/sdl_audio_system.h" // Input drivers. @@ -79,7 +80,7 @@ DEFINE_path(cache_root, "", "Root path for cache files. If empty, the cache folder under the storage " "root will be used.", "Storage"); -DEFINE_string(apu, "sdl", "Audio system. Use: [sdl, nop]", "APU"); +DEFINE_string(apu, "phase", "Audio system. Use: [sdl, phase, nop]", "APU"); DEFINE_string(gpu, "metal", "Graphics system. Use: [metal, vulkan]", "GPU"); DEFINE_bool(mount_scratch, false, "Enable scratch mount", "Storage"); DEFINE_bool(mount_cache, true, "Enable cache mount", "Storage"); @@ -111,40 +112,49 @@ std::string NSErrorDescription(NSError* error) { void ConfigureAudioSession() { static std::once_flag once; std::call_once(once, []() { - @autoreleasepool { - AVAudioSession* session = [AVAudioSession sharedInstance]; - NSError* error = nil; + // AVAudioSession's setters — especially setActive: — are synchronous and can + // block for a noticeable amount of time, so calling them on the main thread + // risks UI unresponsiveness. Configuration here is fire-and-forget at init + // time (audio playback only begins once a title is running), so hop onto a + // background queue and let it complete asynchronously. + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + @autoreleasepool { + AVAudioSession* session = [AVAudioSession sharedInstance]; + NSError* error = nil; - if (![session setCategory:AVAudioSessionCategoryPlayback - mode:AVAudioSessionModeDefault - options:0 - error:&error]) { - XELOGW("iOS audio session: setCategory failed: {}", NSErrorDescription(error)); - error = nil; + if (![session setCategory:AVAudioSessionCategoryPlayback + mode:AVAudioSessionModeDefault + options:0 + error:&error]) { + XELOGW("iOS audio session: setCategory failed: {}", NSErrorDescription(error)); + error = nil; + } + + if (![session setPreferredSampleRate:48000.0 error:&error]) { + XELOGW("iOS audio session: setPreferredSampleRate failed: {}", + NSErrorDescription(error)); + error = nil; + } + + constexpr NSTimeInterval kPreferredIOBufferDuration = 0.02; + if (![session setPreferredIOBufferDuration:kPreferredIOBufferDuration error:&error]) { + XELOGW("iOS audio session: setPreferredIOBufferDuration failed: {}", + NSErrorDescription(error)); + error = nil; + } + + if (![session setActive:YES error:&error]) { + XELOGW("iOS audio session: activation failed: {}", NSErrorDescription(error)); + error = nil; + } + + XELOGI("iOS audio session: category={}, sample_rate={}, " + "preferred_sample_rate={}, io_buffer={}, preferred_io_buffer={}", + [[session category] UTF8String], [session sampleRate], + [session preferredSampleRate], [session IOBufferDuration], + [session preferredIOBufferDuration]); } - - if (![session setPreferredSampleRate:48000.0 error:&error]) { - XELOGW("iOS audio session: setPreferredSampleRate failed: {}", NSErrorDescription(error)); - error = nil; - } - - constexpr NSTimeInterval kPreferredIOBufferDuration = 0.02; - if (![session setPreferredIOBufferDuration:kPreferredIOBufferDuration error:&error]) { - XELOGW("iOS audio session: setPreferredIOBufferDuration failed: {}", - NSErrorDescription(error)); - error = nil; - } - - if (![session setActive:YES error:&error]) { - XELOGW("iOS audio session: activation failed: {}", NSErrorDescription(error)); - error = nil; - } - - XELOGI("iOS audio session: category={}, sample_rate={}, " - "preferred_sample_rate={}, io_buffer={}, preferred_io_buffer={}", - [[session category] UTF8String], [session sampleRate], [session preferredSampleRate], - [session IOBufferDuration], [session preferredIOBufferDuration]); - } + }); }); } @@ -1532,7 +1542,13 @@ void EmulatorAppIOS::EmulatorThread(const std::filesystem::path& game_path, } std::unique_ptr EmulatorAppIOS::CreateAudioSystem(cpu::Processor* processor) { + // Opt-in PHASE backend renders the native 5.1 bed as spatial audio; default // SDL uses CoreAudio on iOS for audio output. + if (cvars::apu == "phase") { + XELOGI("Audio backend: PHASE (spatial) selected via --apu/apu cvar"); + return std::make_unique(processor); + } + XELOGI("Audio backend: SDL (apu cvar = '{}')", std::string(cvars::apu)); return std::make_unique(processor); } diff --git a/src/xenia/apu/phase/CMakeLists.txt b/src/xenia/apu/phase/CMakeLists.txt new file mode 100644 index 000000000..39df0a795 --- /dev/null +++ b/src/xenia/apu/phase/CMakeLists.txt @@ -0,0 +1,17 @@ +# Apple PHASE spatial-audio backend (iOS + macOS). Sources are listed +# explicitly because xe_platform_sources does not glob .mm files. +add_library(xenia-apu-phase STATIC + phase_audio_driver.h + phase_audio_driver.mm + phase_audio_system.cc + phase_audio_system.h +) +target_link_libraries(xenia-apu-phase PUBLIC + xenia-apu + xenia-base + "-framework Foundation" + "-framework AVFoundation" + "-framework PHASE" + "-framework CoreMotion" +) +xe_target_defaults(xenia-apu-phase) diff --git a/src/xenia/apu/phase/phase_audio_driver.h b/src/xenia/apu/phase/phase_audio_driver.h new file mode 100644 index 000000000..067fb4174 --- /dev/null +++ b/src/xenia/apu/phase/phase_audio_driver.h @@ -0,0 +1,60 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_APU_PHASE_PHASE_AUDIO_DRIVER_H_ +#define XENIA_APU_PHASE_PHASE_AUDIO_DRIVER_H_ + +#include +#include + +#include "xenia/apu/audio_driver.h" + +namespace xe { +namespace threading { +class Semaphore; +} // namespace threading +} // namespace xe + +namespace xe { +namespace apu { +namespace phase { + +// Audio driver that renders the guest 5.1 bed through Apple's PHASE engine for +// native binaural/spatial output. The implementation lives in the .mm file and +// is hidden behind an opaque Impl so this header stays free of Objective-C and +// can be included from plain C++ translation units (e.g. the audio system). +// +// NOTE: This is currently the Phase 1 scaffold — it compiles and links the +// PHASE framework and honors the worker backpressure contract, but produces +// silence until the Phase 2 playback path is implemented. +class PHASEAudioDriver final : public AudioDriver { + public: + explicit PHASEAudioDriver( + xe::threading::Semaphore* semaphore, + uint32_t frequency = kFrameFrequencyDefault, + uint32_t channels = kFrameChannelsDefault); + ~PHASEAudioDriver() override; + + bool Initialize() override; + void Shutdown() override; + void SubmitFrame(float* samples) override; + void Pause() override; + void Resume() override; + void SetVolume(float volume) override; + + private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace phase +} // namespace apu +} // namespace xe + +#endif // XENIA_APU_PHASE_PHASE_AUDIO_DRIVER_H_ diff --git a/src/xenia/apu/phase/phase_audio_driver.mm b/src/xenia/apu/phase/phase_audio_driver.mm new file mode 100644 index 000000000..de04bd1f8 --- /dev/null +++ b/src/xenia/apu/phase/phase_audio_driver.mm @@ -0,0 +1,422 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/apu/phase/phase_audio_driver.h" + +#import +#import +#import + +#include +#include +#include +#include +#include +#include + +#include "xenia/base/byte_order.h" +#include "xenia/base/cvar.h" +#include "xenia/base/logging.h" +#include "xenia/base/threading.h" + +DEFINE_bool(apu_phase_head_tracking, false, + "Enable PHASE AirPods head tracking for the spatial audio backend. " + "Requires the com.apple.developer.coremotion.head-pose entitlement; " + "leaving it on without that entitlement provisioned can make title " + "audio start raise and crash. Off by default.", + "APU"); + +namespace xe { +namespace apu { +namespace phase { + +namespace { +// The guest delivers the main system as 6 planar big-endian float channels of +// 256 samples at 48 kHz (FL, FR, FC, LFE, BL, BR). The PHASE ambient mixer +// renders each channel from its speaker direction and ignores LFE. +constexpr uint32_t kChannelSamples = AudioDriver::kChannelSamplesDefault; // 256 +// Pool is sized at/above the audio system's max queued frames so the guest +// (throttled by the pre-filled semaphore) never starves the free list. +constexpr size_t kBufferPoolSize = 64; +// Bounded wait so Shutdown never blocks forever on a wedged render thread. +constexpr int kShutdownDrainPollMs = 2; +constexpr int kShutdownDrainMaxPolls = 250; // ~0.5s +// Heartbeat cadence for the steady-state stats log (~5s at 48kHz/256). +constexpr uint64_t kHeartbeatFrames = 1000; + +const char* NSErrorCStr(NSError* error) { + return error ? [[error localizedDescription] UTF8String] : "unknown error"; +} +} // namespace + +// State shared with the scheduleBuffer completion blocks. Held by a shared_ptr +// so a late callback is still safe if the driver is torn down first. +struct PHASEAudioDriverShared { + std::mutex mutex; + std::vector free_pool; + std::atomic outstanding{0}; + xe::threading::Semaphore* semaphore = nullptr; + + // Diagnostics. + std::atomic frames_submitted{0}; + std::atomic frames_completed{0}; + std::atomic underruns{0}; + std::atomic dropped_inactive{0}; + std::atomic logged_first_submit{false}; + std::atomic logged_first_complete{false}; +}; + +struct PHASEAudioDriver::Impl { + std::shared_ptr shared = + std::make_shared(); + + uint32_t frequency = 0; + uint32_t channels = 0; + uint32_t channel_samples = kChannelSamples; + std::atomic volume{1.0f}; + std::atomic shutting_down{false}; + std::atomic paused{false}; + + // PHASE graph (strong refs under ARC). + PHASEEngine* engine = nil; + PHASESoundEvent* sound_event = nil; + PHASEPushStreamNode* stream_node = nil; + PHASEListener* listener = nil; + AVAudioFormat* format = nil; + + void ReleaseSemaphore() { + if (shared->semaphore) { + shared->semaphore->Release(1, nullptr); + } + } +}; + +PHASEAudioDriver::PHASEAudioDriver(xe::threading::Semaphore* semaphore, + uint32_t frequency, uint32_t channels) + : impl_(std::make_unique()) { + impl_->shared->semaphore = semaphore; + impl_->frequency = frequency; + impl_->channels = channels; +} + +PHASEAudioDriver::~PHASEAudioDriver() = default; + +bool PHASEAudioDriver::Initialize() { + @autoreleasepool { + @try { + Impl* d = impl_.get(); + NSError* error = nil; + + XELOGI("PHASEAudioDriver::Initialize begin (freq={}, channels={}, samples={})", + d->frequency, d->channels, d->channel_samples); + + d->engine = [[PHASEEngine alloc] initWithUpdateMode:PHASEUpdateModeAutomatic]; + if (!d->engine) { + XELOGE("PHASEAudioDriver: PHASEEngine creation returned nil"); + return false; + } + // Binaural over headphones, channel-based panning on speakers. + d->engine.outputSpatializationMode = PHASESpatializationModeAutomatic; + XELOGI("PHASEAudioDriver: engine created, spatialization=automatic"); + + // 5.1 source layout (L R C LFE Ls Rs) matching the guest channel order. + AVAudioChannelLayout* layout = [[AVAudioChannelLayout alloc] + initWithLayoutTag:kAudioChannelLayoutTag_MPEG_5_1_A]; + if (!layout) { + XELOGE("PHASEAudioDriver: failed to build 5.1 channel layout"); + return false; + } + + // Deinterleaved float32 stream format; same planar shape the guest delivers. + d->format = [[AVAudioFormat alloc] + initWithCommonFormat:AVAudioPCMFormatFloat32 + sampleRate:static_cast(d->frequency) + interleaved:NO + channelLayout:layout]; + if (!d->format) { + XELOGE("PHASEAudioDriver: failed to build stream AVAudioFormat"); + return false; + } + XELOGI("PHASEAudioDriver: stream format ch={}, rate={}, interleaved=no", + static_cast(d->format.channelCount), d->format.sampleRate); + + // Identity orientation: speakers in their canonical directions. + simd_quatf identity = simd_quaternion(0.0f, 0.0f, 0.0f, 1.0f); + PHASEAmbientMixerDefinition* mixer = [[PHASEAmbientMixerDefinition alloc] + initWithChannelLayout:layout + orientation:identity]; + if (!mixer) { + XELOGE("PHASEAudioDriver: ambient mixer creation returned nil"); + return false; + } + + PHASEPushStreamNodeDefinition* stream_def = + [[PHASEPushStreamNodeDefinition alloc] initWithMixerDefinition:mixer + format:d->format + identifier:@"xe"]; + + PHASESoundEventNodeAsset* asset = [d->engine.assetRegistry + registerSoundEventAssetWithRootNode:stream_def + identifier:@"xeAsset" + error:&error]; + if (!asset) { + XELOGE("PHASEAudioDriver: registerSoundEventAsset failed: {}", + NSErrorCStr(error)); + return false; + } + XELOGI("PHASEAudioDriver: sound event asset registered"); + + d->listener = [[PHASEListener alloc] initWithEngine:d->engine]; + d->listener.transform = matrix_identity_float4x4; + // Apple-recommended head tracking updates the listener orientation from + // compatible AirPods, but it requires the restricted + // com.apple.developer.coremotion.head-pose entitlement. Without a + // provisioning profile that grants it, enabling this makes engine start + // raise an exception. Off by default; opt in via apu_phase_head_tracking + // only once the entitlement is provisioned. + if (cvars::apu_phase_head_tracking) { + d->listener.automaticHeadTrackingFlags = + PHASEAutomaticHeadTrackingFlagOrientation; + XELOGI("PHASEAudioDriver: automatic head tracking ENABLED"); + } else { + XELOGI("PHASEAudioDriver: head tracking disabled (fixed listener)"); + } + if (![d->engine.rootObject addChild:d->listener error:&error]) { + XELOGE("PHASEAudioDriver: addChild(listener) failed: {}", + NSErrorCStr(error)); + return false; + } + XELOGI("PHASEAudioDriver: listener added"); + + PHASEMixerParameters* mixer_params = [[PHASEMixerParameters alloc] init]; + [mixer_params addAmbientMixerParametersWithIdentifier:mixer.identifier + listener:d->listener]; + + d->sound_event = [[PHASESoundEvent alloc] initWithEngine:d->engine + assetIdentifier:@"xeAsset" + mixerParameters:mixer_params + error:&error]; + if (!d->sound_event) { + XELOGE("PHASEAudioDriver: PHASESoundEvent init failed: {}", + NSErrorCStr(error)); + return false; + } + + // Pre-allocate the recycled buffer pool (no allocation on the guest thread + // in steady state). + { + std::lock_guard lock(d->shared->mutex); + d->shared->free_pool.reserve(kBufferPoolSize); + for (size_t i = 0; i < kBufferPoolSize; ++i) { + AVAudioPCMBuffer* buf = + [[AVAudioPCMBuffer alloc] initWithPCMFormat:d->format + frameCapacity:d->channel_samples]; + if (!buf) { + XELOGE("PHASEAudioDriver: failed to allocate PCM buffer {} of {}", i, + kBufferPoolSize); + return false; + } + d->shared->free_pool.push_back(buf); + } + } + XELOGI("PHASEAudioDriver: allocated {} pooled PCM buffers", kBufferPoolSize); + + if (![d->engine startAndReturnError:&error]) { + XELOGE("PHASEAudioDriver: engine start failed: {}", NSErrorCStr(error)); + return false; + } + XELOGI("PHASEAudioDriver: engine started"); + + // PHASESoundEvent uses start(completion:) — NOT startAndReturnError: (that + // selector only exists on PHASEEngine). The completion block fires when the + // event later stops or terminates. + [d->sound_event startWithCompletion:nil]; + XELOGI("PHASEAudioDriver: sound event started"); + + d->stream_node = d->sound_event.pushStreamNodes[@"xe"]; + if (!d->stream_node) { + XELOGE("PHASEAudioDriver: push stream node missing after start " + "(pushStreamNodes count={})", + static_cast(d->sound_event.pushStreamNodes.count)); + return false; + } + + XELOGI("PHASEAudioDriver: INITIALIZED OK — spatial 5.1 over PHASE, " + "waiting for frames"); + return true; + } @catch (NSException* ex) { + // Never let a PHASE/ObjC exception crash the title — fail the driver so + // the audio system substitutes a silent fallback, and log the cause. + XELOGE("PHASEAudioDriver: ObjC exception during init: {} — {}", + ex.name ? [ex.name UTF8String] : "(no name)", + ex.reason ? [ex.reason UTF8String] : "(no reason)"); + return false; + } + } +} + +void PHASEAudioDriver::Shutdown() { + @autoreleasepool { + Impl* d = impl_.get(); + auto s = d->shared; + XELOGI("PHASEAudioDriver::Shutdown begin (submitted={}, completed={}, " + "underruns={}, dropped_inactive={}, outstanding={})", + s->frames_submitted.load(), s->frames_completed.load(), + s->underruns.load(), s->dropped_inactive.load(), + s->outstanding.load()); + + d->shutting_down.store(true, std::memory_order_release); + + // PHASESoundEvent stops via stopAndInvalidate(); the engine via stop(). + // Guard against any selector/state surprises so teardown can't crash. + @try { + if (d->sound_event) { + [d->sound_event stopAndInvalidate]; + } + if (d->engine) { + [d->engine stop]; + } + } @catch (NSException* ex) { + XELOGE("PHASEAudioDriver: exception during PHASE teardown: {} — {}", + ex.name ? [ex.name UTF8String] : "(no name)", + ex.reason ? [ex.reason UTF8String] : "(no reason)"); + } + + // Wait (bounded) for outstanding completion callbacks so no late block runs + // after teardown; then flush any permits they would have released so the + // audio worker never blocks waiting on a frame that will never complete. + int polls = 0; + while (s->outstanding.load(std::memory_order_acquire) > 0 && + polls++ < kShutdownDrainMaxPolls) { + std::this_thread::sleep_for( + std::chrono::milliseconds(kShutdownDrainPollMs)); + } + int leaked = s->outstanding.exchange(0, std::memory_order_acq_rel); + if (leaked > 0) { + XELOGW("PHASEAudioDriver: {} buffer(s) never completed; flushing permits " + "after {}ms wait", + leaked, polls * kShutdownDrainPollMs); + } + for (int i = 0; i < leaked; ++i) { + d->ReleaseSemaphore(); + } + + { + std::lock_guard lock(s->mutex); + s->free_pool.clear(); + } + + d->stream_node = nil; + d->sound_event = nil; + d->listener = nil; + d->format = nil; + d->engine = nil; + XELOGI("PHASEAudioDriver::Shutdown complete"); + } +} + +void PHASEAudioDriver::SubmitFrame(float* samples) { + Impl* d = impl_.get(); + auto& s = *d->shared; + + // Backpressure invariant: exactly one semaphore release per SubmitFrame. + if (!d->stream_node || d->shutting_down.load(std::memory_order_acquire) || + d->paused.load(std::memory_order_acquire)) { + s.dropped_inactive.fetch_add(1, std::memory_order_relaxed); + d->ReleaseSemaphore(); + return; + } + + AVAudioPCMBuffer* buf = nil; + size_t pool_free = 0; + { + std::lock_guard lock(s.mutex); + if (!s.free_pool.empty()) { + buf = s.free_pool.back(); + s.free_pool.pop_back(); + } + pool_free = s.free_pool.size(); + } + if (!buf) { + // Pool exhausted (underrun): drop this frame but keep the worker advancing. + uint64_t n = s.underruns.fetch_add(1, std::memory_order_relaxed) + 1; + if (n == 1 || (n % 100) == 0) { + XELOGW("PHASEAudioDriver: buffer pool underrun (count={}, outstanding={})", + n, s.outstanding.load()); + } + d->ReleaseSemaphore(); + return; + } + + const uint32_t ch_count = d->channels; + const uint32_t n = d->channel_samples; + const float vol = d->volume.load(std::memory_order_relaxed); + buf.frameLength = n; + float* const* dst = buf.floatChannelData; + for (uint32_t ch = 0; ch < ch_count; ++ch) { + const float* src = samples + ch * n; + float* out = dst[ch]; + for (uint32_t s_i = 0; s_i < n; ++s_i) { + out[s_i] = xe::byte_swap(src[s_i]) * vol; + } + } + + uint64_t submitted = s.frames_submitted.fetch_add(1, std::memory_order_relaxed) + 1; + if (!s.logged_first_submit.exchange(true, std::memory_order_relaxed)) { + XELOGI("PHASEAudioDriver: first frame submitted to PHASE (vol={})", vol); + } + if ((submitted % kHeartbeatFrames) == 0) { + XELOGI("PHASEAudioDriver: heartbeat submitted={}, completed={}, " + "underruns={}, pool_free={}, outstanding={}", + submitted, s.frames_completed.load(), s.underruns.load(), pool_free, + s.outstanding.load()); + } + + s.outstanding.fetch_add(1, std::memory_order_relaxed); + std::shared_ptr shared = d->shared; + [d->stream_node + scheduleBuffer:buf + completionCallbackType:PHASEPushStreamCompletionDataRendered + completionHandler:^(PHASEPushStreamCompletionCallbackCondition condition) { + (void)condition; + { + std::lock_guard lock(shared->mutex); + shared->free_pool.push_back(buf); + } + shared->outstanding.fetch_sub(1, std::memory_order_relaxed); + shared->frames_completed.fetch_add(1, std::memory_order_relaxed); + if (!shared->logged_first_complete.exchange( + true, std::memory_order_relaxed)) { + XELOGI("PHASEAudioDriver: first buffer rendered by PHASE " + "(audio path confirmed live)"); + } + if (shared->semaphore) { + shared->semaphore->Release(1, nullptr); + } + }]; +} + +void PHASEAudioDriver::Pause() { + XELOGI("PHASEAudioDriver::Pause"); + impl_->paused.store(true, std::memory_order_release); +} + +void PHASEAudioDriver::Resume() { + XELOGI("PHASEAudioDriver::Resume"); + impl_->paused.store(false, std::memory_order_release); +} + +void PHASEAudioDriver::SetVolume(float volume) { + XELOGI("PHASEAudioDriver::SetVolume {}", volume); + impl_->volume.store(volume, std::memory_order_relaxed); +} + +} // namespace phase +} // namespace apu +} // namespace xe diff --git a/src/xenia/apu/phase/phase_audio_system.cc b/src/xenia/apu/phase/phase_audio_system.cc new file mode 100644 index 000000000..1a1f7649f --- /dev/null +++ b/src/xenia/apu/phase/phase_audio_system.cc @@ -0,0 +1,79 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#include "xenia/apu/phase/phase_audio_system.h" + +#include "xenia/apu/phase/phase_audio_driver.h" +#include "xenia/apu/silent_audio_driver.h" +#include "xenia/base/logging.h" +#include "xenia/base/threading.h" + +namespace xe { +namespace apu { +namespace phase { + +std::unique_ptr PHASEAudioSystem::Create( + cpu::Processor* processor) { + return std::make_unique(processor); +} + +PHASEAudioSystem::PHASEAudioSystem(cpu::Processor* processor) + : AudioSystem(processor) {} + +PHASEAudioSystem::~PHASEAudioSystem() {} + +void PHASEAudioSystem::Initialize() { AudioSystem::Initialize(); } + +X_STATUS PHASEAudioSystem::CreateDriver(size_t index, + xe::threading::Semaphore* semaphore, + AudioDriver** out_driver) { + assert_not_null(out_driver); + // Only guest client 0 drives the single shared PHASE engine. Additional + // clients use a silent fallback: there is one physical output, and standing + // up multiple engines would contend and waste CPU on a mobile device. + if (index > 0) { + *out_driver = new SilentAudioDriver(semaphore); + return X_STATUS_SUCCESS; + } + + auto driver = std::make_unique(semaphore); + if (!driver->Initialize()) { + driver->Shutdown(); + XELOGW("PHASEAudioSystem: PHASE init failed, using silent fallback"); + *out_driver = new SilentAudioDriver(semaphore); + return X_STATUS_SUCCESS; + } + + *out_driver = driver.release(); + return X_STATUS_SUCCESS; +} + +AudioDriver* PHASEAudioSystem::CreateDriver(xe::threading::Semaphore* semaphore, + uint32_t frequency, + uint32_t channels, + bool need_format_conversion) { + (void)frequency; + (void)channels; + (void)need_format_conversion; + // The independent/XMP path carries stereo content we don't spatialize; keep + // it silent under the PHASE backend for now. + return new SilentAudioDriver(semaphore); +} + +void PHASEAudioSystem::DestroyDriver(AudioDriver* driver) { + assert_not_null(driver); + // AudioDriver is polymorphic (virtual Shutdown + dtor), so this handles both + // PHASEAudioDriver and SilentAudioDriver without a downcast. + driver->Shutdown(); + delete driver; +} + +} // namespace phase +} // namespace apu +} // namespace xe diff --git a/src/xenia/apu/phase/phase_audio_system.h b/src/xenia/apu/phase/phase_audio_system.h new file mode 100644 index 000000000..12646564d --- /dev/null +++ b/src/xenia/apu/phase/phase_audio_system.h @@ -0,0 +1,49 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_APU_PHASE_PHASE_AUDIO_SYSTEM_H_ +#define XENIA_APU_PHASE_PHASE_AUDIO_SYSTEM_H_ + +#include "xenia/apu/audio_system.h" + +namespace xe { +namespace apu { +namespace phase { + +// Apple-platforms audio system that renders the main 5.1 system through PHASE +// for native spatial audio. Mirrors SDLAudioSystem: a single real driver backs +// guest client 0; all other clients and the independent/XMP path fall back to a +// silent driver (one physical output; multiple PHASE engines would contend). +class PHASEAudioSystem : public AudioSystem { + public: + explicit PHASEAudioSystem(cpu::Processor* processor); + ~PHASEAudioSystem() override; + + static bool IsAvailable() { return true; } + + static std::unique_ptr Create(cpu::Processor* processor); + + std::string name() const override { return "PHASE"; } + + X_RESULT CreateDriver(size_t index, xe::threading::Semaphore* semaphore, + AudioDriver** out_driver) override; + AudioDriver* CreateDriver(xe::threading::Semaphore* semaphore, + uint32_t frequency, uint32_t channels, + bool need_format_conversion) override; + void DestroyDriver(AudioDriver* driver) override; + + protected: + void Initialize() override; +}; + +} // namespace phase +} // namespace apu +} // namespace xe + +#endif // XENIA_APU_PHASE_PHASE_AUDIO_SYSTEM_H_ diff --git a/src/xenia/apu/sdl/sdl_audio_system.cc b/src/xenia/apu/sdl/sdl_audio_system.cc index cb5502bf0..5afba95d2 100644 --- a/src/xenia/apu/sdl/sdl_audio_system.cc +++ b/src/xenia/apu/sdl/sdl_audio_system.cc @@ -13,6 +13,7 @@ #include "xenia/apu/apu_flags.h" #include "xenia/apu/sdl/sdl_audio_driver.h" +#include "xenia/apu/silent_audio_driver.h" #include "xenia/base/logging.h" #include "xenia/base/threading.h" @@ -20,35 +21,6 @@ namespace xe { namespace apu { namespace sdl { -namespace { - -#if XE_PLATFORM_IOS -// Fallback used when SDL/CoreAudio device creation fails on iOS. -// Keeps guest audio callback flow alive without real audio output. -class SilentAudioDriver final : public AudioDriver { - public: - explicit SilentAudioDriver(xe::threading::Semaphore* semaphore) - : semaphore_(semaphore) {} - - bool Initialize() override { return true; } - void Shutdown() override {} - void SubmitFrame(float* samples) override { - (void)samples; - if (semaphore_) { - semaphore_->Release(1, nullptr); - } - } - void Pause() override {} - void Resume() override {} - void SetVolume(float volume) override { (void)volume; } - - private: - xe::threading::Semaphore* semaphore_ = nullptr; -}; -#endif // XE_PLATFORM_IOS - -} // namespace - std::unique_ptr SDLAudioSystem::Create(cpu::Processor* processor) { return std::make_unique(processor); } diff --git a/src/xenia/apu/silent_audio_driver.h b/src/xenia/apu/silent_audio_driver.h new file mode 100644 index 000000000..fb6026b17 --- /dev/null +++ b/src/xenia/apu/silent_audio_driver.h @@ -0,0 +1,48 @@ +/** + ****************************************************************************** + * Xenia : Xbox 360 Emulator Research Project * + ****************************************************************************** + * Copyright 2026 Ben Vanik. All rights reserved. * + * Released under the BSD license - see LICENSE in the root for more details. * + ****************************************************************************** + */ + +#ifndef XENIA_APU_SILENT_AUDIO_DRIVER_H_ +#define XENIA_APU_SILENT_AUDIO_DRIVER_H_ + +#include "xenia/apu/audio_driver.h" +#include "xenia/base/threading.h" + +namespace xe { +namespace apu { + +// A driver that produces no audio output but still honors the audio worker +// backpressure contract: every SubmitFrame releases one semaphore permit so the +// guest callback flow keeps advancing. Used as a fallback when a real output +// device can't be created (e.g. secondary clients on platforms that only +// support a single shared output device). +class SilentAudioDriver final : public AudioDriver { + public: + explicit SilentAudioDriver(xe::threading::Semaphore* semaphore) + : semaphore_(semaphore) {} + + bool Initialize() override { return true; } + void Shutdown() override {} + void SubmitFrame(float* samples) override { + (void)samples; + if (semaphore_) { + semaphore_->Release(1, nullptr); + } + } + void Pause() override {} + void Resume() override {} + void SetVolume(float volume) override { (void)volume; } + + private: + xe::threading::Semaphore* semaphore_ = nullptr; +}; + +} // namespace apu +} // namespace xe + +#endif // XENIA_APU_SILENT_AUDIO_DRIVER_H_ diff --git a/src/xenia/base/memory_posix.cc b/src/xenia/base/memory_posix.cc index 269022e11..892850378 100644 --- a/src/xenia/base/memory_posix.cc +++ b/src/xenia/base/memory_posix.cc @@ -77,7 +77,12 @@ void AndroidShutdown() { } #endif -size_t page_size() { return getpagesize(); } +size_t page_size() { + // getpagesize() is fixed for the process lifetime but showed up as ~0.3% of + // total CPU (a repeated syscall on hot paths); cache it once. + static const size_t cached_page_size = getpagesize(); + return cached_page_size; +} size_t allocation_granularity() { return page_size(); } uint32_t ToPosixProtectFlags(PageAccess access) { diff --git a/src/xenia/base/threading_timer_queue.cc b/src/xenia/base/threading_timer_queue.cc index 56bcf9f36..bde1e057b 100644 --- a/src/xenia/base/threading_timer_queue.cc +++ b/src/xenia/base/threading_timer_queue.cc @@ -16,6 +16,7 @@ #include "third_party/disruptorplus/include/disruptorplus/spin_wait.hpp" #include "third_party/disruptorplus/include/disruptorplus/spin_wait_strategy.hpp" #include "xenia/base/assert.h" +#include "xenia/base/platform.h" #include "xenia/base/threading.h" #include "xenia/base/threading_timer_queue.h" @@ -38,11 +39,24 @@ using WaitItem = TimerQueueWaitItem; */ +#if XE_PLATFORM_APPLE +// On Apple platforms, block on a condition variable instead of busy-spinning. +// In iOS CPU traces the spin strategy showed up as a recurring busy-yield plus a +// hardware_concurrency() sysctl per reset; busy-yield wastes energy/thermal on +// mobile. blocking_wait_strategy supports the timed (time_point) overload of +// wait_until_published that the dispatch loop uses, and the claim/sequence- +// barrier publish path calls signal_all_when_blocking(), so QueueTimer still +// wakes the dispatch thread immediately. The Windows/proton concern below does +// not apply on Apple (proton is a Windows-on-Linux layer), so this only changes +// Apple behavior; other platforms keep the spin strategy unchanged. +using WaitStrat = dp::blocking_wait_strategy; +#else /* edit2: (30.12.2024) After uplifting version of MSVC compiler Xenia cannot be correctly initialized if you're using proton. */ using WaitStrat = dp::spin_wait_strategy; +#endif class TimerQueue { public: diff --git a/src/xenia/gpu/command_processor.cc b/src/xenia/gpu/command_processor.cc index 51d216056..907695091 100644 --- a/src/xenia/gpu/command_processor.cc +++ b/src/xenia/gpu/command_processor.cc @@ -14,6 +14,7 @@ #include "xenia/base/clock.h" #include "xenia/base/cvar.h" #include "xenia/base/logging.h" +#include "xenia/base/platform.h" #include "xenia/base/profiling.h" #include "xenia/base/threading.h" #include "xenia/config.h" @@ -37,6 +38,15 @@ DEFINE_bool( "its own, just ones the guest makes or instructs it to make.", "GPU"); +#if XE_PLATFORM_IOS +DEFINE_bool(ios_gpu_commands_user_initiated_qos, true, + "Run the GPU command processor (\"GPU Commands\") host thread at " + "user-initiated QoS on iOS so frame submission and presentation are " + "not descheduled under load. On by default; turn off to A/B against " + "default QoS.", + "iOS"); +#endif // XE_PLATFORM_IOS + DEFINE_bool(disassemble_pm4, false, "Only does anything in debug builds, if set will disassemble and " "log all PM4 packets sent to the CP.", @@ -64,7 +74,10 @@ DEFINE_string( " though some effects may look slightly wrong.\n" " fast: Ask the GPU but don't wait for the answer. Writes a cached\n" " result immediately and updates it when the GPU catches up.\n" - " (default)\n" + " Cached results bias toward visible when guessing. (default)\n" + " fast-alt: Variant of fast mode that keeps cached zero results for\n" + " unresolved reports. May improve effects relying on precise\n" + " visibility, but may be less stable for occlusion culling.\n" " strict: Ask the GPU and wait for the real result before continuing.\n" " Most accurate, but may be somewhat less performant.", "GPU"); @@ -150,6 +163,8 @@ static ZPDMode ParseZPDMode() { return ZPDMode::kStrict; } else if (mode == "fast") { return ZPDMode::kFast; + } else if (mode == "fast-alt") { + return ZPDMode::kFastAlt; } else { // Default to "fake" for any unrecognized value. return ZPDMode::kFake; @@ -182,6 +197,12 @@ CommandProcessor::CommandProcessor(GraphicsSystem* graphics_system, CommandProcessor::~CommandProcessor() = default; +#if XE_PLATFORM_IOS +bool CommandProcessor::IsTitleStopRequestedIOS() const { + return kernel_state_ && kernel_state_->IsTitleStopRequestedIOS(); +} +#endif // XE_PLATFORM_IOS + bool CommandProcessor::Initialize() { // Initialize the gamma ramps to their default (linear) values - taken from // what games set when starting with the sRGB (return value 1) @@ -384,6 +405,9 @@ void CommandProcessor::SetZPDMode(ZPDMode mode) { case ZPDMode::kFast: mode_str = "fast"; break; + case ZPDMode::kFastAlt: + mode_str = "fast-alt"; + break; case ZPDMode::kStrict: mode_str = "strict"; break; @@ -480,6 +504,21 @@ void CommandProcessor::ThrottlePresentation() { } void CommandProcessor::WorkerThreadMain() { +#if XE_PLATFORM_IOS + // Lift the GPU command/submit/present thread out of default QoS so iOS does + // not deschedule it across vsync under load. Toggle: + // ios_gpu_commands_user_initiated_qos (on by default). Mirrors the + // Emulator Thread QoS promotion in xenia_main_ios. + xe::threading::set_current_thread_qos(xe::threading::ThreadQoS::kDefault); + if (cvars::ios_gpu_commands_user_initiated_qos) { + if (xe::threading::set_current_thread_qos( + xe::threading::ThreadQoS::kUserInitiated)) { + XELOGI("iOS: GPU Commands thread QoS set to user-initiated"); + } else { + XELOGW("iOS: GPU Commands thread QoS request failed"); + } + } +#endif // XE_PLATFORM_IOS if (!SetupContext()) { xe::FatalError("Unable to setup command processor internal state"); return; @@ -500,9 +539,10 @@ void CommandProcessor::WorkerThreadMain() { // event is too high. PrepareForWait(); uint32_t loop_count = 0; + constexpr uint32_t idle_spin_yields = 500; do { // If we spin around too much, revert to a "low-power" state. - if (loop_count > 500) { + if (loop_count >= idle_spin_yields) { constexpr int wait_time_ms = 2; xe::threading::Wait(write_ptr_index_event_.get(), true, std::chrono::milliseconds(wait_time_ms)); @@ -1053,24 +1093,37 @@ CommandProcessor::PendingZPDSlot CommandProcessor::GetPendingZPDSlot( pending_slot.report_handle = report_pair.first; } - // Keep the strongest fallback delta if this slot has to be reused early. - pending_slot.cached_delta = - std::max(pending_slot.cached_delta, report.cached_delta); + // Slot reuse needs to be handled carefully in fast mode. Keep the biggest + // cached delta, not the newest one. A stale zero is a lot more dangerous + // than a stale nonzero. + if (report.has_cached_delta) { + if (!pending_slot.has_cached_delta || + report.cached_delta > pending_slot.cached_delta) { + pending_slot.cached_delta = report.cached_delta; + } + pending_slot.has_cached_delta = true; + } if (report.end_record) { auto report_cache_it = fast_zpd_report_cached_values_.find(report.end_record); if (report_cache_it != fast_zpd_report_cached_values_.end()) { - pending_slot.cached_delta = - std::max(pending_slot.cached_delta, report_cache_it->second); + if (!pending_slot.has_cached_delta || + report_cache_it->second > pending_slot.cached_delta) { + pending_slot.cached_delta = report_cache_it->second; + } + pending_slot.has_cached_delta = true; } } } auto end_record_cache_it = fast_zpd_report_cached_values_.find(end_record); if (end_record_cache_it != fast_zpd_report_cached_values_.end()) { - pending_slot.cached_delta = - std::max(pending_slot.cached_delta, end_record_cache_it->second); + if (!pending_slot.has_cached_delta || + end_record_cache_it->second > pending_slot.cached_delta) { + pending_slot.cached_delta = end_record_cache_it->second; + } + pending_slot.has_cached_delta = true; } return pending_slot; @@ -1083,6 +1136,7 @@ bool CommandProcessor::BeginZPDReport(uint32_t report_address) { // Track any delta to carry forward if the same slot is immediately reused. uint32_t carried_cached_delta = 0; + bool has_carried_cached_delta = false; uint32_t carried_from_slot_base = 0; if (zpd_active_segment_.logical_active) { @@ -1103,8 +1157,10 @@ bool CommandProcessor::BeginZPDReport(uint32_t report_address) { auto dying_report = logical_zpd_reports_.find(zpd_active_segment_.report_handle); // Carry prior delta forward so the slot doesn't briefly look occluded. - if (dying_report != logical_zpd_reports_.end()) { + if (dying_report != logical_zpd_reports_.end() && + dying_report->second.has_cached_delta) { carried_cached_delta = dying_report->second.cached_delta; + has_carried_cached_delta = true; } if (zpd_active_segment_.segment_active) { @@ -1143,10 +1199,12 @@ bool CommandProcessor::BeginZPDReport(uint32_t report_address) { if (pending_slot.report_handle != kInvalidReportHandle) { zpd_stats_.same_slot_reuse++; - if (GetZPDMode() == ZPDMode::kFast) { - carried_cached_delta = - pending_slot.cached_delta ? pending_slot.cached_delta : 1; - carried_from_slot_base = slot_base; + if (GetZPDMode() == ZPDMode::kFast || GetZPDMode() == ZPDMode::kFastAlt) { + if (pending_slot.has_cached_delta) { + carried_cached_delta = pending_slot.cached_delta; + has_carried_cached_delta = true; + carried_from_slot_base = slot_base; + } } else { while (pending_slot.report_handle != kInvalidReportHandle) { auto report_it = logical_zpd_reports_.find(pending_slot.report_handle); @@ -1163,6 +1221,7 @@ bool CommandProcessor::BeginZPDReport(uint32_t report_address) { if (!wait_succeeded) { if (pending_slot.cached_delta != 0) { carried_cached_delta = pending_slot.cached_delta; + has_carried_cached_delta = true; carried_from_slot_base = slot_base; } break; @@ -1178,10 +1237,10 @@ bool CommandProcessor::BeginZPDReport(uint32_t report_address) { // Bump slot sequence — invalidates pending writes from prior lifetime. uint64_t slot_sequence_id = ++zpd_slot_sequences_[slot_base]; - // Clear the cached delta for this address so an orphan END can't carry a - // value from the previous lifetime into the new one. A real END will write - // the cache again when it resolves. - if (cvars::occlusion_query_fast_trust_report) { + // By default, BEGIN drops the cached value so an orphaned END doesn't replay + // something from a prior lifetime. The alternate fast path keeps it around + // long enough for an async zero to help the next unresolved write. + if (GetZPDMode() != ZPDMode::kFastAlt) { fast_zpd_report_cached_values_.erase(end_record); } @@ -1201,10 +1260,12 @@ bool CommandProcessor::BeginZPDReport(uint32_t report_address) { logical.last_segment_end_submission = 0; logical.pending_segments = 0; logical.cached_delta = 0; + logical.has_cached_delta = false; logical.ended = false; - if (slot_base == carried_from_slot_base && carried_cached_delta != 0) { + if (slot_base == carried_from_slot_base && has_carried_cached_delta) { logical.cached_delta = carried_cached_delta; + logical.has_cached_delta = true; } zpd_active_segment_.report_handle = report_handle; @@ -1259,7 +1320,8 @@ bool CommandProcessor::EndZPDReport(uint32_t report_address, uint32_t begin_record = 0; uint32_t begin_value = 0; uint32_t final_value = 0; - uint32_t cached_delta = 1; + uint32_t cached_delta = 0; + bool has_cached_delta = false; auto it = logical_zpd_reports_.find(report_handle); if (it == logical_zpd_reports_.end()) { @@ -1277,13 +1339,10 @@ bool CommandProcessor::EndZPDReport(uint32_t report_address, resolved_immediately = true; final_value = NormalizeSampleCount(logical.accumulated_samples); - // Use the current report's result. A cached delta carried over from an - // earlier lifetime on this slot should not replace a real zero. - cached_delta = cvars::occlusion_query_fast_trust_report ? final_value - : (final_value == 0 && logical.cached_delta != 0) - ? logical.cached_delta - : final_value; + cached_delta = final_value; + has_cached_delta = true; logical.cached_delta = cached_delta; + logical.has_cached_delta = true; if (fast_zpd_report_cached_values_.size() >= kFastZPDCacheMaxEntries && !fast_zpd_report_cached_values_.count(report_record_base)) { fast_zpd_report_cached_values_.clear(); @@ -1291,12 +1350,14 @@ bool CommandProcessor::EndZPDReport(uint32_t report_address, fast_zpd_report_cached_values_[report_record_base] = cached_delta; final_value = cached_delta; } else { - if (logical.cached_delta != 0) { + if (logical.has_cached_delta) { cached_delta = logical.cached_delta; + has_cached_delta = true; } auto cache_it = fast_zpd_report_cached_values_.find(report_record_base); if (cache_it != fast_zpd_report_cached_values_.end()) { cached_delta = cache_it->second; + has_cached_delta = true; } } @@ -1311,15 +1372,20 @@ bool CommandProcessor::EndZPDReport(uint32_t report_address, WriteZPDReport(0, stored_end_record, 0, begin_value, false); } - if (GetZPDMode() == ZPDMode::kFast) { + if (GetZPDMode() == ZPDMode::kFast || GetZPDMode() == ZPDMode::kFastAlt) { bool write_begin = begin_record && report_record_base && begin_record != report_record_base; - // Promote zero to one only while segments are still pending. Once the real - // result is ready, write it as is. - bool escape_zero = - write_begin && cached_delta == 0 && - (!cvars::occlusion_query_fast_trust_report || !resolved_immediately); - uint32_t speculative = escape_zero ? 1 : cached_delta; + // Unknown still means visible in fast mode. Reusing cached zeroes can help + // flares stop shining through walls, but it also tends to break occlusion + // culling, so only do it in the alternate fast path. + uint32_t speculative = cached_delta; + if (!resolved_immediately) { + speculative = 1; + if (has_cached_delta && + (cached_delta != 0 || GetZPDMode() == ZPDMode::kFastAlt)) { + speculative = cached_delta; + } + } WriteZPDReport(begin_record, report_record_base, begin_value, speculative, write_begin); } else if (!resolved_immediately) { @@ -1343,7 +1409,8 @@ bool CommandProcessor::EndZPDReport(uint32_t report_address, } void CommandProcessor::OpenQuerySegment(bool can_close_submission) { - if (GetZPDMode() == ZPDMode::kFake || !zpd_active_segment_.logical_active || + if (GetZPDMode() == ZPDMode::kFake || zpd_force_fake_fallback_ || + !zpd_active_segment_.logical_active || !zpd_active_segment_.segment_pending_begin || !CanOpenZPDQuery()) { return; } @@ -1352,6 +1419,9 @@ void CommandProcessor::OpenQuerySegment(bool can_close_submission) { if (!IsZPDQueryPoolReady()) { zpd_stats_.failed++; + zpd_force_fake_fallback_ = true; + logical_zpd_reports_.erase(zpd_active_segment_.report_handle); + zpd_active_segment_ = {}; return; } @@ -1367,7 +1437,7 @@ void CommandProcessor::OpenQuerySegment(bool can_close_submission) { return; case QueryOpenResult::kPoolExhausted: { zpd_stats_.pool_exhausted++; - if (GetZPDMode() == ZPDMode::kFast) { + if (GetZPDMode() == ZPDMode::kFast || GetZPDMode() == ZPDMode::kFastAlt) { // Fast mode favors forward progress over accuracy. Keep a minimal // accumulated value instead of waiting for a slot to become available. auto it = logical_zpd_reports_.find(zpd_active_segment_.report_handle); @@ -1446,6 +1516,7 @@ void CommandProcessor::OnZPDQueryResolved(ReportHandle report_handle, uint32_t final_value = NormalizeSampleCount(logical.accumulated_samples); logical.cached_delta = final_value; + logical.has_cached_delta = true; if (logical.end_record) { if (fast_zpd_report_cached_values_.size() >= kFastZPDCacheMaxEntries && !fast_zpd_report_cached_values_.count(logical.end_record)) { @@ -1519,8 +1590,10 @@ void CommandProcessor::PumpPendingRetire() { } // Write the cached delta to guest memory to avoid a sudden occlusion flash. if (IsZPDReportCurrent(logical_report->second)) { - CommitZPDReport(logical_report->second, - logical_report->second.cached_delta); + uint32_t fallback_delta = logical_report->second.cached_delta + ? logical_report->second.cached_delta + : 1; + CommitZPDReport(logical_report->second, fallback_delta); } logical_zpd_reports_.erase(logical_report); zpd_pending_retire_handle_ = kInvalidReportHandle; diff --git a/src/xenia/gpu/command_processor.h b/src/xenia/gpu/command_processor.h index 72be7fea7..d0e828433 100644 --- a/src/xenia/gpu/command_processor.h +++ b/src/xenia/gpu/command_processor.h @@ -22,6 +22,7 @@ #include #include "xenia/base/math.h" +#include "xenia/base/platform.h" #include "xenia/base/ring_buffer.h" #include "xenia/gpu/register_file.h" #include "xenia/gpu/trace_writer.h" @@ -52,9 +53,10 @@ enum class ReadbackResolveMode { // Occlusion queries - ZPD report mode. enum class ZPDMode { - kFake, // Fake sample counts, no real GPU queries (fake) - kFast, // Real queries with speculative cached writes (fast) - kStrict, // Real queries, waits before writeback. May hang. (strict) + kFake, // Fake sample counts, no real GPU queries (fake) + kFast, // Real queries with speculative cached writes (fast) + kFastAlt, // Fast queries, but preserves cached zeroes (fast-alt) + kStrict, // Real queries, waits before writeback. May hang. (strict) }; void SaveGPUSetting(GPUSetting setting, uint64_t value); @@ -353,8 +355,10 @@ class CommandProcessor { uint32_t begin_value = 0; uint32_t pending_segments = 0; // Last known delta. Carried forward on forced close so slot doesn't - // briefly look fully occluded. + // briefly look fully occluded. 0 is a valid delta for alternate fast path. uint32_t cached_delta = 0; + // Distinguishes "we resolved to zero" from "we have no cached value yet". + bool has_cached_delta = false; bool ended = false; }; @@ -372,6 +376,7 @@ class CommandProcessor { struct PendingZPDSlot { ReportHandle report_handle = kInvalidReportHandle; uint32_t cached_delta = 0; + bool has_cached_delta = false; }; // Logged by the backend every 100 frames if ZPD logging cvar is true. @@ -466,8 +471,13 @@ class CommandProcessor { zpd_pending_retire_handle_ = kInvalidReportHandle; zpd_pending_retire_stalls_ = 0; zpd_pending_retire_start_ms_ = 0; + zpd_force_fake_fallback_ = false; } +#if XE_PLATFORM_IOS + bool IsTitleStopRequestedIOS() const; +#endif // XE_PLATFORM_IOS + #include "pm4_command_processor_declare.h" virtual Shader* LoadShader(xenos::ShaderType shader_type, @@ -514,6 +524,11 @@ class CommandProcessor { uint32_t querybatch_zpd_sample_count_ = UINT32_MAX; + // Sticky after host pool init failure. Forces EVENT_WRITE_ZPD onto the fake + // path so guests don't stall waiting on a pending sentinel that will never + // be written. Cleared by ResetZPDState. + bool zpd_force_fake_fallback_ = false; + // Strict mode defers guest completion until the queued END has retired. ReportHandle zpd_pending_retire_handle_ = kInvalidReportHandle; uint32_t zpd_pending_retire_stalls_ = 0; diff --git a/src/xenia/gpu/draw_util.cc b/src/xenia/gpu/draw_util.cc index c9465ab71..55033f728 100644 --- a/src/xenia/gpu/draw_util.cc +++ b/src/xenia/gpu/draw_util.cc @@ -28,11 +28,13 @@ DEFINE_bool( "is necessary for certain games to display the scene graphics).", "GPU"); -DEFINE_double( - depth_bias_decal_clamp, 0.0, - "Minimum depth bias for projected decals. Set to 0 to disable. UE3 titles " - "often use very small bias values (~0.00005) that cause Z-fighting on " - "near-coplanar decal projections with host render target paths.", +DEFINE_bool( + depth_bias_shader_offset, false, + "Route decal host render target draws with polygon offset through shader " + "depth. This avoids Z-fighting in games that rely on tiny depth bias " + "values that host fixed function depth bias cannot reproduce reliably." + "This likely causes some minor performance penalty, but the cost should " + "be minimal if only a small portion of the scene is affected.", "GPU"); namespace xe { @@ -85,6 +87,57 @@ reg::RB_DEPTHCONTROL GetNormalizedDepthControl(const RegisterFile& regs) { return depthcontrol; } +bool GetHostDepthPolygonOffsetIfNeeded( + const RegisterFile& regs, bool primitive_polygonal, + reg::RB_DEPTHCONTROL normalized_depth_control, + uint32_t normalized_color_mask, + HostDepthPolygonOffset& polygon_offset_out) { + polygon_offset_out = {}; + if (!cvars::depth_bias_shader_offset) { + return false; + } + + const xenos::CompareFunction zfunc = normalized_depth_control.zfunc; + // Keep this on the decal-style redraws that need it. Larger use of shader + // depth changes early-Z and coverage behavior in places like hair, foliage, + // and stencil masked effects. + if (!primitive_polygonal || !normalized_depth_control.z_enable || + !(zfunc == xenos::CompareFunction::kLessEqual || + zfunc == xenos::CompareFunction::kGreaterEqual) || + !normalized_color_mask || normalized_depth_control.stencil_enable || + regs.Get().alpha_to_mask_enable) { + return false; + } + + auto pa_su_sc_mode_cntl = regs.Get(); + if (pa_su_sc_mode_cntl.poly_offset_front_enable) { + polygon_offset_out.front_scale = + regs.Get(XE_GPU_REG_PA_SU_POLY_OFFSET_FRONT_SCALE); + polygon_offset_out.front_offset = + regs.Get(XE_GPU_REG_PA_SU_POLY_OFFSET_FRONT_OFFSET); + } + if (pa_su_sc_mode_cntl.poly_offset_back_enable) { + polygon_offset_out.back_scale = + regs.Get(XE_GPU_REG_PA_SU_POLY_OFFSET_BACK_SCALE); + polygon_offset_out.back_offset = + regs.Get(XE_GPU_REG_PA_SU_POLY_OFFSET_BACK_OFFSET); + } + + if (!polygon_offset_out.front_scale && !polygon_offset_out.front_offset && + !polygon_offset_out.back_scale && !polygon_offset_out.back_offset) { + return false; + } + + polygon_offset_out.front_scale *= xenos::kPolygonOffsetScaleSubpixelUnit; + polygon_offset_out.back_scale *= xenos::kPolygonOffsetScaleSubpixelUnit; + if (regs.Get().depth_format == + xenos::DepthRenderTargetFormat::kD24FS8) { + polygon_offset_out.front_offset *= 0.5f; + polygon_offset_out.back_offset *= 0.5f; + } + return true; +} + // https://docs.microsoft.com/en-us/windows/win32/api/d3d11/ne-d3d11-d3d11_standard_multisample_quality_levels constexpr int8_t kD3D10StandardSamplePositions2x[2][2] = {{4, 4}, {-4, -4}}; constexpr int8_t kD3D10StandardSamplePositions4x[4][2] = { @@ -117,22 +170,6 @@ void GetPreferredFacePolygonOffset(const RegisterFile& regs, offset = regs.Get(XE_GPU_REG_PA_SU_POLY_OFFSET_FRONT_OFFSET); } } - // Clamp small positive depth bias to prevent Z-fighting on decals. - // UE3 titles often use very small bias values (~0.00005f) that cause - // Z-fighting on near-coplanar projected decals (static deferred decal style - // draws). A minimum of ~0.01f keeps them stable even at extreme grazing - // angles. The threshold where Z-fighting typically kicks in is around - // 0.001f or smaller. - float min_depth_bias = float(cvars::depth_bias_decal_clamp); - if (min_depth_bias > 0.0f && offset > 0.0f && offset < min_depth_bias) { - offset = min_depth_bias; - // When pushing geometry away (positive offset), ensure the slope scale - // doesn't counteract it by going negative at grazing angles. - if (scale < 0.0f) { - scale = 0.0f; - } - } - scale_out = scale; offset_out = offset; } diff --git a/src/xenia/gpu/draw_util.h b/src/xenia/gpu/draw_util.h index 104e8118b..aa47ebf81 100644 --- a/src/xenia/gpu/draw_util.h +++ b/src/xenia/gpu/draw_util.h @@ -235,6 +235,22 @@ inline int32_t GetD3D10IntegerPolygonOffset( return polygon_offset < 0 ? -polygon_offset_int : polygon_offset_int; } +struct HostDepthPolygonOffset { + float front_scale = 0.0f; + float front_offset = 0.0f; + float back_scale = 0.0f; + float back_offset = 0.0f; +}; + +// Detects a narrow type of coplanar redraws that tend to Z-fight when the depth +// bias is applied via host fixed function polygon offset, like static decals in +// UE3 and Halo engine titles. Host space offsets are computed so that the depth +// bias is applied via shader depth output instead. +bool GetHostDepthPolygonOffsetIfNeeded( + const RegisterFile& regs, bool primitive_polygonal, + reg::RB_DEPTHCONTROL normalized_depth_control, + uint32_t normalized_color_mask, HostDepthPolygonOffset& polygon_offset_out); + // For hosts not supporting separate front and back polygon offsets, returns the // polygon offset for the face which likely needs the offset the most (and that // will not be culled). The values returned will have the units of the original diff --git a/src/xenia/gpu/graphics_system.cc b/src/xenia/gpu/graphics_system.cc index 235178858..9706702b0 100644 --- a/src/xenia/gpu/graphics_system.cc +++ b/src/xenia/gpu/graphics_system.cc @@ -83,6 +83,30 @@ GraphicsSystem::GraphicsSystem() : frame_limiter_worker_running_(false) { GraphicsSystem::~GraphicsSystem() = default; +void GraphicsSystem::SetScaledAspectRatio(uint32_t x, uint32_t y) { + uint32_t old_x = scaled_aspect_x_.exchange(x, std::memory_order_relaxed); + uint32_t old_y = scaled_aspect_y_.exchange(y, std::memory_order_relaxed); + if (old_x == x && old_y == y) { + return; + } + + ScaledAspectRatioChangedCallback callback; + { + std::lock_guard lock( + scaled_aspect_ratio_changed_callback_mutex_); + callback = scaled_aspect_ratio_changed_callback_; + } + if (callback) { + callback(x, y); + } +} + +void GraphicsSystem::SetScaledAspectRatioChangedCallback( + ScaledAspectRatioChangedCallback callback) { + std::lock_guard lock(scaled_aspect_ratio_changed_callback_mutex_); + scaled_aspect_ratio_changed_callback_ = std::move(callback); +} + X_STATUS GraphicsSystem::Setup(cpu::Processor* processor, kernel::KernelState* kernel_state, ui::WindowedAppContext* app_context, @@ -150,9 +174,10 @@ X_STATUS GraphicsSystem::Setup(cpu::Processor* processor, kernel_state_, 128 * 1024, 0, [this]() { uint64_t last_frame_time = Clock::QueryGuestTickCount(); - // Sleep for 90% of the vblank duration on Windows/macOS, spin for 10% - // Linux uses full sleep duration due to scheduler quantum issues -#if XE_PLATFORM_WIN32 || XE_PLATFORM_MAC + // Sleep for 90% of the vblank duration on Windows/Apple, spin for + // 10%. Linux uses full sleep duration due to scheduler quantum + // issues. +#if XE_PLATFORM_WIN32 || XE_PLATFORM_APPLE constexpr double duration_scalar = 0.90; #elif XE_PLATFORM_LINUX constexpr double duration_scalar = 1.0; @@ -173,8 +198,8 @@ X_STATUS GraphicsSystem::Setup(cpu::Processor* processor, (1000000000.0 / static_cast(vblank_hz)) * duration_scalar); -#if XE_PLATFORM_WIN32 || XE_PLATFORM_MAC - // Windows/macOS: time-gating + 90% sleep + 10% spin +#if XE_PLATFORM_WIN32 || XE_PLATFORM_APPLE + // Windows/Apple: time-gating + 90% sleep + 10% spin. const uint64_t tick_freq = Clock::guest_tick_frequency(); const uint64_t target_duration_ticks = tick_freq / vblank_hz; const uint64_t current_time = Clock::QueryGuestTickCount(); @@ -188,7 +213,7 @@ X_STATUS GraphicsSystem::Setup(cpu::Processor* processor, last_frame_time += target_duration_ticks; } MarkVblank(); -#if XE_PLATFORM_MAC +#if XE_PLATFORM_APPLE threading::NanoSleepPrecise(sleep_ns); #else threading::NanoSleep(sleep_ns); @@ -224,18 +249,20 @@ X_STATUS GraphicsSystem::Setup(cpu::Processor* processor, } void GraphicsSystem::Shutdown() { - if (command_processor_) { - EndTracing(); - command_processor_->Shutdown(); - command_processor_.reset(); - } - + // The frame limiter thread calls MarkVblank, which touches the command + // processor. Stop it before tearing the command processor down. if (frame_limiter_worker_thread_) { frame_limiter_worker_running_ = false; frame_limiter_worker_thread_->Wait(0, 0, 0, nullptr); frame_limiter_worker_thread_.reset(); } + if (command_processor_) { + EndTracing(); + command_processor_->Shutdown(); + command_processor_.reset(); + } + if (presenter_) { if (app_context_) { app_context_->CallInUIThreadSynchronous([this]() { presenter_.reset(); }); diff --git a/src/xenia/gpu/graphics_system.h b/src/xenia/gpu/graphics_system.h index 66a944d7a..977c65590 100644 --- a/src/xenia/gpu/graphics_system.h +++ b/src/xenia/gpu/graphics_system.h @@ -17,6 +17,7 @@ #include #include #include +#include #include "xenia/cpu/processor.h" #include "xenia/gpu/register_file.h" @@ -114,12 +115,14 @@ class GraphicsSystem { static std::pair GetInternalDisplayResolution(); std::pair GetScaledAspectRatio() const { - return {scaled_aspect_x_, scaled_aspect_y_}; - }; - void SetScaledAspectRatio(uint32_t x, uint32_t y) { - scaled_aspect_x_ = x; - scaled_aspect_y_ = y; - }; + return {scaled_aspect_x_.load(std::memory_order_relaxed), + scaled_aspect_y_.load(std::memory_order_relaxed)}; + } + void SetScaledAspectRatio(uint32_t x, uint32_t y); + using ScaledAspectRatioChangedCallback = + std::function; + void SetScaledAspectRatioChangedCallback( + ScaledAspectRatioChangedCallback callback); protected: GraphicsSystem(); @@ -159,8 +162,10 @@ class GraphicsSystem { bool paused_ = false; - uint32_t scaled_aspect_x_ = 0; - uint32_t scaled_aspect_y_ = 0; + std::atomic scaled_aspect_x_{0}; + std::atomic scaled_aspect_y_{0}; + std::mutex scaled_aspect_ratio_changed_callback_mutex_; + ScaledAspectRatioChangedCallback scaled_aspect_ratio_changed_callback_; private: std::unique_ptr presenter_; diff --git a/src/xenia/gpu/metal/metal_command_processor.cc b/src/xenia/gpu/metal/metal_command_processor.cc index 04b34cf05..fb797f9f7 100644 --- a/src/xenia/gpu/metal/metal_command_processor.cc +++ b/src/xenia/gpu/metal/metal_command_processor.cc @@ -74,6 +74,21 @@ DEFINE_bool( "dirty-on-write behavior.", "Metal"); +DEFINE_bool( + metal_backend_hazard_model, false, + "Experimental: let the Metal backend own GPU read/write hazard tracking " + "(explicit fences/events) for heap-backed textures, render targets, and the " + "shared-memory/EDRAM buffers so they can be MTLResidencySet-covered and the " + "per-encoder useResource/useHeap re-apply can be dropped. Off = current " + "useResource path. See scratch/metal_hazard_model_design.md.", + "Metal"); +DEFINE_bool( + metal_backend_hazard_model_validate, false, + "Shadow-validate the hazard model: run the tracker alongside the existing " + "useResource path and log when they disagree (a missed hazard). Verify on " + "each game before enabling metal_backend_hazard_model.", + "Metal"); + namespace xe { namespace gpu { namespace metal { @@ -194,27 +209,23 @@ uint8_t MaskNativeMslTextureSigns(uint8_t signs, uint8_t component_mask) { return masked; } -uint64_t NativeMslTextureSignInputKey(const RegisterFile& regs, - const Shader& shader) { +// Computes the texture-sign cache key from the precomputed, shader-invariant +// (fetch_constant, component_mask) list. Only the per-draw register reads +// (SwizzleSigns) happen here; the result is byte-identical to walking the +// shader's texture bindings directly. +uint64_t NativeMslTextureSignInputKey(const uint32_t* fetch_constants, + const uint8_t* component_masks, + uint32_t binding_count, + const RegisterFile& regs) { uint64_t key = UINT64_C(0x4E4D534C53696E70); // "NMSLSinp" bool has_sign_inputs = false; auto mix_key = [](uint64_t hash, uint64_t value) { hash ^= value + UINT64_C(0x9E3779B185EBCA87) + (hash << 6) + (hash >> 2); return hash; }; - for (const Shader::TextureBinding& shader_binding : - shader.texture_bindings()) { - const ParsedTextureFetchInstruction& fetch = shader_binding.fetch_instr; - if (fetch.opcode != ucode::FetchOpcode::kTextureFetch || - shader_binding.fetch_constant >= xenos::kTextureFetchConstantCount) { - continue; - } - uint8_t component_mask = uint8_t(fetch.result.GetUsedResultComponents() & - fetch.GetNonZeroResultComponents()); - if (!component_mask) { - continue; - } - const uint32_t fetch_constant = shader_binding.fetch_constant; + for (uint32_t i = 0; i < binding_count; ++i) { + const uint32_t fetch_constant = fetch_constants[i]; + const uint8_t component_mask = component_masks[i]; const uint8_t signs = texture_util::SwizzleSigns(regs.GetTextureFetch(fetch_constant)); const uint8_t masked_signs = @@ -231,22 +242,13 @@ uint64_t NativeMslTextureSignInputKey(const RegisterFile& regs, } NativeMslTextureSignVariant BuildNativeMslTextureSignVariant( - const RegisterFile& regs, const Shader& shader) { + const uint32_t* fetch_constants, const uint8_t* component_masks, + uint32_t binding_count, const RegisterFile& regs) { NativeMslTextureSignVariant variant = {}; bool has_sign_inputs = false; - for (const Shader::TextureBinding& shader_binding : - shader.texture_bindings()) { - const ParsedTextureFetchInstruction& fetch = shader_binding.fetch_instr; - if (fetch.opcode != ucode::FetchOpcode::kTextureFetch || - shader_binding.fetch_constant >= xenos::kTextureFetchConstantCount) { - continue; - } - uint8_t component_mask = uint8_t(fetch.result.GetUsedResultComponents() & - fetch.GetNonZeroResultComponents()); - if (!component_mask) { - continue; - } - const uint32_t fetch_constant = shader_binding.fetch_constant; + for (uint32_t i = 0; i < binding_count; ++i) { + const uint32_t fetch_constant = fetch_constants[i]; + const uint8_t component_mask = component_masks[i]; const uint8_t signs = texture_util::SwizzleSigns(regs.GetTextureFetch(fetch_constant)); variant.component_masks[fetch_constant] |= component_mask; @@ -3633,7 +3635,38 @@ bool MetalCommandProcessor::IssueDraw(xenos::PrimitiveType primitive_type, const Shader& shader) { NativeMslTextureSignVariantCache& cache = native_msl_texture_sign_variant_cache_[stage]; - const uint64_t input_key = NativeMslTextureSignInputKey(regs, shader); + // component_mask is derived only from the shader's parsed fetch + // instructions, so precompute the (fetch_constant, component_mask) list + // once per shader. The per-draw key/variant below then only reads the + // register file, not GetUsedResultComponents/GetNonZeroResultComponents. + if (cache.precomputed_shader != &shader) { + cache.precomputed_fetch_constants.clear(); + cache.precomputed_component_masks.clear(); + for (const Shader::TextureBinding& shader_binding : + shader.texture_bindings()) { + const ParsedTextureFetchInstruction& fetch = shader_binding.fetch_instr; + if (fetch.opcode != ucode::FetchOpcode::kTextureFetch || + shader_binding.fetch_constant >= xenos::kTextureFetchConstantCount) { + continue; + } + uint8_t component_mask = + uint8_t(fetch.result.GetUsedResultComponents() & + fetch.GetNonZeroResultComponents()); + if (!component_mask) { + continue; + } + cache.precomputed_fetch_constants.push_back( + shader_binding.fetch_constant); + cache.precomputed_component_masks.push_back(component_mask); + } + cache.precomputed_shader = &shader; + cache.shader = nullptr; // Invalidate variant cache for the new shader. + } + const uint32_t binding_count = + uint32_t(cache.precomputed_fetch_constants.size()); + const uint64_t input_key = NativeMslTextureSignInputKey( + cache.precomputed_fetch_constants.data(), + cache.precomputed_component_masks.data(), binding_count, regs); if (cache.shader == &shader && cache.input_key == input_key) { NativeMslTextureSignVariant variant = {}; variant.key = cache.variant_key; @@ -3641,8 +3674,9 @@ bool MetalCommandProcessor::IssueDraw(xenos::PrimitiveType primitive_type, variant.sign_values = cache.sign_values; return variant; } - NativeMslTextureSignVariant variant = - BuildNativeMslTextureSignVariant(regs, shader); + NativeMslTextureSignVariant variant = BuildNativeMslTextureSignVariant( + cache.precomputed_fetch_constants.data(), + cache.precomputed_component_masks.data(), binding_count, regs); cache.shader = &shader; cache.input_key = input_key; cache.variant_key = variant.key; @@ -4858,26 +4892,47 @@ bool MetalCommandProcessor::PrepareDrawConstants( auto write_packed_float_constants = [&](uint8_t* dst, size_t dst_size, const Shader::ConstantRegisterMap* map, uint32_t regs_base) { - std::memset(dst, 0, dst_size); if (!map || !map->float_count) { + std::memset(dst, 0, dst_size); return; } + // float_count == popcount(float_bitmap) and dst_size == 16 * + // max(float_count, 1) (see Shader::ConstantRegisterMap), so the packed + // copies below fill the whole buffer whenever any constant is used -- + // the previous up-front memset of the entire buffer was dead work. + // Consecutive set bits map to a contiguous run of source registers + // (regs.values is uint32_t[], 4 per float4), so copy each run in a + // single memcpy instead of one per register. uint8_t* out = dst; - uint8_t* end = dst + dst_size; - for (uint32_t i = 0; i < 4; ++i) { + uint8_t* const end = dst + dst_size; + for (uint32_t i = 0; i < 4 && out < end; ++i) { uint64_t bits = map->float_bitmap[i]; - uint32_t constant_index; - while (xe::bit_scan_forward(bits, &constant_index)) { - bits &= ~(uint64_t(1) << constant_index); - if (out + 4 * sizeof(uint32_t) > end) { - return; + const uint32_t* word_values = ®s.values[regs_base + (i << 8)]; + uint32_t run_start; + while (xe::bit_scan_forward(bits, &run_start)) { + // Length of the contiguous run of set bits starting at run_start. + uint64_t run = bits >> run_start; + uint32_t first_zero; + uint32_t run_len = xe::bit_scan_forward(~run, &first_zero) + ? first_zero + : (64u - run_start); + size_t bytes = size_t(run_len) * 4 * sizeof(uint32_t); + if (out + bytes > end) { + bytes = static_cast(end - out); + } + std::memcpy(out, word_values + (run_start << 2), bytes); + out += bytes; + if (run_start + run_len >= 64u) { + bits = 0; + } else { + bits &= ~((((uint64_t(1) << run_len) - 1)) << run_start); } - std::memcpy( - out, ®s.values[regs_base + (i << 8) + (constant_index << 2)], - 4 * sizeof(uint32_t)); - out += 4 * sizeof(uint32_t); } } + // Defensive: zero any tail if float_count ever lags the bitmap popcount. + if (out < end) { + std::memset(out, 0, static_cast(end - out)); + } }; const Shader::ConstantRegisterMap& float_map_vertex = @@ -6382,10 +6437,13 @@ bool MetalCommandProcessor::EncodePreparedDraw(const PreparedDraw& draw) { InvalidateRenderEncoderStateAfterDrawPassTransfers(transfer_mutations); } - if (draw.shared_memory_hazard_range_count && - PendingSharedMemoryWritesOverlapRanges( - draw.shared_memory_hazard_ranges.data(), - draw.shared_memory_hazard_range_count)) { + // EncodeSharedMemoryRenderReadDependencies already early-outs when the pending + // write list is empty and when no pending write overlaps these ranges, so the + // previous PendingSharedMemoryWritesOverlapRanges gate here was a redundant + // second full scan of the pending-write list on every hazard draw. Call it + // directly; the behavior is identical (the error path inside is still only + // reachable on a real overlap). + if (draw.shared_memory_hazard_range_count) { if (!EncodeSharedMemoryRenderReadDependencies( draw.shared_memory_hazard_ranges.data(), draw.shared_memory_hazard_range_count, @@ -8186,6 +8244,18 @@ void MetalCommandProcessor::PrepareSharedMemoryUploadBeforeDrawPass( return; } const bool render_encoder_active = current_render_encoder_ != nullptr; + // Resident-draw fast path: if every range is already valid there is nothing to + // upload, and GetUploadRouteInfo would scan every page (one IsRangeValid call + // per page) only to return an empty route. AnySharedMemoryRangeInvalid checks + // each whole range with a single coarse validity scan that early-exits, so + // skip the per-page scan when nothing is invalid. When all ranges are valid, + // GetUploadRouteInfo can only yield upload_bytes == 0, so this is behavior- + // identical: same no-upload telemetry, no staged bytes, no encoder teardown. + if (!AnySharedMemoryRangeInvalid(ranges, range_count)) { + RecordSharedMemoryLazyUploadRoute(MetalSharedMemory::UploadRouteInfo(), + render_encoder_active); + return; + } MetalSharedMemory::UploadRouteInfo route_info = shared_memory_->GetUploadRouteInfo(ranges, range_count); RecordSharedMemoryLazyUploadRoute(route_info, render_encoder_active); diff --git a/src/xenia/gpu/metal/metal_command_processor.h b/src/xenia/gpu/metal/metal_command_processor.h index b9aacd1fc..3bd8fad78 100644 --- a/src/xenia/gpu/metal/metal_command_processor.h +++ b/src/xenia/gpu/metal/metal_command_processor.h @@ -845,6 +845,13 @@ class MetalCommandProcessor final : public CommandProcessor { uint64_t begin_encoder_descriptor_failures = 0; uint64_t begin_encoder_creation_failures = 0; + // Hazard model (scratch/metal_hazard_model_design.md); 0 until enabled. + uint64_t hazard_barriers_emitted = 0; + uint64_t hazard_fences_emitted = 0; + uint64_t hazard_events_emitted = 0; + uint64_t hazard_useresource_suppressed = 0; + uint64_t hazard_validation_disagreements = 0; + uint64_t end_encoder_active = 0; uint64_t end_encoder_no_active = 0; std::array end_reasons = {}; @@ -1527,6 +1534,13 @@ class MetalCommandProcessor final : public CommandProcessor { uint64_t variant_key = 0; DxbcShader::TextureSignComponentMasks component_masks = {}; DxbcShader::TextureSignComponentMasks sign_values = {}; + // Shader-invariant (fetch_constant, component_mask) pairs in texture-binding + // order. component_mask comes only from the parsed fetch instruction, so it + // is precomputed once per shader; the per-draw key/variant computation then + // only reads the register file (SwizzleSigns). Reused across rebuilds. + const Shader* precomputed_shader = nullptr; + std::vector precomputed_fetch_constants; + std::vector precomputed_component_masks; }; std::array native_msl_texture_sign_variant_cache_ = {}; diff --git a/src/xenia/gpu/metal/metal_shader_converter.cc b/src/xenia/gpu/metal/metal_shader_converter.cc index 4b4539cb3..202f23a49 100644 --- a/src/xenia/gpu/metal/metal_shader_converter.cc +++ b/src/xenia/gpu/metal/metal_shader_converter.cc @@ -20,16 +20,26 @@ namespace gpu { namespace metal { constexpr uint32_t kFunctionConstantRegisterSpace = 2147420894u; +// Metal Shader Converter 4.0 enables NaN/Inf optimization by default, in which +// Metal assumes float operands are neither NaN nor Inf (per Apple's MSC user +// manual). Xenia's DXBC translator deliberately depends on real IEEE NaN/Inf +// semantics: it writes NaN to SV_Position to cull primitives, flushes NaN to 0 +// on saturate, uses ordered (NaN-false) comparisons for alpha test, and emits +// explicit ==/!= INFINITY clamps in rcp/rsq/log. Keep IRCompatibilityFlag- +// DisableNanInfOptimization set so the upgrade preserves pre-4.0 behavior; +// drop it only after auditing those paths for the GPU-side perf gain. constexpr uint32_t kDefaultCompatibilityFlags = IRCompatibilityFlagForceTextureArray | IRCompatibilityFlagBoundsCheck | - IRCompatibilityFlagVertexPositionInfToNan; + IRCompatibilityFlagVertexPositionInfToNan | + IRCompatibilityFlagDisableNanInfOptimization; constexpr uint32_t kDebugCompatibilityFlags = IRCompatibilityFlagBoundsCheck | IRCompatibilityFlagVertexPositionInfToNan | IRCompatibilityFlagTextureMinLODClamp | IRCompatibilityFlagSamplerLODBias | IRCompatibilityFlagPositionInvariance | IRCompatibilityFlagSampleNanToZero | IRCompatibilityFlagTexWriteRoundingRTZ | IRCompatibilityFlagSuppress2DComputeDerivativeErrors | - IRCompatibilityFlagForceTextureArray; + IRCompatibilityFlagForceTextureArray | + IRCompatibilityFlagDisableNanInfOptimization; constexpr uint32_t kUnsetCompilerOption = UINT32_MAX; MetalShaderConverter::MetalShaderConverter() diff --git a/src/xenia/gpu/msl_shader_translator.cc b/src/xenia/gpu/msl_shader_translator.cc index a6ebba020..fb4fcbe9a 100644 --- a/src/xenia/gpu/msl_shader_translator.cc +++ b/src/xenia/gpu/msl_shader_translator.cc @@ -1983,18 +1983,26 @@ void MslShaderTranslator::EmitHelperFunctions() { EmitLine("}"); } if (helper_usage.uses_mul_sm3) { + // Shader Model 3 multiply: +-0 or denormal * anything (incl. Inf/NaN) = +0. + // fmin(|a|, |b|) == 0 detects a zero operand even when the other is NaN, + // because MSL fmin returns the non-NaN argument (OpenCL/C99 semantics); this + // is the same formulation the DXBC/SPIR-V backends use (SPIR-V's NMin), and + // is one ALU op cheaper than OR-ing two equality compares. Must be fmin, not + // min (min is `y < x ? y : x` and would propagate NaN). fabs folds to a free + // source modifier on Apple GPUs. Relies on MathModeSafe (default) for the + // NaN behavior to be honored. EmitLine( - "inline float XeMulSM3(float a, float b) { return (a == 0.0f || b == " - "0.0f) ? 0.0f : a * b; }"); + "inline float XeMulSM3(float a, float b) { return fmin(fabs(a), " + "fabs(b)) == 0.0f ? 0.0f : a * b; }"); EmitLine( "inline float2 XeMulSM3(float2 a, float2 b) { return select(a * b, " - "float2(0.0f), (a == float2(0.0f)) | (b == float2(0.0f))); }"); + "float2(0.0f), fmin(fabs(a), fabs(b)) == float2(0.0f)); }"); EmitLine( "inline float3 XeMulSM3(float3 a, float3 b) { return select(a * b, " - "float3(0.0f), (a == float3(0.0f)) | (b == float3(0.0f))); }"); + "float3(0.0f), fmin(fabs(a), fabs(b)) == float3(0.0f)); }"); EmitLine( "inline float4 XeMulSM3(float4 a, float4 b) { return select(a * b, " - "float4(0.0f), (a == float4(0.0f)) | (b == float4(0.0f))); }"); + "float4(0.0f), fmin(fabs(a), fabs(b)) == float4(0.0f)); }"); } if (helper_usage.uses_clamp_inf_to_max) { EmitLine( diff --git a/src/xenia/gpu/pm4_command_processor_implement.h b/src/xenia/gpu/pm4_command_processor_implement.h index b70e50eb3..65eb23268 100644 --- a/src/xenia/gpu/pm4_command_processor_implement.h +++ b/src/xenia/gpu/pm4_command_processor_implement.h @@ -97,10 +97,26 @@ void COMMAND_PROCESSOR::ExecuteIndirectBuffer(uint32_t ptr, reader_.BeginPrefetchedRead( COMMAND_PROCESSOR::GetCurrentRingReadCount()); do { +#if XE_PLATFORM_IOS + if (COMMAND_PROCESSOR::IsTitleStopRequestedIOS() || !worker_running_) { + XELOGI( + "iOS: abandoning indirect ringbuffer decode during title " + "stop/shutdown"); + break; + } +#endif // XE_PLATFORM_IOS if (COMMAND_PROCESSOR::ExecutePacket()) { continue; } else { // Return up a level if we encounter a bad packet. +#if XE_PLATFORM_IOS + if (COMMAND_PROCESSOR::IsTitleStopRequestedIOS() || !worker_running_) { + XELOGI( + "iOS: ignoring indirect ringbuffer decode failure during title " + "stop/shutdown"); + break; + } +#endif // XE_PLATFORM_IOS XELOGE("**** INDIRECT RINGBUFFER: Failed to execute packet."); assert_always(); break; @@ -1186,7 +1202,6 @@ bool COMMAND_PROCESSOR::ExecutePacketType3_EVENT_WRITE_ZPD( // QueryBatch titles can have multiple pending sentinels in a row and don't // necessarily update in an order we currently observe. bool guest_marks_end = report && XenosZPDReport::HasPendingSentinel(report); - uint32_t slot_base = XenosZPDReport::GetSlotBase(report_address); bool logical_active = zpd_active_segment_.logical_active; if (cvars::occlusion_query_log && report) { @@ -1214,11 +1229,10 @@ bool COMMAND_PROCESSOR::ExecutePacketType3_EVENT_WRITE_ZPD( return true; } - if (COMMAND_PROCESSOR::GetZPDMode() != ZPDMode::kFake) { + if (COMMAND_PROCESSOR::GetZPDMode() != ZPDMode::kFake && + !zpd_force_fake_fallback_) { if (logical_active && is_end_record) { - if (slot_base == zpd_active_segment_.slot_base) { - COMMAND_PROCESSOR::EndZPDReport(report_address, false); - } + COMMAND_PROCESSOR::EndZPDReport(report_address, false); return true; } if (is_begin_record) { @@ -1234,7 +1248,8 @@ bool COMMAND_PROCESSOR::ExecutePacketType3_EVENT_WRITE_ZPD( // No logical report is active for this slot, so this is likely an // orphaned END. In fast mode, replay the last cached delta so polling // code does not sit on the sentinel forever. - if (COMMAND_PROCESSOR::GetZPDMode() == ZPDMode::kFast) { + if (COMMAND_PROCESSOR::GetZPDMode() == ZPDMode::kFast || + COMMAND_PROCESSOR::GetZPDMode() == ZPDMode::kFastAlt) { uint32_t cached_delta = 1; auto cache_it = fast_zpd_report_cached_values_.find(report_record_base); if (cache_it != fast_zpd_report_cached_values_.end()) { @@ -1735,8 +1750,24 @@ uint32_t COMMAND_PROCESSOR::ExecutePrimaryBuffer(uint32_t read_index, reader_.BeginPrefetchedRead( GetCurrentRingReadCount()); do { +#if XE_PLATFORM_IOS + if (COMMAND_PROCESSOR::IsTitleStopRequestedIOS() || !worker_running_) { + XELOGI( + "iOS: abandoning primary ringbuffer decode during title " + "stop/shutdown"); + break; + } +#endif // XE_PLATFORM_IOS if (!COMMAND_PROCESSOR::ExecutePacket()) { // This probably should be fatal - but we're going to continue anyways. +#if XE_PLATFORM_IOS + if (COMMAND_PROCESSOR::IsTitleStopRequestedIOS() || !worker_running_) { + XELOGI( + "iOS: ignoring primary ringbuffer decode failure during title " + "stop/shutdown"); + break; + } +#endif // XE_PLATFORM_IOS XELOGE("**** PRIMARY RINGBUFFER: Failed to execute packet."); assert_always(); break; diff --git a/src/xenia/gpu/spirv_shader_translator.cc b/src/xenia/gpu/spirv_shader_translator.cc index 018398101..83704afce 100644 --- a/src/xenia/gpu/spirv_shader_translator.cc +++ b/src/xenia/gpu/spirv_shader_translator.cc @@ -298,6 +298,8 @@ void SpirvShaderTranslator::Reset() { spv::NoResult); output_or_var_fragment_depth_ = spv::NoResult; output_fragment_depth_ = spv::NoResult; + main_fbo_depth_unbiased_ = spv::NoResult; + main_fbo_depth_derivatives_.fill(spv::NoResult); main_switch_op_.reset(); main_switch_next_pc_phi_operands_.clear(); @@ -1011,14 +1013,15 @@ std::vector SpirvShaderTranslator::CompleteTranslation() { } // FSI handles depth manually. if (!edram_fragment_shader_interlock_ && - (current_shader().writes_depth() || DSV_IsWritingFloat24Depth())) { + (current_shader().writes_depth() || DSV_IsWritingFloat24Depth() || + DSV_IsApplyingPolygonOffset())) { builder_->addExecutionMode(function_main_, spv::ExecutionModeDepthReplacing); // Truncating float24 conversion of the rasterizer's own depth rounds // towards zero, so the output is always <= the original - announce that // to keep coarse early-Z culling possible. Matches SV_DepthLessEqual in // the DXBC backend. - if (!current_shader().writes_depth() && + if (!current_shader().writes_depth() && !DSV_IsApplyingPolygonOffset() && GetSpirvShaderModification().pixel.depth_stencil_mode == Modification::DepthStencilMode::kFloat24Truncating) { builder_->addExecutionMode(function_main_, spv::ExecutionModeDepthLess); @@ -3073,6 +3076,7 @@ void SpirvShaderTranslator::StartFragmentShaderBeforeMain() { // - and must do so per-sample for MSAA antialiasing of intersections. bool need_frag_coord = edram_fragment_shader_interlock_ || param_gen_needed || IsSampleRate() || + DSV_IsApplyingPolygonOffset() || (!edram_fragment_shader_interlock_ && !is_depth_only_fragment_shader_ && current_shader().writes_color_target(0) && !IsExecutionModeEarlyFragmentTests()); @@ -3092,7 +3096,7 @@ void SpirvShaderTranslator::StartFragmentShaderBeforeMain() { } // Is front facing. - if (edram_fragment_shader_interlock_ || + if (edram_fragment_shader_interlock_ || DSV_IsApplyingPolygonOffset() || (param_gen_needed && !GetSpirvShaderModification().pixel.param_gen_point)) { input_front_facing_ = builder_->createVariable( @@ -3157,13 +3161,11 @@ void SpirvShaderTranslator::StartFragmentShaderBeforeMain() { } } - // Fragment depth output (gl_FragDepth) for the FBO path. - // Created when the guest pixel shader writes oDepth, or when the host depth - // buffer is float24 and the rasterizer's own depth needs in-PS conversion - // (including the synthetic depth-only shader used for no-PS guest draws). - // FSI manages its own depth and does not need an Output. + // FBO fragment depth output. Used for guest oDepth, float24 conversion, and + // the narrow host RT decal path. FSI manages depth in EDRAM instead. if (!edram_fragment_shader_interlock_ && - (current_shader().writes_depth() || DSV_IsWritingFloat24Depth())) { + (current_shader().writes_depth() || DSV_IsWritingFloat24Depth() || + DSV_IsApplyingPolygonOffset())) { output_fragment_depth_ = builder_->createVariable( spv::NoPrecision, spv::StorageClassOutput, type_float_, "gl_FragDepth"); builder_->addDecoration(output_fragment_depth_, spv::DecorationBuiltIn, @@ -3258,6 +3260,24 @@ void SpirvShaderTranslator::StartFragmentShaderInMain() { "xe_var_fragment_depth", const_float_0_); } + if (DSV_IsApplyingPolygonOffset()) { + // The decal path needs the original triangle depth slope, not the slope of + // whichever lanes survive guest control flow or kill. + assert_true(input_fragment_coordinates_ != spv::NoResult); + id_vector_temp_.clear(); + id_vector_temp_.push_back(builder_->makeIntConstant(2)); + main_fbo_depth_unbiased_ = + builder_->createLoad(builder_->createAccessChain( + spv::StorageClassInput, + input_fragment_coordinates_, id_vector_temp_), + spv::NoPrecision); + builder_->addCapability(spv::CapabilityDerivativeControl); + main_fbo_depth_derivatives_[0] = builder_->createUnaryOp( + spv::OpDPdxCoarse, type_float_, main_fbo_depth_unbiased_); + main_fbo_depth_derivatives_[1] = builder_->createUnaryOp( + spv::OpDPdyCoarse, type_float_, main_fbo_depth_unbiased_); + } + if (edram_fragment_shader_interlock_ && FSI_IsDepthStencilEarly()) { spv::Id msaa_samples = LoadMsaaSamplesFromFlags(); FSI_LoadSampleMask(msaa_samples); diff --git a/src/xenia/gpu/spirv_shader_translator.h b/src/xenia/gpu/spirv_shader_translator.h index 544141f39..c53dacd76 100644 --- a/src/xenia/gpu/spirv_shader_translator.h +++ b/src/xenia/gpu/spirv_shader_translator.h @@ -42,7 +42,7 @@ class SpirvShaderTranslator : public ShaderTranslator { // TODO(Triang3l): Change to 0xYYYYMMDD once it's out of the rapid // prototyping stage (easier to do small granular updates with an // incremental counter). - static constexpr uint32_t kVersion = 11; + static constexpr uint32_t kVersion = 12; enum class DepthStencilMode : uint32_t { kNoModifiers, @@ -59,6 +59,13 @@ class SpirvShaderTranslator : public ShaderTranslator { // Similar to kFloat24Truncating, but rounding to the nearest even, so // plain ExecutionModeDepthReplacing is used rather than DepthLess. kFloat24Rounding, + // Host RT shader polygon offset for suspected coplanar redraws with tiny + // biases. Writes the biased depth from the pixel shader and zeroes fixed + // function depth bias to avoid host slope/quantization quirks. This path + // is controlled by depth_bias_shader_offset. + kPolygonOffset, + kFloat24TruncatingPolygonOffset, + kFloat24RoundingPolygonOffset, // TODO(Triang3l): Unorm24 (rounding) output mode. }; @@ -594,8 +601,27 @@ class SpirvShaderTranslator : public ShaderTranslator { GetSpirvShaderModification().pixel.depth_stencil_mode; return depth_stencil_mode == Modification::DepthStencilMode::kFloat24Truncating || + depth_stencil_mode == Modification::DepthStencilMode:: + kFloat24TruncatingPolygonOffset || depth_stencil_mode == - Modification::DepthStencilMode::kFloat24Rounding; + Modification::DepthStencilMode::kFloat24Rounding || + depth_stencil_mode == + Modification::DepthStencilMode::kFloat24RoundingPolygonOffset; + } + // Whether the current non-FSI pixel shader applies polygon offset via shader + // depth output instead of fixed function bias. + bool DSV_IsApplyingPolygonOffset() const { + if (edram_fragment_shader_interlock_) { + return false; + } + Modification::DepthStencilMode depth_stencil_mode = + GetSpirvShaderModification().pixel.depth_stencil_mode; + return depth_stencil_mode == + Modification::DepthStencilMode::kPolygonOffset || + depth_stencil_mode == Modification::DepthStencilMode:: + kFloat24TruncatingPolygonOffset || + depth_stencil_mode == + Modification::DepthStencilMode::kFloat24RoundingPolygonOffset; } // Whether the shader runs at sample frequency - when converting depth to // float24 from the rasterizer's own depth (not guest oDepth), each sample @@ -631,9 +657,8 @@ class SpirvShaderTranslator : public ShaderTranslator { void StartFragmentShaderInMain(); void CompleteFragmentShaderInMain(); - // Writes gl_FragDepth at the end of an FBO pixel shader, remapping the - // guest 0...1 oDepth value to host 0...0.5 when the bound depth buffer is - // float24. No-op for FSI and for shaders that don't write oDepth. + // Writes gl_FragDepth for FBO shaders that need explicit depth: guest oDepth, + // float24 conversion, or the host RT decal bias path. void CompleteFragmentShader_DSV_DepthTo24Bit(); // Updates the current flow control condition (to be called in the beginning @@ -1076,6 +1101,9 @@ class SpirvShaderTranslator : public ShaderTranslator { // FBO only: actual gl_FragDepth Output. // Written at the end of the pixel shader from output_or_var_fragment_depth_. spv::Id output_fragment_depth_; + // Raster depth and derivatives captured early for the host RT decal path. + spv::Id main_fbo_depth_unbiased_; + std::array main_fbo_depth_derivatives_; // Fragment shader sample mask output (gl_SampleMask). // Only used for alpha-to-coverage in non-FSI mode. diff --git a/src/xenia/gpu/spirv_shader_translator_rb.cc b/src/xenia/gpu/spirv_shader_translator_rb.cc index 8ef09f2b4..0fd207bb1 100644 --- a/src/xenia/gpu/spirv_shader_translator_rb.cc +++ b/src/xenia/gpu/spirv_shader_translator_rb.cc @@ -1518,31 +1518,84 @@ void SpirvShaderTranslator::CompleteFragmentShader_DSV_DepthTo24Bit() { } bool shader_writes_depth = current_shader().writes_depth(); bool is_float24 = DSV_IsWritingFloat24Depth(); - assert_true(shader_writes_depth || is_float24); + bool apply_polygon_offset = + DSV_IsApplyingPolygonOffset() && !shader_writes_depth; + assert_true(shader_writes_depth || is_float24 || apply_polygon_offset); - // Source the depth: staged guest oDepth (already [0, 1]), or the rasterizer's - // own depth from gl_FragCoord.z remapped from host 0...0.5 back to 0...1. + // Source depth from guest oDepth, or from raster depth for float24 conversion + // and the host RT decal path. spv::Id depth_value; if (shader_writes_depth) { depth_value = builder_->createLoad(output_or_var_fragment_depth_, spv::NoPrecision); } else { - assert_true(input_fragment_coordinates_ != spv::NoResult); - id_vector_temp_.clear(); - id_vector_temp_.push_back(builder_->makeIntConstant(2)); - spv::Id raster_z = - builder_->createLoad(builder_->createAccessChain( - spv::StorageClassInput, - input_fragment_coordinates_, id_vector_temp_), - spv::NoPrecision); - depth_value = builder_->createBinOp(spv::OpFMul, type_float_, raster_z, - builder_->makeFloatConstant(2.0f)); - depth_value = builder_->createTriBuiltinCall( - type_float_, ext_inst_glsl_std_450_, GLSLstd450NClamp, depth_value, - const_float_0_, const_float_1_); + spv::Id host_depth; + if (apply_polygon_offset) { + assert_true(input_front_facing_ != spv::NoResult); + assert_true(main_fbo_depth_unbiased_ != spv::NoResult); + assert_true(main_fbo_depth_derivatives_[0] != spv::NoResult); + assert_true(main_fbo_depth_derivatives_[1] != spv::NoResult); + auto load_system_constant_float = [&](SystemConstantIndex index) { + id_vector_temp_.clear(); + id_vector_temp_.push_back(builder_->makeIntConstant(index)); + return builder_->createLoad( + builder_->createAccessChain(spv::StorageClassUniform, + uniform_system_constants_, + id_vector_temp_), + spv::NoPrecision); + }; + spv::Id depth_dx = builder_->createUnaryBuiltinCall( + type_float_, ext_inst_glsl_std_450_, GLSLstd450FAbs, + main_fbo_depth_derivatives_[0]); + spv::Id depth_dy = builder_->createUnaryBuiltinCall( + type_float_, ext_inst_glsl_std_450_, GLSLstd450FAbs, + main_fbo_depth_derivatives_[1]); + spv::Id depth_max_slope = + builder_->createBinBuiltinCall(type_float_, ext_inst_glsl_std_450_, + GLSLstd450FMax, depth_dx, depth_dy); + spv::Id front_facing = + builder_->createLoad(input_front_facing_, spv::NoPrecision); + spv::Id poly_offset_scale = builder_->createTriOp( + spv::OpSelect, type_float_, front_facing, + load_system_constant_float(kSystemConstantEdramPolyOffsetFrontScale), + load_system_constant_float(kSystemConstantEdramPolyOffsetBackScale)); + spv::Id poly_offset_offset = builder_->createTriOp( + spv::OpSelect, type_float_, front_facing, + load_system_constant_float(kSystemConstantEdramPolyOffsetFrontOffset), + load_system_constant_float(kSystemConstantEdramPolyOffsetBackOffset)); + spv::Id poly_offset = builder_->createNoContractionBinOp( + spv::OpFAdd, type_float_, + builder_->createNoContractionBinOp( + spv::OpFMul, type_float_, depth_max_slope, poly_offset_scale), + poly_offset_offset); + host_depth = builder_->createNoContractionBinOp( + spv::OpFAdd, type_float_, main_fbo_depth_unbiased_, poly_offset); + } else { + assert_true(input_fragment_coordinates_ != spv::NoResult); + id_vector_temp_.clear(); + id_vector_temp_.push_back(builder_->makeIntConstant(2)); + host_depth = builder_->createLoad( + builder_->createAccessChain(spv::StorageClassInput, + input_fragment_coordinates_, + id_vector_temp_), + spv::NoPrecision); + } + if (is_float24) { + depth_value = builder_->createBinOp(spv::OpFMul, type_float_, host_depth, + builder_->makeFloatConstant(2.0f)); + depth_value = builder_->createTriBuiltinCall( + type_float_, ext_inst_glsl_std_450_, GLSLstd450NClamp, depth_value, + const_float_0_, const_float_1_); + } else { + depth_value = host_depth; + } } if (!is_float24) { + if (!shader_writes_depth) { + builder_->createStore(depth_value, output_fragment_depth_); + return; + } // Legacy path: shader writes oDepth, but the modification is not in float24 // mode (the host buffer may still be float24 if depth_float24_convert_in_ // pixel_shader is off - check dynamically via the system flag). @@ -1565,7 +1618,8 @@ void SpirvShaderTranslator::CompleteFragmentShader_DSV_DepthTo24Bit() { // Float24 mode: statically known float24 host buffer; perform the conversion. Modification::DepthStencilMode mode = GetSpirvShaderModification().pixel.depth_stencil_mode; - if (mode == Modification::DepthStencilMode::kFloat24Truncating) { + if (mode == Modification::DepthStencilMode::kFloat24Truncating || + mode == Modification::DepthStencilMode::kFloat24TruncatingPolygonOffset) { // Mantissa bit-truncation, then guest 0...1 -> host 0...0.5. spv::Id depth_uint = builder_->createUnaryOp(spv::OpBitcast, type_uint_, depth_value); diff --git a/src/xenia/kernel/kernel_state.cc b/src/xenia/kernel/kernel_state.cc index d93d20310..c469e4a69 100644 --- a/src/xenia/kernel/kernel_state.cc +++ b/src/xenia/kernel/kernel_state.cc @@ -98,8 +98,22 @@ KernelState::~KernelState() { ShutdownDispatchThread(); #if XE_PLATFORM_IOS if (ios_title_stop) { - guest_scheduler()->Shutdown(); + // Let guest threads observe the title-stop request and exit on their own + // first, with the guest scheduler still running. That lets any in-flight + // save / content writes finish and their files close normally. Shutting the + // scheduler down before this wait (as we previously did) stopped guest + // fibers from ever reaching their title-stop exit points, so every stop + // force-terminated threads mid-write and dropped unsaved content. if (!WaitForTitleThreadsToExitIOS(500)) { + // Threads are stuck. Freeze the scheduler so no fiber can run (and no new + // writes can begin), commit whatever guest code already wrote by closing + // any still-open content packages, then force-terminate the stragglers. + guest_scheduler()->Shutdown(); + if (auto* xam = xam_state()) { + if (auto* content_manager = xam->content_manager()) { + content_manager->CloseAllOpenedContent(); + } + } TerminateTitleThreadsIOS(); if (!WaitForTitleThreadsToExitIOS(500)) { XELOGW( diff --git a/src/xenia/kernel/xam/content_manager.cc b/src/xenia/kernel/xam/content_manager.cc index e91608fc0..b4ba82ce1 100644 --- a/src/xenia/kernel/xam/content_manager.cc +++ b/src/xenia/kernel/xam/content_manager.cc @@ -453,6 +453,20 @@ X_RESULT ContentManager::CloseContent(const std::string_view root_name) { return X_ERROR_SUCCESS; } +void ContentManager::CloseAllOpenedContent() { + auto global_lock = global_critical_region_.Acquire(); + + for (auto it = open_packages_.begin(); it != open_packages_.end();) { + // Release any guest file handles living inside this package so their + // host-side writes are flushed and the files are closed before the package + // is dropped. + CloseOpenedFilesFromContent(it->first.view()); + ContentPackage* package = it->second; + it = open_packages_.erase(it); + delete package; + } +} + X_RESULT ContentManager::GetContentThumbnail( const uint64_t xuid, const XCONTENT_AGGREGATE_DATA& data, std::vector* buffer) { diff --git a/src/xenia/kernel/xam/content_manager.h b/src/xenia/kernel/xam/content_manager.h index 02b03c19f..f5b34ebe1 100644 --- a/src/xenia/kernel/xam/content_manager.h +++ b/src/xenia/kernel/xam/content_manager.h @@ -249,6 +249,11 @@ class ContentManager { uint32_t& content_license, const uint32_t disc_number = -1); X_RESULT CloseContent(const std::string_view root_name); + // Closes every currently-open content package, flushing and closing the + // host-side save files behind them. Used on iOS title stop so a save that + // reached the guest file system is committed to disk before guest threads + // are torn down. + void CloseAllOpenedContent(); X_RESULT GetContentThumbnail(const uint64_t xuid, const XCONTENT_AGGREGATE_DATA& data, std::vector* buffer); diff --git a/src/xenia/kernel/xobject.cc b/src/xenia/kernel/xobject.cc index d5e147b48..a8cc31d84 100644 --- a/src/xenia/kernel/xobject.cc +++ b/src/xenia/kernel/xobject.cc @@ -9,6 +9,8 @@ #include "xenia/kernel/xobject.h" +#include +#include #include #include "xenia/base/byte_stream.h" @@ -28,9 +30,82 @@ #include "xenia/kernel/xthread.h" #include "xenia/xbox.h" +#include "xenia/base/cvar.h" +#include "xenia/base/threading.h" + +DEFINE_bool( + wait_timeout_backoff, false, + "On a guest wait that keeps timing out immediately (a zero-timeout poll " + "loop), park the thread briefly after a short fast-yield window instead of " + "sched_yield-spinning every iteration, so the core can idle. Off = the " + "previous unconditional MaybeYield.", + "CPU"); +DEFINE_int32( + wait_backoff_spin_polls, 64, + "wait_timeout_backoff: consecutive immediate timeouts to keep cheap-yielding " + "before parking.", + "CPU"); +DEFINE_int32(wait_backoff_step_us, 25, + "wait_timeout_backoff: park-interval growth per extra miss (us).", + "CPU"); +DEFINE_int32( + wait_backoff_cap_us, 250, + "wait_timeout_backoff: max park interval per poll (us); bounds added " + "latency.", + "CPU"); +DEFINE_int32( + wait_backoff_reset_us, 1000, + "wait_timeout_backoff: gap since the last immediate timeout that resets the " + "spin counter (so only tight loops are parked), microseconds.", + "CPU"); + namespace xe { namespace kernel { +namespace { + +// Adaptive backoff for the wait-timeout path (XObject::Wait / SignalAndWait / +// WaitMultiple). A guest zero-timeout poll loop otherwise costs one sched_yield +// (MaybeYield) per iteration -- the top non-JIT CPU cost on device for poll- +// heavy titles (~11-15%) and an energy/thermal drain on mobile. Keep cheap +// yields for short waits; after a sustained tight spin, park briefly so the core +// can idle. The inter-miss gap self-detects a tight loop, so a real blocking +// wait that merely times out keeps the cheap yield. Gated off by default. +thread_local uint32_t t_wait_spin_count = 0; +thread_local std::chrono::steady_clock::time_point t_wait_spin_last{}; + +void WaitTimeoutBackoff() { + if (!cvars::wait_timeout_backoff) { + xe::threading::MaybeYield(); + return; + } + const auto now = std::chrono::steady_clock::now(); + if (t_wait_spin_last == std::chrono::steady_clock::time_point{} || + now - t_wait_spin_last > + std::chrono::microseconds(cvars::wait_backoff_reset_us)) { + t_wait_spin_count = 0; // gap since last miss => not a tight poll loop + } + t_wait_spin_last = now; + const uint32_t spin_polls = + static_cast(std::max(cvars::wait_backoff_spin_polls, 0)); + if (++t_wait_spin_count <= spin_polls) { + xe::threading::MaybeYield(); // short wait: stay fast + return; + } + const uint32_t over = t_wait_spin_count - spin_polls; + const uint32_t step = + static_cast(std::max(cvars::wait_backoff_step_us, 0)); + const uint32_t cap = + static_cast(std::max(cvars::wait_backoff_cap_us, 0)); + uint32_t us = cap; + if (step > 0 && over <= cap / step) { + us = over * step; // bounded by cap, no overflow + } + xe::threading::Sleep(std::chrono::microseconds(us)); +} + +} // namespace + #if XE_PLATFORM_IOS namespace { @@ -439,7 +514,7 @@ X_STATUS XObject::Wait(uint32_t wait_reason, uint32_t processor_mode, return X_STATUS_USER_APC; } case xe::threading::WaitResult::kTimeout: - xe::threading::MaybeYield(); + WaitTimeoutBackoff(); WaitExit(kthread, X_STATUS_TIMEOUT); return X_STATUS_TIMEOUT; default: @@ -540,7 +615,7 @@ X_STATUS XObject::SignalAndWait(XObject* signal_object, XObject* wait_object, return X_STATUS_USER_APC; } case xe::threading::WaitResult::kTimeout: - xe::threading::MaybeYield(); + WaitTimeoutBackoff(); WaitExit(kthread, X_STATUS_TIMEOUT); return X_STATUS_TIMEOUT; default: @@ -664,7 +739,7 @@ X_STATUS XObject::WaitMultiple(uint32_t count, XObject** objects, status = X_STATUS_USER_APC; break; case xe::threading::WaitResult::kTimeout: - xe::threading::MaybeYield(); + WaitTimeoutBackoff(); status = X_STATUS_TIMEOUT; break; case xe::threading::WaitResult::kAbandoned: @@ -703,7 +778,7 @@ X_STATUS XObject::WaitMultiple(uint32_t count, XObject** objects, status = X_STATUS_USER_APC; break; case xe::threading::WaitResult::kTimeout: - xe::threading::MaybeYield(); + WaitTimeoutBackoff(); status = X_STATUS_TIMEOUT; break; default: diff --git a/src/xenia/ui/ios/launcher/ios_compat_report_cells.mm b/src/xenia/ui/ios/launcher/ios_compat_report_cells.mm index 5ac8e22aa..3bfb94dc2 100644 --- a/src/xenia/ui/ios/launcher/ios_compat_report_cells.mm +++ b/src/xenia/ui/ios/launcher/ios_compat_report_cells.mm @@ -153,8 +153,11 @@ section == 2 ? (option_index == selected_status) : (option_index == selected_perf); BOOL enabled = YES; if (section == 3) { - BOOL force_na = (selected_status == 4); - enabled = !force_na || option_index == 3; + // "n/a" (index 3) is only valid when the status is "nothing" (index 4); + // the real tiers are only valid for every other status. Keep the button + // states in sync with the server's invariant in both directions. + BOOL status_is_nothing = (selected_status == 4); + enabled = (option_index == 3) ? status_is_nothing : !status_is_nothing; } UIButton* button = diff --git a/src/xenia/ui/ios/launcher/ios_compat_report_view_controller.mm b/src/xenia/ui/ios/launcher/ios_compat_report_view_controller.mm index b6ed97897..495623db1 100644 --- a/src/xenia/ui/ios/launcher/ios_compat_report_view_controller.mm +++ b/src/xenia/ui/ios/launcher/ios_compat_report_view_controller.mm @@ -186,8 +186,13 @@ - (void)reportStatusButtonTapped:(UIButton*)sender { selected_status_ = sender.tag; + // The server only accepts the "n/a" performance tier when the status is + // "nothing". Force it for "nothing", and clear any stale "n/a" selection when + // switching to another status so the user is required to pick a real tier. if (selected_status_ == 4) { selected_perf_ = 3; + } else if (selected_perf_ == 3) { + selected_perf_ = -1; } [self.tableView reloadSections:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(2, 2)] withRowAnimation:UITableViewRowAnimationNone]; @@ -197,6 +202,10 @@ if (selected_status_ == 4 && sender.tag != 3) { return; } + // "n/a" performance is only valid for the "nothing" status. + if (selected_status_ != 4 && sender.tag == 3) { + return; + } selected_perf_ = sender.tag; [self.tableView reloadSections:[NSIndexSet indexSetWithIndex:3] withRowAnimation:UITableViewRowAnimationNone]; diff --git a/src/xenia/ui/ios/settings/ios_config_catalog.mm b/src/xenia/ui/ios/settings/ios_config_catalog.mm index cb0294658..a893e1932 100644 --- a/src/xenia/ui/ios/settings/ios_config_catalog.mm +++ b/src/xenia/ui/ios/settings/ios_config_catalog.mm @@ -23,6 +23,31 @@ #import "xenia/ui/ios/settings/ios_config_storage.h" +// SecTask entitlement APIs aren't public on iOS, so detect a granted entitlement +// by scanning the bundle's embedded provisioning profile, where granted +// entitlement keys appear verbatim in the (CMS-wrapped) Entitlements plist. +// Conservative: if there's no profile to inspect, assume granted so we never +// show a false "missing entitlement" warning. +static bool IOSHasHeadPoseEntitlement() { + @autoreleasepool { + NSString* path = [[NSBundle mainBundle] pathForResource:@"embedded" + ofType:@"mobileprovision"]; + if (!path.length) { + return true; + } + NSData* data = [NSData dataWithContentsOfFile:path]; + if (!data || data.length == 0) { + return true; + } + NSData* needle = [@"com.apple.developer.coremotion.head-pose" + dataUsingEncoding:NSUTF8StringEncoding]; + NSRange found = [data rangeOfData:needle + options:0 + range:NSMakeRange(0, data.length)]; + return found.location != NSNotFound; + } +} + static std::string TrimAscii(std::string value) { size_t start = 0; while (start < value.size() && std::isspace(static_cast(value[start]))) { @@ -324,14 +349,15 @@ static std::vector BuildPerformanceSections() { IOSConfigSection scheduler; scheduler.title = "Thread QoS"; - scheduler.footer = "Xenia's regular iOS threads use Default QoS. These options are " - "experimental promotions; leave them off unless you are testing CPU, GPU, " - "or audio scheduling behavior after a full relaunch."; + scheduler.footer = "Xenia's regular iOS threads use Default QoS. The GPU Commands " + "promotion is on by default; the others are experimental — leave " + "them off unless you are testing CPU or audio scheduling behavior " + "after a full relaunch."; AddBoolSetting(scheduler.items, "ios_gpu_commands_user_initiated_qos", "GPU Commands User-Initiated QoS", "Runs the Metal command processor host thread at user-initiated QoS. " "Try this first if frames appear to miss submission deadlines.", - false); + true); AddBoolSetting(scheduler.items, "ios_guest_threads_user_initiated_qos", "Guest Threads User-Initiated QoS", "Runs guest XThreads at user-initiated QoS. This may help CPU-bound " @@ -350,9 +376,39 @@ static std::vector BuildPerformanceSections() { static std::vector BuildAudioSections() { std::vector sections; + const bool phase_selected = IOSConfigGetConfigVarString("apu", "phase") == "phase"; + const bool head_pose_entitlement = IOSHasHeadPoseEntitlement(); + IOSConfigSection audio; audio.title = "Audio"; - audio.footer = "These settings control mute state and XMA decoding behavior."; + audio.footer = "These settings control the audio backend, mute state and XMA decoding " + "behavior."; + // Surface the head-pose entitlement status when PHASE is in use, since head + // tracking silently falls back to a fixed listener without it. + if (phase_selected && !head_pose_entitlement) { + audio.footer = + "PHASE backend selected. Head tracking is UNAVAILABLE: the head-pose entitlement " + "(com.apple.developer.coremotion.head-pose) is not present in this build's code " + "signature, so AirPods head tracking can't run — spatial audio still works with a " + "fixed listener. Re-sign with a profile that grants that entitlement to enable it."; + } + AddStringChoiceSetting(audio.items, "apu", "Audio Backend", + "Select the audio output backend, applied after the next full " + "relaunch. SDL is the stereo path. PHASE renders the game's 5.1 " + "mix as spatial audio over headphones (Apple PHASE engine); " + "requires compatible headphones for the full effect.", + "phase", {{"SDL (Stereo)", "sdl"}, {"PHASE (Spatial)", "phase"}}); + std::string head_tracking_subtitle = + "Only affects the PHASE backend. Tracks compatible AirPods head motion so the sound " + "stage stays anchored to the screen as you turn your head. Applied after a full " + "relaunch."; + if (!head_pose_entitlement) { + head_tracking_subtitle += + " \xE2\x9A\xA0\xEF\xB8\x8F Head-pose entitlement NOT detected in this build — head " + "tracking will not run (PHASE keeps a fixed listener)."; + } + AddBoolSetting(audio.items, "apu_phase_head_tracking", "PHASE Head Tracking", + head_tracking_subtitle, false); AddBoolSetting(audio.items, "mute", "Mute Audio", "Immediately silences all emulator audio. Useful for background testing " "or silent repro runs.", diff --git a/src/xenia/ui/metal/metal_immediate_drawer.mm b/src/xenia/ui/metal/metal_immediate_drawer.mm index ac04ed42c..61955defa 100644 --- a/src/xenia/ui/metal/metal_immediate_drawer.mm +++ b/src/xenia/ui/metal/metal_immediate_drawer.mm @@ -334,18 +334,21 @@ void MetalImmediateDrawer::BeginDrawBatch(const ImmediateDrawBatch& batch) { // XeSL MSL backend places push constants at buffer(0), so vertex attributes // bind at buffer(1) (matches the VertexDescriptor's bufferIndex=1). [encoder setVertexBuffer:vertex_buffer offset:0 atIndex:1]; - // newBufferWithBytes returns +1; keep the batch vertex data alive until all - // draws using the encoder state have been encoded. - current_vertex_buffer_ = (__bridge void*)vertex_buffer; + // newBufferWithBytes returns +1 owned by the ARC local. __bridge_retained + // takes a matching +1 for the non-ARC void*, so the buffer outlives the ARC + // local's scope-exit release and stays alive until all draws using the + // encoder state are encoded. EndDrawBatch() performs the matching CFRelease. + current_vertex_buffer_ = (__bridge_retained void*)vertex_buffer; if (batch.indices && batch.index_count > 0) { size_t index_buffer_size = batch.index_count * sizeof(uint16_t); id index_buffer = [mtl_device newBufferWithBytes:batch.indices length:index_buffer_size options:MTLResourceStorageModeShared]; - // newBufferWithBytes returns +1; storing through __bridge preserves the - // retain. EndDrawBatch() performs the matching release. - current_index_buffer_ = (__bridge void*)index_buffer; + // newBufferWithBytes returns +1 owned by the ARC local. __bridge_retained + // takes a matching +1 for the non-ARC void*, so the buffer outlives the ARC + // local's scope-exit release. EndDrawBatch() performs the matching CFRelease. + current_index_buffer_ = (__bridge_retained void*)index_buffer; } else { current_index_buffer_ = nullptr; } diff --git a/third_party/crypto/TinySHA1.hpp b/third_party/crypto/TinySHA1.hpp index 2493e88f3..fd470a694 100644 --- a/third_party/crypto/TinySHA1.hpp +++ b/third_party/crypto/TinySHA1.hpp @@ -30,7 +30,225 @@ #include #include +#if defined(__aarch64__) && \ + (defined(__ARM_FEATURE_CRYPTO) || defined(__ARM_FEATURE_SHA1)) +#include +#define XE_TINYSHA1_ARM64_HW 1 +#endif + namespace sha1 { + +inline uint32_t SHA1Rotl(uint32_t value, uint32_t count) { + return (value << count) ^ (value >> (32 - count)); +} + +// Reference (scalar) block compression: reads big-endian message bytes in +// `block`, updates the five SHA1 state words in `digest`. +inline void SHA1ProcessBlockSoft(uint32_t digest[5], const uint8_t block[64]) { + uint32_t w[80]; + for (size_t i = 0; i < 16; i++) { + w[i] = (uint32_t(block[i * 4 + 0]) << 24) | + (uint32_t(block[i * 4 + 1]) << 16) | + (uint32_t(block[i * 4 + 2]) << 8) | (uint32_t(block[i * 4 + 3])); + } + for (size_t i = 16; i < 80; i++) { + w[i] = SHA1Rotl(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1); + } + uint32_t a = digest[0], b = digest[1], c = digest[2], d = digest[3], + e = digest[4]; + for (size_t i = 0; i < 80; ++i) { + uint32_t f = 0, k = 0; + if (i < 20) { + f = (b & c) | (~b & d); + k = 0x5A827999; + } else if (i < 40) { + f = b ^ c ^ d; + k = 0x6ED9EBA1; + } else if (i < 60) { + f = (b & c) | (b & d) | (c & d); + k = 0x8F1BBCDC; + } else { + f = b ^ c ^ d; + k = 0xCA62C1D6; + } + uint32_t temp = SHA1Rotl(a, 5) + f + e + k + w[i]; + e = d; + d = c; + c = SHA1Rotl(b, 30); + b = a; + a = temp; + } + digest[0] += a; + digest[1] += b; + digest[2] += c; + digest[3] += d; + digest[4] += e; +} + +#if XE_TINYSHA1_ARM64_HW +// ARMv8 SHA1 crypto-extension block compression (canonical unrolled form). +// Always validated against SHA1ProcessBlockSoft at runtime before use, so a +// transcription error here can only cost the speedup, never corrupt a hash. +inline void SHA1ProcessBlockHW(uint32_t state[5], const uint8_t block[64]) { + uint32x4_t ABCD = vld1q_u32(&state[0]); + uint32_t E0 = state[4]; + const uint32x4_t ABCD_SAVED = ABCD; + const uint32_t E0_SAVED = E0; + uint32_t E1; + uint32x4_t MSG0 = vreinterpretq_u32_u8(vrev32q_u8(vld1q_u8(block + 0))); + uint32x4_t MSG1 = vreinterpretq_u32_u8(vrev32q_u8(vld1q_u8(block + 16))); + uint32x4_t MSG2 = vreinterpretq_u32_u8(vrev32q_u8(vld1q_u8(block + 32))); + uint32x4_t MSG3 = vreinterpretq_u32_u8(vrev32q_u8(vld1q_u8(block + 48))); + const uint32x4_t K0 = vdupq_n_u32(0x5A827999), K1 = vdupq_n_u32(0x6ED9EBA1), + K2 = vdupq_n_u32(0x8F1BBCDC), K3 = vdupq_n_u32(0xCA62C1D6); + uint32x4_t TMP0 = vaddq_u32(MSG0, K0), TMP1 = vaddq_u32(MSG1, K0); + // 0-3 + E1 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1cq_u32(ABCD, E0, TMP0); + TMP0 = vaddq_u32(MSG2, K0); + MSG0 = vsha1su0q_u32(MSG0, MSG1, MSG2); + // 4-7 + E0 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1cq_u32(ABCD, E1, TMP1); + TMP1 = vaddq_u32(MSG3, K0); + MSG0 = vsha1su1q_u32(MSG0, MSG3); + MSG1 = vsha1su0q_u32(MSG1, MSG2, MSG3); + // 8-11 + E1 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1cq_u32(ABCD, E0, TMP0); + TMP0 = vaddq_u32(MSG0, K0); + MSG1 = vsha1su1q_u32(MSG1, MSG0); + MSG2 = vsha1su0q_u32(MSG2, MSG3, MSG0); + // 12-15 + E0 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1cq_u32(ABCD, E1, TMP1); + TMP1 = vaddq_u32(MSG1, K1); + MSG2 = vsha1su1q_u32(MSG2, MSG1); + MSG3 = vsha1su0q_u32(MSG3, MSG0, MSG1); + // 16-19 + E1 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1cq_u32(ABCD, E0, TMP0); + TMP0 = vaddq_u32(MSG2, K1); + MSG3 = vsha1su1q_u32(MSG3, MSG2); + MSG0 = vsha1su0q_u32(MSG0, MSG1, MSG2); + // 20-23 + E0 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1pq_u32(ABCD, E1, TMP1); + TMP1 = vaddq_u32(MSG3, K1); + MSG0 = vsha1su1q_u32(MSG0, MSG3); + MSG1 = vsha1su0q_u32(MSG1, MSG2, MSG3); + // 24-27 + E1 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1pq_u32(ABCD, E0, TMP0); + TMP0 = vaddq_u32(MSG0, K1); + MSG1 = vsha1su1q_u32(MSG1, MSG0); + MSG2 = vsha1su0q_u32(MSG2, MSG3, MSG0); + // 28-31 + E0 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1pq_u32(ABCD, E1, TMP1); + TMP1 = vaddq_u32(MSG1, K1); + MSG2 = vsha1su1q_u32(MSG2, MSG1); + MSG3 = vsha1su0q_u32(MSG3, MSG0, MSG1); + // 32-35 + E1 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1pq_u32(ABCD, E0, TMP0); + TMP0 = vaddq_u32(MSG2, K2); + MSG3 = vsha1su1q_u32(MSG3, MSG2); + MSG0 = vsha1su0q_u32(MSG0, MSG1, MSG2); + // 36-39 + E0 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1pq_u32(ABCD, E1, TMP1); + TMP1 = vaddq_u32(MSG3, K2); + MSG0 = vsha1su1q_u32(MSG0, MSG3); + MSG1 = vsha1su0q_u32(MSG1, MSG2, MSG3); + // 40-43 + E1 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1mq_u32(ABCD, E0, TMP0); + TMP0 = vaddq_u32(MSG0, K2); + MSG1 = vsha1su1q_u32(MSG1, MSG0); + MSG2 = vsha1su0q_u32(MSG2, MSG3, MSG0); + // 44-47 + E0 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1mq_u32(ABCD, E1, TMP1); + TMP1 = vaddq_u32(MSG1, K2); + MSG2 = vsha1su1q_u32(MSG2, MSG1); + MSG3 = vsha1su0q_u32(MSG3, MSG0, MSG1); + // 48-51 + E1 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1mq_u32(ABCD, E0, TMP0); + TMP0 = vaddq_u32(MSG2, K2); + MSG3 = vsha1su1q_u32(MSG3, MSG2); + MSG0 = vsha1su0q_u32(MSG0, MSG1, MSG2); + // 52-55 + E0 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1mq_u32(ABCD, E1, TMP1); + TMP1 = vaddq_u32(MSG3, K3); + MSG0 = vsha1su1q_u32(MSG0, MSG3); + MSG1 = vsha1su0q_u32(MSG1, MSG2, MSG3); + // 56-59 + E1 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1mq_u32(ABCD, E0, TMP0); + TMP0 = vaddq_u32(MSG0, K3); + MSG1 = vsha1su1q_u32(MSG1, MSG0); + MSG2 = vsha1su0q_u32(MSG2, MSG3, MSG0); + // 60-63 + E0 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1pq_u32(ABCD, E1, TMP1); + TMP1 = vaddq_u32(MSG1, K3); + MSG2 = vsha1su1q_u32(MSG2, MSG1); + MSG3 = vsha1su0q_u32(MSG3, MSG0, MSG1); + // 64-67 + E1 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1pq_u32(ABCD, E0, TMP0); + TMP0 = vaddq_u32(MSG2, K3); + MSG3 = vsha1su1q_u32(MSG3, MSG2); + MSG0 = vsha1su0q_u32(MSG0, MSG1, MSG2); + // 68-71 + E0 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1pq_u32(ABCD, E1, TMP1); + TMP1 = vaddq_u32(MSG3, K3); + MSG0 = vsha1su1q_u32(MSG0, MSG3); + // 72-75 + E1 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1pq_u32(ABCD, E0, TMP0); + // 76-79 + E0 = vsha1h_u32(vgetq_lane_u32(ABCD, 0)); + ABCD = vsha1pq_u32(ABCD, E1, TMP1); + E0 += E0_SAVED; + ABCD = vaddq_u32(ABCD_SAVED, ABCD); + vst1q_u32(&state[0], ABCD); + state[4] = E0; +} + +// One-time self-test: hash an arbitrary block with both paths and compare. +// Caches the result; if the hardware path ever disagrees, it is never used. +inline bool SHA1HardwareUsable() { + static const bool ok = [] { + uint32_t hw[5] = {0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, + 0xC3D2E1F0}; + uint32_t sw[5]; + std::memcpy(sw, hw, sizeof(sw)); + uint8_t blk[64]; + for (int i = 0; i < 64; i++) blk[i] = static_cast(i * 7 + 3); + SHA1ProcessBlockHW(hw, blk); + SHA1ProcessBlockSoft(sw, blk); + return std::memcmp(hw, sw, sizeof(sw)) == 0; + }(); + return ok; +} +#endif // XE_TINYSHA1_ARM64_HW + +inline void SHA1ProcessBlockDispatch(uint32_t digest[5], + const uint8_t block[64]) { +#if XE_TINYSHA1_ARM64_HW + if (SHA1HardwareUsable()) { + SHA1ProcessBlockHW(digest, block); + return; + } +#endif + SHA1ProcessBlockSoft(digest, block); +} + class SHA1 { public: typedef uint32_t digest32_t[5]; @@ -163,55 +381,7 @@ class SHA1 { } protected: - void processBlock() { - uint32_t w[80]; - for (size_t i = 0; i < 16; i++) { - w[i] = (m_block[i * 4 + 0] << 24); - w[i] |= (m_block[i * 4 + 1] << 16); - w[i] |= (m_block[i * 4 + 2] << 8); - w[i] |= (m_block[i * 4 + 3]); - } - for (size_t i = 16; i < 80; i++) { - w[i] = LeftRotate((w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]), 1); - } - - uint32_t a = m_digest[0]; - uint32_t b = m_digest[1]; - uint32_t c = m_digest[2]; - uint32_t d = m_digest[3]; - uint32_t e = m_digest[4]; - - for (std::size_t i = 0; i < 80; ++i) { - uint32_t f = 0; - uint32_t k = 0; - - if (i < 20) { - f = (b & c) | (~b & d); - k = 0x5A827999; - } else if (i < 40) { - f = b ^ c ^ d; - k = 0x6ED9EBA1; - } else if (i < 60) { - f = (b & c) | (b & d) | (c & d); - k = 0x8F1BBCDC; - } else { - f = b ^ c ^ d; - k = 0xCA62C1D6; - } - uint32_t temp = LeftRotate(a, 5) + f + e + k + w[i]; - e = d; - d = c; - c = LeftRotate(b, 30); - b = a; - a = temp; - } - - m_digest[0] += a; - m_digest[1] += b; - m_digest[2] += c; - m_digest[3] += d; - m_digest[4] += e; - } + void processBlock() { SHA1ProcessBlockDispatch(m_digest, m_block); } private: digest32_t m_digest; diff --git a/xenia-build.py b/xenia-build.py index db269865b..92d11201c 100755 --- a/xenia-build.py +++ b/xenia-build.py @@ -13,6 +13,7 @@ from glob import glob from json import loads as jsonloads import os import platform +import re from shutil import rmtree, which as shutil_which import subprocess import sys @@ -604,7 +605,7 @@ def run_cmake_configure(cc=None, generator=None, build_tests=False, enable_ftrace=False, target_arch=None, target_os=None, config=None, build_dir=None, configuration_types=None, - xcode_generate_schemes=False): + xcode_generate_schemes=False, extra_cmake_args=None): """Runs `cmake` to (re)configure build/ from the source root. Uses Ninja Multi-Config by default on all platforms. On Linux the @@ -733,23 +734,264 @@ def run_cmake_configure(cc=None, generator=None, build_tests=False, args += [f"-DCMAKE_CONFIGURATION_TYPES={configuration_types}"] if xcode_generate_schemes: args += ["-DCMAKE_XCODE_GENERATE_SCHEME=ON"] + if extra_cmake_args: + args += list(extra_cmake_args) ret = subprocess.call(args) if ret == 0: generate_version_h(build_dir) return ret -def run_ios_xcode_configure(): +def _is_full_xcode_developer_dir(dev_dir): + """A full Xcode developer dir ships xcodebuild; Command Line Tools does not.""" + return bool(dev_dir) and os.path.isfile( + os.path.join(dev_dir, "usr", "bin", "xcodebuild")) + + +def _find_xcode_developer_dir(): + """Locates an installed Xcode.app's Contents/Developer, or None.""" + candidates = sorted(glob("/Applications/Xcode*.app")) + candidates += sorted(glob(os.path.expanduser("~/Applications/Xcode*.app"))) + # Spotlight catches Xcodes installed outside /Applications. + try: + out = subprocess.run( + ["mdfind", "kMDItemCFBundleIdentifier == 'com.apple.dt.Xcode'"], + capture_output=True, text=True) + if out.returncode == 0: + candidates += [p for p in out.stdout.splitlines() if p.strip()] + except FileNotFoundError: + pass + # Prefer a stable "Xcode.app" over beta/versioned installs. + def rank(app_path): + return 0 if os.path.basename(app_path) == "Xcode.app" else 1 + seen = set() + for app in sorted(candidates, key=rank): + if app in seen: + continue + seen.add(app) + dev_dir = os.path.join(app, "Contents", "Developer") + if _is_full_xcode_developer_dir(dev_dir): + return dev_dir + return None + + +def ensure_ios_xcode_developer_dir(): + """Ensures a full Xcode (not just Command Line Tools) is active for the + Xcode generator, setting DEVELOPER_DIR for this process when needed. + + The Xcode project generator needs xcodebuild; if xcode-select points at + Command Line Tools (a very common state), CMake misreads the version and + fails. This finds an installed Xcode.app and points DEVELOPER_DIR at it + without requiring `sudo xcode-select`. Returns True if usable. + """ + if _is_full_xcode_developer_dir(os.environ.get("DEVELOPER_DIR")): + return True + try: + selected = subprocess.run( + ["xcode-select", "-p"], capture_output=True, text=True) + selected_dir = (selected.stdout.strip() + if selected.returncode == 0 else "") + except FileNotFoundError: + selected_dir = "" + if _is_full_xcode_developer_dir(selected_dir): + return True # xcode-select already points at a full Xcode. + dev_dir = _find_xcode_developer_dir() + if not dev_dir: + print_error( + "The Xcode project generator needs a full Xcode, but the active " + "developer dir is Command Line Tools and no Xcode.app was found.\n" + " Install Xcode, then either:\n" + " sudo xcode-select -s /Applications/Xcode.app/Contents/Developer\n" + " or set DEVELOPER_DIR to your Xcode's Contents/Developer.") + return False + os.environ["DEVELOPER_DIR"] = dev_dir + print(f" Active toolchain is {selected_dir or 'Command Line Tools'}; " + f"using Xcode at {dev_dir} via DEVELOPER_DIR.") + return True + + +def run_ios_xcode_configure(config=None, development_team=None): + """Configures the Xcode iOS build tree. + + When config is given, the generated project is pinned to that single + configuration (Checked/Debug/Release) so Xcode's scheme — including the + Run action — defaults to it instead of falling back to Debug. With no + config the tree keeps all three configurations (used by the headless CLI + build, which selects the configuration via xcodebuild -configuration). + + development_team, when set, enables Xcode automatic signing with that + Apple Developer team ID so device builds sign without manual setup. + """ + if not ensure_ios_xcode_developer_dir(): + return 1 + configuration_types = config.title() if config else "Checked;Debug;Release" + extra_cmake_args = [] + if development_team: + extra_cmake_args += [ + f"-DCMAKE_XCODE_ATTRIBUTE_DEVELOPMENT_TEAM={development_team}", + "-DCMAKE_XCODE_ATTRIBUTE_CODE_SIGN_STYLE=Automatic", + ] return run_cmake_configure( generator="Xcode", target_arch="arm64", target_os="ios", + config=config, build_dir=get_ios_xcode_build_dir(), - configuration_types="Checked;Debug;Release", + configuration_types=configuration_types, xcode_generate_schemes=True, + extra_cmake_args=extra_cmake_args, ) +def detect_ios_development_team(): + """Best-effort lookup of an Apple Developer team ID for iOS code signing. + + Nothing is hardcoded — this picks up whatever signing identity exists on + the machine building the repo, so a clone signs with its own team. + + Resolution order: + 1. XENIA_IOS_DEVELOPMENT_TEAM / DEVELOPMENT_TEAM environment override. + 2. Teams the user has signed into Xcode with (IDEProvisioningTeams). + 3. Any installed provisioning profile's TeamIdentifier. + 4. The team ID (cert OU) of an Apple Development identity in the keychain. + Returns a team ID string, or None if nothing usable is found. + """ + for env_key in ("XENIA_IOS_DEVELOPMENT_TEAM", "DEVELOPMENT_TEAM"): + team = os.environ.get(env_key) + if team and team.strip(): + return team.strip() + if sys.platform != "darwin": + return None + return (_detect_team_from_xcode_prefs() or + _detect_team_from_provisioning_profiles() or + _detect_team_from_keychain_identities()) + + +def _plist_text_to_obj(text): + """Parses an XML/plist string into a Python object via plutil -> JSON.""" + if not text: + return None + try: + conv = subprocess.run( + ["plutil", "-convert", "json", "-o", "-", "-"], + input=text, capture_output=True, text=True) + except FileNotFoundError: + return None + if conv.returncode != 0 or not conv.stdout: + return None + try: + return jsonloads(conv.stdout) + except ValueError: + return None + + +def _detect_team_from_xcode_prefs(): + try: + raw = subprocess.run( + ["defaults", "export", "com.apple.dt.Xcode", "-"], + capture_output=True, text=True) + except FileNotFoundError: + return None + if raw.returncode != 0: + return None + prefs = _plist_text_to_obj(raw.stdout) + if not isinstance(prefs, dict): + return None + teams = prefs.get("IDEProvisioningTeams") + if not isinstance(teams, dict): + return None + # IDEProvisioningTeams maps each signed-in Apple ID to a list of teams. + candidates = [] + for account_teams in teams.values(): + if not isinstance(account_teams, list): + continue + for entry in account_teams: + if isinstance(entry, dict) and entry.get("teamID"): + candidates.append(entry) + if not candidates: + return None + # Prefer a real (paid) team over a free personal team when both exist. + candidates.sort(key=lambda e: 1 if e.get("isFreeProvisioningTeam") else 0) + return candidates[0]["teamID"] + + +def _detect_team_from_provisioning_profiles(): + prof_dir = os.path.expanduser( + "~/Library/MobileDevice/Provisioning Profiles") + if not os.path.isdir(prof_dir): + return None + for name in sorted(os.listdir(prof_dir)): + if not name.endswith((".mobileprovision", ".provisionprofile")): + continue + try: + decoded = subprocess.run( + ["security", "cms", "-D", "-i", os.path.join(prof_dir, name)], + capture_output=True, text=True) + except FileNotFoundError: + return None + if decoded.returncode != 0: + continue + plist = _plist_text_to_obj(decoded.stdout) + if not isinstance(plist, dict): + continue + team_ids = plist.get("TeamIdentifier") + if isinstance(team_ids, list) and team_ids: + return team_ids[0] + return None + + +def _detect_team_from_keychain_identities(): + """Returns the team ID of the first Apple Development/Distribution code + signing identity in the keychain (the cert's Organizational Unit), or None. + """ + try: + out = subprocess.run( + ["security", "find-identity", "-v", "-p", "codesigning"], + capture_output=True, text=True) + except FileNotFoundError: + return None + if out.returncode != 0 or not out.stdout: + return None + # Lines look like: 1) <40-hex sha1> "Apple Development: Name (XXXXXXXXXX)" + identity_re = re.compile( + r'"((?:Apple Development|Apple Distribution|iPhone Developer|' + r'iPhone Distribution)[^"]*)"') + for line in out.stdout.splitlines(): + match = identity_re.search(line) + if not match: + continue + team = _team_id_from_cert_subject(match.group(1)) + if team: + return team + return None + + +def _team_id_from_cert_subject(common_name): + """Extracts the team ID (subject OU) from the named keychain certificate.""" + try: + pem = subprocess.run( + ["security", "find-certificate", "-c", common_name, "-p"], + capture_output=True, text=True) + except FileNotFoundError: + return None + if pem.returncode != 0 or not pem.stdout: + return None + try: + subj = subprocess.run( + ["openssl", "x509", "-noout", "-subject", "-nameopt", + "sep_multiline"], + input=pem.stdout, capture_output=True, text=True) + except FileNotFoundError: + return None + if subj.returncode != 0 or not subj.stdout: + return None + for line in subj.stdout.splitlines(): + stripped = line.strip() + if stripped.startswith("OU="): + return stripped[len("OU="):].strip() + return None + + def build_ios_xcode_app(config, force=False, configure=True, pass_args=None): """Builds the iOS app with Xcode so Icon Composer packages are processed.""" if sys.platform != "darwin": @@ -1697,12 +1939,20 @@ class DevenvCommand(Command): self.parser.add_argument( "--no-open", action="store_true", help="Configure the IDE build tree without launching the IDE.") + self.parser.add_argument( + "--development-team", default=None, + help="Apple Developer team ID for iOS code signing in Xcode. " + "Defaults to autodetection (Xcode accounts / installed " + "provisioning profiles) or the XENIA_IOS_DEVELOPMENT_TEAM " + "environment variable.") def execute(self, args, pass_args, cwd): target_arch = args.get("target_arch") target_os = args.get("target_os") if sys.platform == "darwin" and target_os == "ios": - return self._launch_xcode_ios(args["config"], open_project=not args["no_open"]) + return self._launch_xcode_ios( + args["config"], open_project=not args["no_open"], + development_team=args.get("development_team")) if sys.platform == "win32": return self._launch_visual_studio(target_arch, args["config"]) # Non-Windows: CLion is the only IDE we know how to launch @@ -1759,12 +2009,22 @@ class DevenvCommand(Command): shell_call(["devenv", sln_path]) return 0 - def _launch_xcode_ios(self, config="debug", open_project=True): + def _launch_xcode_ios(self, config="debug", open_project=True, + development_team=None): """Configures a separate Xcode iOS build tree, then opens it.""" config_title = config.title() build_dir = get_ios_xcode_build_dir() + if development_team is None: + development_team = detect_ios_development_team() print(f"Configuring Xcode iOS build tree ({config_title}) in {build_dir}...") - ret = run_ios_xcode_configure() + print(f" Project pinned to the {config_title} configuration.") + if development_team: + print(f" Signing automatically with development team {development_team}.") + else: + print(" No development team found; set one in Signing & Capabilities,") + print(" or pass --development-team / set XENIA_IOS_DEVELOPMENT_TEAM.") + ret = run_ios_xcode_configure( + config=config, development_team=development_team) if ret != 0: print_error("cmake configure failed for the Xcode iOS build tree") return ret diff --git a/xenia_ios.entitlements b/xenia_ios.entitlements index 619f11c74..2db9249d2 100644 --- a/xenia_ios.entitlements +++ b/xenia_ios.entitlements @@ -2,9 +2,13 @@ - com.apple.developer.kernel.increased-memory-limit - - com.apple.security.files.user-selected.read-write - + com.apple.developer.coremotion.head-pose + + com.apple.developer.kernel.increased-debugging-memory-limit + + com.apple.developer.kernel.increased-memory-limit + + com.apple.security.files.user-selected.read-write +