Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions dm/host/tests/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ CLANG = clang $(SAN)
# stderr that the plain run hides is kept in sanitizer builds: the sanitizers report there
QUIET = $(if $(strip $(SAN)),,2>/dev/null)
.DEFAULT_GOAL := run
.PHONY: ios-audio-route
ios-audio-route: $(B)/t_ios_audio_route
$(B)/t_ios_audio_route
$(B)/t_ios_audio_route: t_ios_audio_route.c ../../ios/Sources/DmAudioRoutePolicy.h | $(B)
$(CLANG) -O1 -Wall -Wextra -Werror -o $@ $<
run: ios-audio-route
# game profile header (dm.h needs game_profile.h, generated by the host Makefile into ../gen/<profile>/)
# tests that use the game profile need GAME=<base game title ID> (there is no default game); GINC only expands when used, so tests without the profile build as before
GINC = $(if $(strip $(GAME)),-I../gen/$(GAME) -I../../games/$(GAME),$(error GAME is not set: make GAME=<base game title ID>: $(shell python3 ../../games/game.py list | awk -F'\t' '{printf "%s (%s) ", $$1, $$3}')))
Expand Down
19 changes: 19 additions & 0 deletions dm/host/tests/t_ios_audio_route.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#include <stdio.h>
#include "../../ios/Sources/DmAudioRoutePolicy.h"

int main(void) {
int failures = 0, checks = 0;
#define CHECK(name, value) do { checks++; if (!(value)) { failures++; fprintf(stderr, "FAIL: %s\n", name); } } while (0)
const unsigned routes[] = {1, 2, 4, 6, 7, 8};
for (unsigned i = 0; i < sizeof routes / sizeof *routes; i++) {
CHECK("failed foreground activation retries on route events", dm_audio_route_retry(routes[i], 0, 0, 1));
CHECK("background never activates audio", !dm_audio_route_retry(routes[i], 1, 0, 1));
CHECK("interruption never activates audio", !dm_audio_route_retry(routes[i], 0, 1, 1));
CHECK("healthy route keeps native RemoteIO handling", !dm_audio_route_retry(routes[i], 0, 0, 0));
}
CHECK("own category notification cannot recurse", !dm_audio_route_retry(3, 0, 0, 1));
CHECK("unknown notification does not guess", !dm_audio_route_retry(0, 0, 0, 1));
CHECK("future notification does not guess", !dm_audio_route_retry(99, 0, 0, 1));
printf("audio route policy: %d checks, %d failed\n", checks, failures);
return failures != 0;
}
9 changes: 9 additions & 0 deletions dm/ios/Sources/DmAudioRoutePolicy.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Route changes can retry a failed activation while the game remains foreground.
// Category changes caused by our own setup must not recurse into activation.
#pragma once
static inline int dm_audio_route_retry(unsigned reason, int background, int interrupted, int session_off) {
if (background || interrupted || !session_off) return 0;
// AVAudioSessionRouteChangeReason: new/old device, override, wake,
// no suitable route, or route configuration. Unknown/category are excluded.
return reason == 1 || reason == 2 || reason == 4 || reason == 6 || reason == 7 || reason == 8;
}
19 changes: 19 additions & 0 deletions dm/ios/Sources/front_ios.m
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include "hle.h"
#include "dm.h"
#include "haptics_apple.h"
#include "DmAudioRoutePolicy.h"

