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
+