From 414db0277fb428d2b0f4e78a817d16fcebc1a9fb Mon Sep 17 00:00:00 2001 From: Oliver Ames Date: Wed, 28 Oct 2026 21:30:44 -0400 Subject: [PATCH] Trace display queries before native aspect experiments --- dm/host/hle_oe.c | 27 +++++- dm/host/tests/Makefile | 9 ++ dm/host/tests/t_aspect_trace.c | 64 ++++++++++++++ dm/ios/tools/build_aspect_trace_probe.py | 103 +++++++++++++++++++++++ docs/aspect-tracing.md | 31 +++++++ docs/development.md | 2 + docs/env_switches.tsv | 1 + 7 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 dm/host/tests/t_aspect_trace.c create mode 100644 dm/ios/tools/build_aspect_trace_probe.py create mode 100644 docs/aspect-tracing.md diff --git a/dm/host/hle_oe.c b/dm/host/hle_oe.c index d6eb5b238d..c7169c2778 100644 --- a/dm/host/hle_oe.c +++ b/dm/host/hle_oe.c @@ -5,6 +5,7 @@ // The operation mode is runtime state (the front end can change it: iOS overheat downgrade switches to handheld): the single source is here, taken once from DM_OPMODE at startup, the environment variable is not read again afterwards #include +#include static _Atomic int g_opmode = -1; int dm_opmode(void) { int m = g_opmode; @@ -131,11 +132,33 @@ HLE_FORCE("_ZN2nn2oe18GetPerformanceModeEv", "performance mode query; same as th static unsigned char g_cpu_oc; HLE_FORCE("_ZN2nn2oe21IsCpuOverclockEnabledEv", "CPU overclock state (through apm): what SetCpuOverclockEnabled set, off at start", unsigned, h_oe_CpuOc, void) { return g_cpu_oc; } HLE_FORCE("_ZN2nn2oe22SetCpuOverclockEnabledEb", "CPU overclock switch (through apm); the host does not scale clocks", void, h_oe_SetCpuOc, unsigned on) { g_cpu_oc = on & 0xff; } +// This query is not every game's source of camera dimensions. Record actual +// callers before trying a resolution override; keep the stock Switch answer. +static _Atomic int g_aspect_trace_on = -1; +static _Atomic unsigned g_aspect_trace_count; +static void aspect_query_trace(const char *symbol, int width, int height) { + int saved_errno = errno, enabled = atomic_load(&g_aspect_trace_on); + if (enabled < 0) { + const char *e = getenv("DM_ASPECT_TRACE"); int value = e && !strcmp(e, "1"), unknown = -1; + atomic_compare_exchange_strong(&g_aspect_trace_on, &unknown, value); enabled = atomic_load(&g_aspect_trace_on); + } + if (enabled) { + unsigned n = atomic_load(&g_aspect_trace_count); + while (n < 16 && !atomic_compare_exchange_weak(&g_aspect_trace_count, &n, n + 1)) {} + if (n < 16) { + if (width && height) LOG("aspect query %u/16 %s answer=%dx%d (stock; no camera override)", n + 1, symbol, width, height); + else LOG("aspect query %u/16 %s change_event_request (stock binding)", n + 1, symbol); + } + } + errno = saved_errno; +} HLE_FORCE("_ZN2nn2oe27GetDefaultDisplayResolutionEPiS1_", "default display resolution: 1920x1080 docked, 1280x720 handheld", void, h_oe_DefRes, int *w, int *h) { - int con = opmode() == 1; if (w) *w = con ? 1920 : 1280; if (h) *h = con ? 1080 : 720; + int con = opmode() == 1, width = con ? 1920 : 1280, height = con ? 1080 : 720; + if (w) *w = width; if (h) *h = height; + aspect_query_trace(hle_force_h_oe_DefRes.name, width, height); } HLE_FORCE("_ZN2nn2oe38GetDefaultDisplayResolutionChangeEventEPNS_2os11SystemEventE", "default display resolution change event: bound, never signaled (the operation mode notification carries the change)", - void, h_oe_DefResEv, void *ev) { hle_bind_system_event(ev, 0); } + void, h_oe_DefResEv, void *ev) { aspect_query_trace(hle_force_h_oe_DefResEv.name, 0, 0); hle_bind_system_event(ev, 0); } HLE_FORCE("_ZN2nn2oe40SendServerMaintenanceOverlayNotificationENS_4time9PosixTimeES2_", "server maintenance overlay (through am); the host has no system overlay", void, h_oe_MaintOverlay, int64_t a, int64_t b) { (void)a; (void)b; } // apm performance configurations: Normal 0x00010000 (CPU 1020 / GPU 384 / EMC 1600 MHz, the handheld default), Boost 0x00010001 (GPU 768 MHz, the docked default) HLE_FORCE("_ZN2nn2oe27GetPerformanceConfigurationENS0_15PerformanceModeE", "query the CPU/GPU clock config (through apm); reports the default for the mode", int, h_oe_GetPerfCfg, int mode) { return mode == 1 ? 0x00010001 : 0x00010000; } diff --git a/dm/host/tests/Makefile b/dm/host/tests/Makefile index 499e40a4e4..43374f20ef 100644 --- a/dm/host/tests/Makefile +++ b/dm/host/tests/Makefile @@ -252,3 +252,12 @@ tsan-threads-run: $(addprefix $(B)/,$(TSAN_T)) $(B)/t_altstack DM_QOS_MAP=1 $(B)/t_notify_sched DM_QOS_MAP=1 $(B)/t_notify_sched --all-peers + +# Guest query tracing plus the existing VI geometry/crop boundary; no game data or GPU. +$(B)/t_aspect_trace: t_aspect_trace.c ../hle_oe.c ../../gfx/hle_vi.c ../hle.h ../dm.h $(GAME_H) | $(B) + $(CLANG) -O1 -g -Wall -Werror -Wno-unused-function -I.. $(GINC) -o $@ t_aspect_trace.c +.PHONY: aspect-trace +aspect-trace: $(B)/t_aspect_trace + $(B)/t_aspect_trace + +run: aspect-trace diff --git a/dm/host/tests/t_aspect_trace.c b/dm/host/tests/t_aspect_trace.c new file mode 100644 index 0000000000..081f277422 --- /dev/null +++ b/dm/host/tests/t_aspect_trace.c @@ -0,0 +1,64 @@ +// Actual guest display-query and layer hooks: diagnostics must not override geometry. +#include "../hle_oe.c" +#include "../../gfx/hle_vi.c" +#include +#include +static int bound, autoclear; +static void *event_pointer; +void dm_thread_native_wait(int entering) { (void)entering; } +uint64_t now_ns(void) { return 0; } +void dm_audio_suspend(int on) { (void)on; } +void guest_clock_freeze_for(int reason, int on) { (void)reason; (void)on; } +void dm_gfx_suspend(int on) { (void)on; } +uint64_t dm_nvn_frame_count(void) { return 0; } +void hle_bind_system_event(void *ev, int clear) { bound++; autoclear = clear; event_pointer = ev; } +void hle_signal_system_event(void *ev) { (void)ev; } +void hle_clear_system_event(void *ev) { (void)ev; } +extern const struct mach_header_64 _mh_execute_header; +static int checks, failures; +#define CHECK(c, message) do { checks++; if (!(c)) { failures++; printf("FAIL: %s\n", message); } } while (0) +static void *hook(const char *name) { + unsigned long bytes = 0; void *fn = NULL; unsigned count = 0; + const hle_force_ent *entries = (const hle_force_ent *)getsectiondata(&_mh_execute_header, "__DATA", "swforce", &bytes); + for (size_t i = 0; entries && i < bytes / sizeof *entries; i++) if (!strcmp(entries[i].name, name)) { fn = entries[i].fn; count++; } + CHECK(count == 1 && fn, name); return fn; +} +int main(void) { + unsetenv("DM_OE_SCRIPT"); unsetenv("DM_ASPECT_TRACE"); g_aspect_trace_on = -1; g_aspect_trace_count = 0; + FILE *capture = tmpfile(); int saved = dup(STDERR_FILENO); + CHECK(capture && saved >= 0, "capture available"); if (!capture || saved < 0) return 1; + fflush(stderr); dup2(fileno(capture), STDERR_FILENO); + void (*resolution)(int *, int *) = hook("_ZN2nn2oe27GetDefaultDisplayResolutionEPiS1_"); + void (*resolution_event)(void *) = hook("_ZN2nn2oe38GetDefaultDisplayResolutionChangeEventEPNS_2os11SystemEventE"); + Result (*create)(void **, void *, int, int) = hook("_ZN2nn2vi11CreateLayerEPPNS0_5LayerEPNS0_7DisplayEii"); + Result (*crop)(void *, int, int, int, int) = hook("_ZN2nn2vi12SetLayerCropEPNS0_5LayerEiiii"); + int w = -1, h = -1; g_opmode = 1; errno = EDOM; resolution(&w, &h); + CHECK(w == 1920 && h == 1080 && errno == EDOM, "default docked geometry/error unchanged"); + fflush(stderr); CHECK(ftell(capture) == 0, "default query trace off"); + setenv("DM_ASPECT_TRACE", "1", 1); g_aspect_trace_on = -1; + errno = ERANGE; resolution(&w, &h); + CHECK(w == 1920 && h == 1080 && errno == ERANGE, "traced docked answer remains stock"); + g_opmode = 0; resolution(&w,&h); + CHECK(w == 1280 && h == 720, "traced handheld answer remains stock"); + h = -1; resolution(NULL, &h); CHECK(h == 720, "null width output remains supported"); + resolution(NULL, NULL); CHECK(g_aspect_trace_count == 4, "null outputs still record an actual query"); + uint8_t event[0x100]; resolution_event(event); + CHECK(bound == 1 && event_pointer == event && autoclear == 0, "change event retains one unsignaled binding"); + int correct = 1; + for (int i = 0; i < 200; i++) { resolution(&w,&h); correct &= w == 1280 && h == 720; } + CHECK(correct && g_aspect_trace_count == 16, "bounded query tracing never changes repeated answers"); + fflush(stderr); long query_bytes = ftell(capture); + void *layer = NULL; CHECK(create(&layer, NULL, 1920, 1080) == RES_OK && layer, "actual sized layer hook still succeeds"); + dm_vi_layer_size(&w,&h); CHECK(w == 1920 && h == 1080, "layer geometry comes from its guest arguments"); + CHECK(crop(layer, 12, 8, 1280, 720) == RES_OK, "actual guest crop hook succeeds"); + int rect[4]; dm_vi_layer_crop(rect); + CHECK(rect[0] == 12 && rect[1] == 8 && rect[2] == 1280 && rect[3] == 720, "crop is retained independently of display query"); + fflush(stderr); dup2(saved, STDERR_FILENO); close(saved); rewind(capture); + char text[16384]; size_t bytes = fread(text, 1, sizeof text - 1, capture); text[bytes] = 0; fclose(capture); + int lines = 0; for (long i = 0; i < query_bytes; i++) if (text[i] == '\n') lines++; + CHECK(lines == 16, "only first sixteen actual query/event calls logged"); + CHECK(strstr(text,"aspect query 1/16 _ZN2nn2oe27GetDefaultDisplayResolutionEPiS1_ answer=1920x1080") && strstr(text,"answer=1280x720"), "bounded trace identifies symbol and stock answers"); + CHECK(strstr(text,"GetDefaultDisplayResolutionChangeEvent") && strstr(text,"vi::CreateLayer 1920x1080") && strstr(text,"vi::SetLayerCrop 12,8 1280x720"), "existing layer logs and new query log expose separate boundaries"); + printf("aspect trace: %d checks, %d failed\n", checks, failures); + return failures != 0; +} diff --git a/dm/ios/tools/build_aspect_trace_probe.py b/dm/ios/tools/build_aspect_trace_probe.py new file mode 100644 index 0000000000..6de3aaaf4b --- /dev/null +++ b/dm/ios/tools/build_aspect_trace_probe.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Build an unsigned native iOS probe of actual registered diagnostic hooks. + +GAME selects a known source profile. No game files, keys or signing are used. +Install and run only through the user's authorized device workflow. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys + +REPO = Path(__file__).resolve().parents[3] +NAME = 'AspectTraceVerification' +KIND = 'aspect' +PRODUCTION = ['dm/host/hle_oe.c', 'dm/gfx/hle_vi.c'] +EXTRA = [] +HOST = r'''#import +extern int dm_trace_test_main(void); +@interface TraceScene : NSObject +@property(strong,nonatomic) UIWindow *window; +@end +@implementation TraceScene +- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)options { + (void)session; (void)options; + self.window = [[UIWindow alloc] initWithWindowScene:(UIWindowScene *)scene]; + self.window.rootViewController = [UIViewController new]; [self.window makeKeyAndVisible]; + dispatch_async(dispatch_get_main_queue(), ^{ + int result = dm_trace_test_main(); + NSURL *url = [NSBundle.mainBundle URLForResource:@"SourceProvenance" withExtension:@"json"]; + NSDictionary *source = [NSJSONSerialization JSONObjectWithData:[NSData dataWithContentsOfURL:url] options:0 error:NULL]; + NSDictionary *report = @{@"status":@(result), @"registeredProductionHooksUsed":@YES, + @"eventBindingStubbed":@YES, @"gameRuntimeUsed":@NO, @"physicalNetworkOrCameraTested":@NO, + @"systemVersion":UIDevice.currentDevice.systemVersion, @"sourceProvenance":source}; + NSURL *documents = [NSFileManager.defaultManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0]; + [[NSJSONSerialization dataWithJSONObject:report options:NSJSONWritingPrettyPrinted error:NULL] + writeToURL:[documents URLByAppendingPathComponent:@"results.json"] atomically:YES]; + fflush(stdout); fflush(stderr); + }); +} +@end +@interface TraceApp : UIResponder +@end +@implementation TraceApp +@end +int main(int argc, char **argv) { @autoreleasepool { + NSURL *documents = [NSFileManager.defaultManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask][0]; + freopen([documents URLByAppendingPathComponent:@"verification.log"].fileSystemRepresentation, "w", stdout); + freopen([documents URLByAppendingPathComponent:@"errors.log"].fileSystemRepresentation, "w", stderr); + setvbuf(stdout, NULL, _IOLBF, 0); setvbuf(stderr, NULL, _IOLBF, 0); + return UIApplicationMain(argc, argv, nil, NSStringFromClass(TraceApp.class)); +} } +''' + + +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, '-B', str(REPO / 'dm/games/game.py'), 'header', game, + str(include / 'game_profile.h')], check=True) + (output / 'Host.m').write_text(HOST) + test = f'dm/host/tests/t_{KIND}_trace.c' + provenance = {'commit': subprocess.check_output(['git', '-C', str(REPO), 'rev-parse', 'HEAD'], text=True).strip(), + 'sourceSHA256': {path: hashlib.sha256((REPO / path).read_bytes()).hexdigest() + for path in [test, *PRODUCTION]}, 'gameProfile': game} + provenance_file = output / 'SourceProvenance.json' + provenance_file.write_text(json.dumps(provenance, indent=2) + '\n') + project = {'name': NAME, 'options': {'deploymentTarget': {'iOS': '26.0'}}, 'targets': {NAME: { + 'type': 'application', 'platform': 'iOS', + 'sources': [{'path': str(output / 'Host.m')}, + {'path': str(REPO / test), 'compilerFlags': '-Dmain=dm_trace_test_main'}, + *[{'path': str(REPO / path)} for path in EXTRA], + {'path': str(provenance_file), 'buildPhase': 'resources'}], + 'dependencies': [{'sdk': 'UIKit.framework'}, {'sdk': 'Foundation.framework'}], + 'settings': {'base': {'PRODUCT_BUNDLE_IDENTIFIER': f'dev.anygame.{KIND}-trace-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': NAME, 'UILaunchScreen': {}, 'UIFileSharingEnabled': True, + 'UIApplicationSceneManifest': {'UIApplicationSupportsMultipleScenes': False, 'UISceneConfigurations': { + 'UIWindowSceneSessionRoleApplication': [{'UISceneConfigurationName': 'Default', 'UISceneDelegateClassName': 'TraceScene'}]}}}}}}} + 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 / f'{NAME}.xcodeproj'), '-scheme', NAME, + '-configuration', 'Debug', '-destination', 'generic/platform=iOS', + '-derivedDataPath', str(output / 'DerivedData'), '-jobs', '3', + 'CODE_SIGNING_ALLOWED=NO', 'build'], check=True) + print(output / f'DerivedData/Build/Products/Debug-iphoneos/{NAME}.app') + + +if __name__ == '__main__': + main() diff --git a/docs/aspect-tracing.md b/docs/aspect-tracing.md new file mode 100644 index 0000000000..698357b1df --- /dev/null +++ b/docs/aspect-tracing.md @@ -0,0 +1,31 @@ +# Diagnose native aspect before changing a camera + +This branch adds `DM_ASPECT_TRACE=1`, off by default. It records the first 16 actual `oe::GetDefaultDisplayResolution` queries and resolution-change-event requests. Answers remain 1920x1080 docked and 1280x720 handheld. Event binding stays unchanged. The trace works on iOS because it runs inside the registered hooks rather than generating executable counting stubs. + +The source already logs the later output boundaries, so this experiment adds no NVN profiler or present mode: + +| Boundary | Existing source and evidence | +|---|---| +| Guest default display answer | `dm/host/hle_oe.c`, new `aspect query` lines | +| Guest sized layer and layer crop | `dm/gfx/hle_vi.c`, `vi::CreateLayer` and changed `vi::SetLayerCrop` lines | +| Swapchain and window crop | `dm/gfx/nvn/hle_nvn.c`, `WindowInitialize` and changed `WindowCrop` lines | +| Valid source rectangle and drawable canvas | `dm/gfx/nvn/nvn_mtl.m`, changed `present crop` and `present viewport` lines | +| Internal render attachments and shader bindings | existing `DM_NVN_PASSDUMP` / `DM_NVN_PASSDUMP_ALL` | +| Exact projection candidates | existing `DM_NVN_CBDUMP` / `DM_NVN_CBDUMP_KEY`, after identifying a real world vertex shader | + +## Capture procedure + +1. Record the exact game version, `main` and `sdk` NSO build IDs, host revision, shader-chain stamp and source hashes. Use an isolated save and a stable gameplay route. Keep game dumps and generated captures in that game's external work folder. A profile title or a matching shader name is not a substitute for the exact build. +2. Enable `DM_ASPECT_TRACE=1` before launch. Capture the stock run first, including the existing crop, swapchain and viewport lines. No query line means these particular registered hooks were not reached while tracing; it does not prove that the game has no display or camera logic. A line numbered `16/16` means the query budget is exhausted. +3. Pick a known frame in the stable world scene and set `DM_NVN_PASSDUMP` to that frame, or a short inclusive range. Set `DM_NVN_PASSDUMP_ALL=1` only for that bounded capture. Record the actual world vertex shader key from its draw and resolve it in that build's shader map. Menus and final composite triangles do not identify the world camera. On a Mac host, an existing `DM_CTL` `passdump` request can select a few frames once the scene is reached. +4. Create an output directory, set `DM_NVN_CBDUMP` to it, and set `DM_NVN_CBDUMP_KEY` to the confirmed full vertex key. Keep the same bounded `DM_NVN_PASSDUMP` range. The backend writes `F_p_st_cb_
.bin` blocks; for a vertex key it also records vertex streams and indices. Do not substitute an example or invented shader key. +5. Compare a fixed scene and a controlled camera change. Track which internal attachments feed the final image, which crop is valid, and which uniform block contains the projection actually used by that world draw. Trace that block's producer in the exact memory image before changing it. A changing matrix is a candidate until its consumer and producer are confirmed. +6. Only then define a build-gated experiment that covers the camera projection, culling, internal target dimensions, postprocessing and UI anchoring together. Compare world geometry and visible edges against the stock capture. A wider output texture, a scalar float hit or an absence of literal-load references does not establish that the camera widened. + +`tools/scan_aspect.py` in the wrapper repository is a preliminary scalar/LDR-literal scan. It misses ADRP-based loads and computed ratios. Earlier draw-table and float-constant experiments are recorded in [anygame issue #2](https://github.com/oliverames/anygame/issues/2); their negative results should not be replaced with an assumption that the camera follows the output size. + +## Verification and limits + +`make -C dm/host/tests GAME= aspect-trace` calls the actual registered resolution and layer hooks without game files or a GPU. It checks default-off logging, stock docked/handheld answers, nullable output arguments, event binding, the 16-line cap, errno preservation, and the independent guest layer/crop state. `GAME= python3 dm/ios/tools/build_aspect_trace_probe.py --output ` builds the same test in an unsigned native iOS app. The app writes `Documents/results.json`, `verification.log` and `errors.log`, with exact source hashes. Its event binding is a stub and it runs no guest game. + +No camera patch, native-aspect rendering, cropping, stretching or resolution override is implemented. The procedure still needs a confirmed game build, world shader key and real capture before it can identify a safe projection change. diff --git a/docs/development.md b/docs/development.md index 163a508333..4bb1c2af84 100644 --- a/docs/development.md +++ b/docs/development.md @@ -574,6 +574,8 @@ The game's own work and its frame rate stay as they are; the host avoids work of Each game's notes are in its profile folder, `dm/games//README.md`: what the profile needs and why, what was found on the game, Mac routes and open items. The [games table](../dm/games/README.md#games) lists them. +Diagnostic experiments: [Native-aspect boundary diagnostics](aspect-tracing.md) records existing guest boundaries without changing network or rendering behavior. + ## Tools (`dm/tools`) | Tool | What it does | diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index c7695d9902..f637c0a977 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -6,6 +6,7 @@ DM_ALLOW_OTHER_METAL switch `1` lets `own_compilers.py` build the shader librari DM_AOC_DIR path Folder of add-on content: one numbered subfolder per DLC index holding that DLC's RomFS, reported by `aoc` and mounted read-only; the app, the Mac launcher and the NSP script set it when the build has DLC DM_AOC_OWNED switch `0` reports no add-on content even when `DM_AOC_DIR` has some, for runs without the DLC DM_APP_VERSION switch Overrides the IPA's version in `build_ipa.sh`; by default it is the packed game's display version from its control data (the update's when one is packed) +DM_ASPECT_TRACE diagnostic `=1` logs the first 16 actual guest default-display-resolution queries or change-event requests, with the stock Switch answers; existing layer/window crop, swapchain, present and pass-dump diagnostics record later rendering boundaries; no resolution or camera override DM_AUDIO switch `1` plays audio through Core Audio, `0` uses the silent sink; real output is the default on iOS, the silent sink on the Mac (the Mac launcher sets `1`) DM_AUDIO_DUMP diagnostic `=` writes every PCM block the game appends as is (16-bit, 48 kHz, channel count as in the OpenAudioOut log), to judge whether a sound played DM_AUDIO_MUTE switch `=1` runs the real Core Audio output path (callback pacing, background take-over) with the output silenced, for lifecycle tests on the Mac without noise