UIView *dm_gamepad_view(CGRect frame);
void dm_gamepad_reset(void);
Expand Down Expand Up @@ -243,6 +244,24 @@ void front_ios_audio_session(void) {
static void front_ios_audio_apply(void) { dm_audio_suspend(g_audio_bg || g_audio_intr); }
static void front_ios_audio_watch(void) {
NSNotificationCenter *nc = NSNotificationCenter.defaultCenter;
// Experimental, default off. A failed foreground activation otherwise only
// retries on returning to the app, even if CarPlay/headphones change route.
const char *route_recovery = getenv("DM_IOS_AUDIO_ROUTE_RECOVERY");
if (route_recovery && !strcmp(route_recovery, "1")) {
[nc addObserverForName:AVAudioSessionRouteChangeNotification object:nil queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification *n) {
unsigned reason = [n.userInfo[AVAudioSessionRouteChangeReasonKey] unsignedIntValue];
AVAudioSession *s = AVAudioSession.sharedInstance;
LOG("audio route: reason %u, outputs %s, %.0f Hz, %.2f ms, activation %s", reason,
s.currentRoute.outputs.description.UTF8String, s.sampleRate, s.IOBufferDuration * 1000,
g_session_off ? "failed" : "active");
if (dm_audio_route_retry(reason, g_audio_bg, g_audio_intr, g_session_off)) {
front_ios_audio_session();
// Restart a silent fallback only after the native session is
// usable. Healthy route changes keep RemoteIO's own handling.
if (!g_session_off) dm_audio_reset();
}
}];
}
[nc addObserverForName:AVAudioSessionInterruptionNotification object:nil queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification *n) {
NSUInteger type = [n.userInfo[AVAudioSessionInterruptionTypeKey] unsignedIntegerValue], reason = [n.userInfo[AVAudioSessionInterruptionReasonKey] unsignedIntegerValue];
if (type == AVAudioSessionInterruptionTypeBegan) { LOG("audio session: interrupted (reason %lu)", (unsigned long)reason); g_audio_intr = 1; }
Expand Down
92 changes: 92 additions & 0 deletions dm/ios/tests/audio_route_check.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Exercise the production notification observer with deterministic activation
// responses. Actual CarPlay/USB/Bluetooth routes are deliberately not simulated.
#import <UIKit/UIKit.h>
#import <AVFAudio/AVFAudio.h>
#import <GameController/GameController.h>
#include <stdatomic.h>
#include <time.h>
static int activations, resets;
static BOOL activationFails;
@interface ProbeAudioSession : NSObject
+ (instancetype)sharedInstance;
- (BOOL)setCategory:(NSString *)category error:(NSError **)error;
- (BOOL)setPreferredSampleRate:(double)value error:(NSError **)error;
- (BOOL)setPreferredIOBufferDuration:(double)value error:(NSError **)error;
- (BOOL)setActive:(BOOL)value error:(NSError **)error;
@property(readonly) double sampleRate;
@property(readonly) double IOBufferDuration;
@property(readonly) AVAudioSessionRouteDescription *currentRoute;
@end
@implementation ProbeAudioSession
+ (instancetype)sharedInstance { static ProbeAudioSession *s; static dispatch_once_t once; dispatch_once(&once, ^{ s = [self new]; }); return s; }
- (BOOL)setCategory:(NSString *)category error:(NSError **)error { (void)category; (void)error; return YES; }
- (BOOL)setPreferredSampleRate:(double)value error:(NSError **)error { (void)value; (void)error; return YES; }
- (BOOL)setPreferredIOBufferDuration:(double)value error:(NSError **)error { (void)value; (void)error; return YES; }
- (BOOL)setActive:(BOOL)value error:(NSError **)error { (void)value; activations++; if (activationFails && error) *error = [NSError errorWithDomain:@"verification" code:1 userInfo:nil]; return !activationFails; }
- (double)sampleRate { return 48000; }
- (double)IOBufferDuration { return .005; }
- (AVAudioSessionRouteDescription *)currentRoute { return nil; }
@end
#define AVAudioSession ProbeAudioSession
#include "../Sources/front_ios.m"
#undef AVAudioSession
#define main unused_production_main
#include "../Sources/main.m"
#undef main
uint64_t g_t0_ns;
uint64_t now_ns(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return (uint64_t)t.tv_sec * 1000000000ull + t.tv_nsec; }
int dm_main(int argc, char **argv) { (void)argc; (void)argv; return 0; }
void dm_host_preload_start(int argc, char **argv) { (void)argc; (void)argv; }
void dm_haptics_apple_init(void) {}
UIView *dm_gamepad_view(CGRect f) { return [[UIView alloc] initWithFrame:f]; }
void dm_gamepad_reset(void) {}
bool dm_show_launcher(void *v, void (*d)(void)) { (void)v; (void)d; return false; }
uint64_t mtl_present_count(void) { return 0; }
void dm_audio_suspend(int on) { (void)on; }
void dm_audio_reset(void) { resets++; }
void dm_focus_set(int st) { (void)st; }
void guest_clock_freeze_for(int r, int on) { (void)r; (void)on; }
void dm_gfx_suspend(int on) { (void)on; }
static NSMutableArray *checks;
static void check(NSString *name, BOOL passed) { [checks addObject:@{@"name":name,@"passed":@(passed)}]; fprintf(stderr,"%s: %s\n",name.UTF8String,passed?"PASS":"FAIL"); }
static void route(unsigned reason) { [NSNotificationCenter.defaultCenter postNotificationName:AVAudioSessionRouteChangeNotification object:nil userInfo:@{AVAudioSessionRouteChangeReasonKey:@(reason)}]; }
static void runChecks(void) {
checks = [NSMutableArray array];
unsetenv("DM_IOS_AUDIO_ROUTE_RECOVERY"); front_ios_audio_watch(); g_session_off=1; route(1);
check(@"default off leaves route behavior unchanged",activations==0 && resets==0 && g_session_off);
setenv("DM_IOS_AUDIO_ROUTE_RECOVERY","1",1); front_ios_audio_watch();
route(1); check(@"failed foreground session reactivates and rebuilds outputs",activations==1 && resets==1 && !g_session_off);
route(2); check(@"healthy route change leaves RemoteIO handling intact",activations==1 && resets==1);
g_session_off=1; route(3); check(@"own category notification cannot recurse",activations==1 && resets==1);
route(0); route(99); check(@"unknown reasons do not invent recovery",activations==1);
g_audio_bg=1; route(1); check(@"background does not activate audio",activations==1);
g_audio_bg=0; g_audio_intr=1; route(1); check(@"interrupted session does not activate audio",activations==1);
g_audio_intr=0; activationFails=YES; route(8);
check(@"failed recovery stays failed without resetting outputs",activations==2 && resets==1 && g_session_off);
activationFails=NO; route(8);
check(@"later route event can recover without foreground cycling",activations==3 && resets==2 && !g_session_off);
NSUInteger failed=0; for (NSDictionary *c in checks) failed += ![c[@"passed"] boolValue];
NSDictionary *report=@{@"checks":checks,@"failed":@(failed),@"physicalAudioRouteTested":@NO,@"productionAudioObserverUsed":@YES,@"nativeActivationMocked":@YES,@"systemVersion":UIDevice.currentDevice.systemVersion};
NSURL *docs=[NSFileManager.defaultManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0];
[[NSJSONSerialization dataWithJSONObject:report options:NSJSONWritingPrettyPrinted error:nil] writeToURL:[docs URLByAppendingPathComponent:@"results.json"] atomically:YES];
}
@interface AudioRouteScene : NSObject<UIWindowSceneDelegate>
@property(strong,nonatomic) UIWindow *window;
@end
@implementation AudioRouteScene
- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)options {
(void)session; (void)options;
self.window=[[UIWindow alloc] initWithWindowScene:(UIWindowScene *)scene];
UIViewController *vc=[UIViewController new]; self.window.rootViewController=vc; [self.window makeKeyAndVisible];
dispatch_async(dispatch_get_main_queue(), ^{ runChecks(); });
}
@end
@interface AudioRouteApp : UIResponder<UIApplicationDelegate>
@end
@implementation AudioRouteApp
@end
int main(int argc,char **argv) { @autoreleasepool {
NSURL *docs=[NSFileManager.defaultManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0];
freopen([docs URLByAppendingPathComponent:@"audio.log"].fileSystemRepresentation,"w",stderr); setvbuf(stderr,NULL,_IOLBF,0);
return UIApplicationMain(argc,argv,nil,NSStringFromClass(AudioRouteApp.class));
} }
66 changes: 66 additions & 0 deletions dm/ios/tools/build_audio_route_probe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Build an unsigned native iOS audio-route observer probe with synthetic host stubs.

GAME selects only source profile constants. No game files, keys or signing are
used. Install/run the resulting app separately on an authorized test device.
"""
import argparse
import json
import os
from pathlib import Path
import subprocess
import sys


REPO = Path(__file__).resolve().parents[3]


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', required=True, type=Path, help='new scratch directory')
args = parser.parse_args()
game = os.environ.get('GAME') or os.environ.get('DM_GAME')
if not game:
parser.error('GAME or DM_GAME must select a source profile')
output = args.output.resolve()
output.mkdir(parents=True)
include = output / 'Include'
include.mkdir()
subprocess.run([sys.executable, str(REPO / 'dm/games/game.py'), 'header', game,
str(include / 'game_profile.h')], check=True)
project = {
'name': 'AudioRouteVerification',
'options': {'deploymentTarget': {'iOS': '26.0'}},
'targets': {'AudioRouteVerification': {
'type': 'application', 'platform': 'iOS',
'sources': [{'path': str(REPO / 'dm/ios/tests/audio_route_check.m')}],
'dependencies': [{'sdk': name + '.framework'} for name in
('UIKit', 'Foundation', 'QuartzCore', 'Metal', 'AVFAudio', 'GameController')],
'settings': {'base': {
'PRODUCT_BUNDLE_IDENTIFIER': 'dev.anygame.audio-route-verification',
'ENABLE_DEBUG_DYLIB': 'NO', 'CODE_SIGN_STYLE': 'Automatic',
'TARGETED_DEVICE_FAMILY': '1',
'HEADER_SEARCH_PATHS': [str(REPO / 'dm/host'), str(include)],
}},
'info': {'path': 'Info.plist', 'properties': {
'CFBundleDisplayName': 'Audio Route Verification', 'UILaunchScreen': {},
'UIFileSharingEnabled': True,
'UISupportedInterfaceOrientations': ['UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'],
'UIApplicationSceneManifest': {'UIApplicationSupportsMultipleScenes': False,
'UISceneConfigurations': {'UIWindowSceneSessionRoleApplication': [{
'UISceneConfigurationName': 'Default', 'UISceneDelegateClassName': 'AudioRouteScene'}]}},
}},
}},
}
spec = output / 'project.yml'
spec.write_text(json.dumps(project, indent=2) + '\n')
subprocess.run(['xcodegen', 'generate', '--spec', str(spec)], check=True)
subprocess.run(['xcodebuild', '-project', str(output / 'AudioRouteVerification.xcodeproj'),
'-scheme', 'AudioRouteVerification', '-configuration', 'Debug',
'-destination', 'generic/platform=iOS', '-derivedDataPath', str(output / 'DerivedData'),
'-jobs', '3', 'CODE_SIGNING_ALLOWED=NO', 'build'], check=True)
print(output / 'DerivedData/Build/Products/Debug-iphoneos/AudioRouteVerification.app')


if __name__ == '__main__':
main()
1 change: 1 addition & 0 deletions docs/agents/symptoms.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ One line per symptom in the [subsystem references](../reference/): what you see

## Audio

- an iOS session whose activation failed can stay silent while the → [Experimental route recovery](../reference/audio.md#experimental-route-recovery)
- abort with Result 0x499 in `audio::GetAudioRendererWorkBufferSize`. → [SDK 8's longer `AudioRendererParameter`](../reference/audio.md#sdk-8s-longer-audiorendererparameter)
- the game is silent; only the game's updates render (catch-up frames that nothing plays). The log has `audren: OpenAudioRenderer (no event)` and no `render thread started`. → [A renderer opened without an event](../reference/audio.md#a-renderer-opened-without-an-event)
- `[RED] audren: a second renderer` right after the first open, with the same work buffer and "original device 1 mode 1". → [The SDK's open with an event calls the open without one (`t_in_open`)](../reference/audio.md#the-sdks-open-with-an-event-calls-the-open-without-one-t_in_open)
Expand Down
1 change: 1 addition & 0 deletions docs/env_switches.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ DM_HITCH_DUMP diagnostic `=<ms>[:<s>]`: when no frame has been presented for tha
DM_HLECOUNT diagnostic `=<file>` (`%d` = process ID) writes at exit a TSV of how many times the guest called each HLE function, zero counts included
DM_ICON path `=<image>` gives `build_ipa.sh` another app icon (default the `icon.jpg` beside the RomFS)
DM_IMPORTS diagnostic When set, logs each import the loader binds to a host function and each forced takeover
DM_IOS_AUDIO_ROUTE_RECOVERY switch `1` enables experimental iOS route diagnostics and retries failed foreground activation after a recognized route event; default off, no background/interruption activation or category-change recursion
DM_KBD switch `=<text>`: the system keyboard returns that text as OK without showing an input box; unset with no window, it returns cancel
DM_KBD_PLAYER switch `own` makes the Mac keyboard player 1 on its own, with controllers from player 2 on (by default the first controller shares player 1 with the keyboard)
DM_KEYS switch Scripted input: `<seconds or f<frame>>:<key>[+key][~hold][*count/interval],…` presses buttons, sticks and `Shake` at those times
Expand Down
21 changes: 21 additions & 0 deletions docs/reference/audio.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,27 @@

The audio renderer (`dm/host/hle_audren.c`), AudioOut, audio devices and codecs. Each entry: symptom, cause, fix, the games it was found on. The [symptom index](../agents/symptoms.md) lists every symptom.

## iOS audio session

### Experimental route recovery

- **Symptom:** an iOS session whose activation failed can stay silent while the
game remains foreground, even after an audio route becomes available. The
current retry requires another foreground/active transition.
- **Cause:** the front end observes interruptions and media-service resets but
not route changes. A route change alone cannot retry the failed session.
- **Experiment:** `DM_IOS_AUDIO_ROUTE_RECOVERY=1` logs the actual route, sample
rate and buffer duration. Recognized device/route events retry a failed session
only while foreground and uninterrupted. After successful activation, outputs
are recreated to leave a silent fallback. Healthy routes keep RemoteIO's own
handling. Category-change notifications are excluded to avoid recursion from
our own session setup. Default off; no polling or activation retry timer.
- **Found on:** the activation failure was recorded during the Duo/CarPlay
session. Recovery through actual CarPlay/headphone changes is still unverified;
this experiment does not establish the cause of progressive audio glitches.
- **Check:** `make -C dm/host/tests ios-audio-route` runs the actual event policy
without audio hardware. Native compilation and physical route tests are separate.

## Audio renderer

### Running the SDK's own renderer on the CPU
Expand Down