From 4f0217cc60566b124c5c0c496693868df2095291 Mon Sep 17 00:00:00 2001 From: Ji Mao Cai <44921326+jinghero@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:41:56 +0800 Subject: [PATCH] feat: add 1080p and 720p resolution presets --- .github/workflows/ci.yml | 5 + README.en.md | 8 +- README.md | 8 +- internal/avd/avd.go | 92 ++++++++++++---- internal/avd/avd_test.go | 4 +- internal/avd/resolution_test.go | 144 +++++++++++++++++++++++++ internal/avd/testdata/instances_ref.py | 17 +-- internal/cli/args_test.go | 11 +- internal/cli/commands.go | 21 ++-- internal/cli/format.go | 4 +- internal/cli/run_test.go | 6 +- internal/emu/crud.go | 8 +- internal/emu/launch.go | 8 +- internal/emu/ops_root_test.go | 8 +- internal/emu/status.go | 2 +- internal/emu/types.go | 11 +- internal/mcp/mcp_test.go | 10 +- internal/mcp/tools.go | 18 +++- internal/ui/API.md | 10 +- internal/ui/device.go | 26 +++-- internal/ui/device_test.go | 41 +++++++ internal/ui/handlers.go | 10 +- internal/ui/http_test.go | 10 +- internal/ui/web/dev/mock.js | 11 +- internal/ui/web/js/display.js | 10 ++ internal/ui/web/js/forms.js | 9 +- internal/ui/web/js/viewer.js | 15 ++- scripts/test_display_tap.mjs | 31 ++++++ scripts/test_resolution_guards.py | 70 ++++++++++++ scripts/test_resolution_guest.py | 94 ++++++++++++++++ 30 files changed, 616 insertions(+), 106 deletions(-) create mode 100644 internal/avd/resolution_test.go create mode 100644 internal/ui/web/js/display.js create mode 100644 scripts/test_display_tap.mjs create mode 100644 scripts/test_resolution_guards.py create mode 100644 scripts/test_resolution_guest.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96d93e9..1adea78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,11 @@ jobs: with: go-version: ${{ steps.go.outputs.version }} cache: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + - name: display coordinate tests + run: node --test scripts/test_display_tap.mjs - name: gofmt shell: pwsh run: | diff --git a/README.en.md b/README.en.md index 1458cad..dfa8e7f 100644 --- a/README.en.md +++ b/README.en.md @@ -62,8 +62,8 @@ modavd [--root ] [--json] [--quiet] [--verbose] [args] | --- | --- | | `list` | All instances and their status | | `info ` / `status ` | Details of one instance / its status | -| `create [--index N] [--title T] [--memory 6G] [--cpu N] [--data-gb N] [--fps 30\|60] [--no-bridge] [--timeout 1200]`, `create --resume N` | Create an instance, then start it once in the background, headless, to finish initialization, and shut it down (see below). The arm64 execution bridge is included by default; `--no-bridge` records that this instance does not want the bridge, so it is not installed on later starts either. `--resume N` redoes the initialization of an instance whose creation failed | -| `set [--title T] [--memory M] [--cpu N] [--fps F] [--data-gb N]` | Change settings; they take effect at the next start. `--data-gb` can only be increased, and game data is untouched: the emulator (from 0.36) grows the disk image at start and grows the file system online after boot. A disk created before 0.36 at 16 GB or smaller can only be grown to just below the next backup group (a 16 GB disk to about 30 GB) | +| `create [--index N] [--title T] [--memory 6G] [--cpu N] [--data-gb N] [--fps 30\|60] [--resolution 1080\|720] [--no-bridge] [--timeout 1200]`, `create --resume N` | Create an instance, then start it once in the background, headless, to finish initialization, and shut it down (see below). The arm64 execution bridge is included by default; `--no-bridge` records that this instance does not want the bridge, so it is not installed on later starts either. `--resume N` redoes the initialization of an instance whose creation failed | +| `set [--title T] [--memory M] [--cpu N] [--fps F] [--resolution 1080\|720] [--data-gb N]` | Change settings; they take effect at the next start. `--data-gb` can only be increased, and game data is untouched: the emulator (from 0.36) grows the disk image at start and grows the file system online after boot. A disk created before 0.36 at 16 GB or smaller can only be grown to just below the next backup group (a 16 GB disk to about 30 GB) | | `proxy [on\|off\|test] [--server URL\|auto] [--add PKG] [--remove PKG] [--apps PKG]` | Per-app proxy: the listed apps go through a proxy, the others direct (see below) | | `delete --yes` | Delete a stopped instance, together with its game data | | `start [--window \| --headless] [--timeout 300] [--no-bridge]` | Start (headless by default, with the window for `--window`), wait until boot finishes and the post-boot setup is done, and before returning confirm that files can be written to `/sdcard` (normally it is ready long before this; it waits up to 60 more seconds and only warns at the deadline). If the instance is shutting down (for example, right after a click on the emulator window's ×), it first waits for the shutdown to finish (up to 30 seconds) and then starts it. If the instance has no execution bridge yet, install it afterward; `--no-bridge` skips that this time. No switch while it runs: if the instance is already running and `--window` / `--headless` asks for the other way, it fails (exit code 5) without taking it over or restarting it; to switch, `stop` and then `start`. With neither, it is taken over as it runs | @@ -148,6 +148,10 @@ modavd [--root ] [--json] [--quiet] [--verbose] [args] - The number of loops never exceeds what the host has left: logical processors minus the vCPUs of every qemu (this instance's included; counted within its group when pinned). - So on machines with fewer than twice as many logical processors as the instance has vCPUs, even a single instance gets fewer loops: a default 6-core instance gets 2 on an 8-thread machine and none on 6 threads or fewer, and downloads are slower than with all loops (measured on a 7950X3D, 1–2 loops do about as well as none). - This is a deliberate trade-off: it leaves processors for qemu's own threads, Windows and other programs. +Two display presets are available: 1080p is 1920×1080 / DPI 280; 720p is 1280×720 / DPI 240. New instances default to 1080p. 720p uses fewer resources. The manager forms, CLI `--resolution 1080|720` and MCP `resolution` share these presets. Existing landscape or portrait 1920×1080 instances stay at 1080p; other configurations, including missing dimensions, stay at 720p. Changing the preset updates width, height and DPI together, preserves orientation, and takes effect at the next cold boot. DPI is not a separate option. No runtime `wm size` or `wm density` is used. Update the resolution in your automation tool as well. + +AUTO-MAS currently prefers the packaged CLI, which preserves the selected preset. Old roots without a usable CLI fall back to the Python launch path, which still forces 720p. Update the packaged CLI before using 1080p there. AUTO-MAS creation and display settings still need matching two-preset support; use the ModAVD manager to set the preset meanwhile. + ## MCP ``` diff --git a/README.md b/README.md index a986b44..5c0b967 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,8 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数] | --- | --- | | `list` | 全部实例和状态 | | `info ` / `status ` | 一台实例的详细信息 / 状态 | -| `create [--index N] [--title T] [--memory 6G] [--cpu N] [--data-gb N] [--fps 30\|60] [--no-bridge] [--timeout 1200]`、`create --resume N` | 新建,并在后台无头开机一次把初始化做完再关机(见下)。默认带 arm64 执行桥;`--no-bridge` 记下这台不要桥,以后开机也不装。`--resume N` 重做新建失败的那台的初始化 | -| `set [--title T] [--memory M] [--cpu N] [--fps F] [--data-gb N]` | 修改,下次开机生效。`--data-gb` 只能改大,游戏数据不动:模拟器(0.36 起)开机时扩磁盘镜像,开机后在线扩文件系统。0.36 之前按 16 GB 及以下建的盘只能扩到下一个备份组之前(16 GB 约到 30 GB) | +| `create [--index N] [--title T] [--memory 6G] [--cpu N] [--data-gb N] [--fps 30\|60] [--resolution 1080\|720] [--no-bridge] [--timeout 1200]`、`create --resume N` | 新建,并在后台无头开机一次把初始化做完再关机(见下)。默认带 arm64 执行桥;`--no-bridge` 记下这台不要桥,以后开机也不装。`--resume N` 重做新建失败的那台的初始化 | +| `set [--title T] [--memory M] [--cpu N] [--fps F] [--resolution 1080\|720] [--data-gb N]` | 修改,下次开机生效。`--data-gb` 只能改大,游戏数据不动:模拟器(0.36 起)开机时扩磁盘镜像,开机后在线扩文件系统。0.36 之前按 16 GB 及以下建的盘只能扩到下一个备份组之前(16 GB 约到 30 GB) | | `proxy [on\|off\|test] [--server URL\|auto] [--add 包名] [--remove 包名] [--apps 包名]` | 按应用走代理:名单里的应用走代理,其余直连(见下) | | `delete --yes` | 删除已关机的实例,连同游戏数据 | | `start [--window \| --headless] [--timeout 300] [--no-bridge]` | 开机(默认无头,`--window` 带窗口),等开完并做完开机后的设置,返回前确认 `/sdcard` 能写进文件(一般这时早已就绪;最多再等 60 秒,到点只警告);实例正在关机时(比如刚点了模拟器窗口的 ×)先等它关完(最多 30 秒)再开机;实例还没有执行桥时接着装上,`--no-bridge` 这次不装。运行中不换方式:实例已经在运行时,给了 `--window` / `--headless` 而它不是这种方式,就报错(退出码 5),不接管也不重启,要换先 `stop` 再 `start`;两个都不给就照原样接管 | @@ -151,6 +151,10 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数] - 循环数不超过宿主的余量:逻辑核数减去所有 qemu 的 vCPU 数(含本实例;绑了核就按那一组算)。 - 所以逻辑核数不到本实例 vCPU 两倍的电脑上,只开一台也会少开:默认 6 核的实例在 8 线程的电脑上只开 2 个,6 线程及以下不开,下载会比开满时慢(在 7950X3D 上实测,1–2 个循环和不开差不多)。 - 这是有意的取舍:给 qemu 自己的线程、Windows 和别的程序留出核。 +分辨率只有两档:1080p 为 1920×1080 / DPI 280,720p 为 1280×720 / DPI 240。新建默认 1080p,720p 更省资源。管理器的新建和实例设置、CLI `--resolution 1080|720`、MCP `resolution` 使用同一套档位。已有实例按宽高识别:横屏或竖屏的 1920×1080 保持 1080p,其余配置及缺字段的旧配置保持 720p。换档联动宽、高、DPI,保留方向,下次冷启动生效;DPI 不单独设置,不使用运行时 `wm size` 或 `wm density`。自动化工具里的分辨率也要跟着改。 + +AUTO-MAS 当前优先调用包内 CLI,这条启动路径保持所选档位。没有可用 CLI 的旧根目录会回退到固定 720p 的 Python 启动路径,使用 1080p 前须更新包内 CLI。AUTO-MAS 的旧新建和显示配置表单仍需配套支持两档;可先在 ModAVD 管理器里设置。 + ## MCP ``` diff --git a/internal/avd/avd.go b/internal/avd/avd.go index 5eee38b..a10f095 100644 --- a/internal/avd/avd.go +++ b/internal/avd/avd.go @@ -2,7 +2,7 @@ // mas-avd.json. File operations only; nothing here looks at processes or ports (callers pass a busy-port check). // // Port of AUTO-MAS app/utils/common/emulator2/avd/instances.py (+ the choices in constants.py). Files must come out -// byte-identical to what AUTO-MAS writes, so the two can manage the same root during the move to modavd. +// compatible with AUTO-MAS, except for the two display presets added in ModAVD#100. package avd import ( @@ -24,9 +24,10 @@ import ( // Instance options (user decisions: memory and cores only in these steps, no silent rounding). var ( - MemoryChoicesMB = []int{3072, 4096, 5120, 6144} - CPUChoices = []int{2, 4, 6} - FPSChoices = []int{30, 60} + MemoryChoicesMB = []int{3072, 4096, 5120, 6144} + CPUChoices = []int{2, 4, 6} + FPSChoices = []int{30, 60} + ResolutionChoices = []int{1080, 720} ) const ( @@ -36,9 +37,9 @@ const ( DefaultDataPartitionGB = 64 MinDataPartitionGB = 16 MaxDataPartitionGB = 512 - // Display is fixed at 720p (user 10-04), written into config.ini before every boot, never `wm size`. - ScreenWidth, ScreenHeight, ScreenDensity = 1280, 720, 240 - TargetAPI = "android-34" + // New instances default to 1080p. Existing instances are identified by their dimensions. + DefaultResolution = 1080 + TargetAPI = "android-34" // image.sysdir.1, relative to the SDK root, with the trailing backslash AUTO-MAS writes. SystemImageSysdir = `system-images\android-34\google_apis\x86_64\` ) @@ -531,6 +532,18 @@ func ValidateFPS(fps *int) (int, error) { return value, nil } +// ValidateResolution defaults new instances to 1080p; DPI is part of the preset. +func ValidateResolution(resolution *int) (int, error) { + value := DefaultResolution + if resolution != nil { + value = *resolution + } + if !slices.Contains(ResolutionChoices, value) { + return 0, errs.New(errs.InvalidArgument, "分辨率只能选 1080 或 720") + } + return value, nil +} + // isLineBreak is every character str.splitlines() splits at: a title holding one would break config.ini apart. func isLineBreak(r rune) bool { switch r { @@ -556,6 +569,7 @@ type CreateOptions struct { CPU int DataPartitionGB int FPS int + Resolution int // 0 = DefaultResolution // Creating also sets instances..creationState = "creating" (emu's create setup), in the same write as the other // keys: the instance is never listed without it. Creating bool @@ -585,6 +599,15 @@ func Create(r *root.Root, i int, opt CreateOptions) (*Instance, error) { if err != nil { return nil, err } + var resolutionArg *int + if opt.Resolution != 0 { + resolutionArg = &opt.Resolution + } + resolution, err := ValidateResolution(resolutionArg) + if err != nil { + return nil, err + } + display := displayChanges(nil, resolution) if err := validateTitle(opt.Title); err != nil { return nil, err } @@ -612,9 +635,9 @@ func Create(r *root.Root, i int, opt CreateOptions) (*Instance, error) { {"disk.dataPartition.size", strconv.Itoa(dataGB) + "G"}, {"hw.cpu.ncore", strconv.Itoa(cpu)}, {"hw.ramSize", strconv.Itoa(memory) + "M"}, - {"hw.lcd.width", strconv.Itoa(ScreenWidth)}, - {"hw.lcd.height", strconv.Itoa(ScreenHeight)}, - {"hw.lcd.density", strconv.Itoa(ScreenDensity)}, + {"hw.lcd.width", display[0][1]}, + {"hw.lcd.height", display[1][1]}, + {"hw.lcd.density", display[2][1]}, {"hw.lcd.vsync", strconv.Itoa(fps)}, {"image.sysdir.1", SystemImageSysdir}, } { @@ -703,10 +726,11 @@ func FreeIndex(plan root.PortPlan, existing map[int]bool, busy func(ports []int) } type UpdateOptions struct { - Title *string - MemoryMB *int - CPU *int - FPS *int + Title *string + MemoryMB *int + CPU *int + FPS *int + Resolution *int // DataPartitionGB raises disk.dataPartition.size (modavd only; AUTO-MAS cannot change it): within // MinDataPartitionGB..MaxDataPartitionGB and never below the current value. The emulator (sdk-mas36 on) grows the // disk image at the next boot without touching the data, and the boot's after-boot step grows the file system. @@ -750,6 +774,12 @@ func Update(r *root.Root, i int, opt UpdateOptions) (map[string]any, error) { return nil, err } } + var resolution int + if opt.Resolution != nil { + if resolution, err = ValidateResolution(opt.Resolution); err != nil { + return nil, err + } + } memoryArg := opt.MemoryMB if memoryArg == nil { current := MemoryFor(r, i) @@ -786,6 +816,9 @@ func Update(r *root.Root, i int, opt UpdateOptions) (map[string]any, error) { if opt.FPS != nil { changes = append(changes, [2]string{"hw.lcd.vsync", strconv.Itoa(fps)}) } + if opt.Resolution != nil { + changes = append(changes, displayChanges(in.Config, resolution)...) + } if opt.DataPartitionGB != nil { changes = append(changes, [2]string{"disk.dataPartition.size", strconv.Itoa(data) + "G"}) } @@ -817,6 +850,9 @@ func Update(r *root.Root, i int, opt UpdateOptions) (map[string]any, error) { if opt.FPS != nil { applied["fps"] = fps } + if opt.Resolution != nil { + applied["resolution"] = resolution + } if opt.DataPartitionGB != nil { applied["data_partition_gb"] = data } @@ -900,19 +936,35 @@ func deleteInstanceFiles(r *root.Root, i int) error { // ---- display ----------------------------------------------------------------------------------------------------- -// DisplayChanges is the fixed 720p for cfg keeping its orientation (portrait when height > width), plus -// hw.mainKeys=yes: hw.lcd.width, hw.lcd.height, hw.lcd.density, hw.mainKeys in that order. -func DisplayChanges(cfg *Ini) [][2]string { +// Resolution identifies 1080p in either orientation. All other old configurations stay 720p. +func (in *Instance) Resolution() int { return resolutionFor(in.Config) } + +func resolutionFor(cfg *Ini) int { w, _ := intOrNone(cfg.Value("hw.lcd.width")) h, _ := intOrNone(cfg.Value("hw.lcd.height")) - width, height := ScreenWidth, ScreenHeight + if (w == 1920 && h == 1080) || (w == 1080 && h == 1920) { + return 1080 + } + return 720 +} + +// DisplayChanges normalizes the current preset's dimensions and DPI, preserving orientation. +func DisplayChanges(cfg *Ini) [][2]string { return displayChanges(cfg, resolutionFor(cfg)) } + +func displayChanges(cfg *Ini, resolution int) [][2]string { + w, _ := intOrNone(cfg.Value("hw.lcd.width")) + h, _ := intOrNone(cfg.Value("hw.lcd.height")) + width, height, density := 1280, 720, 240 + if resolution == 1080 { + width, height, density = 1920, 1080, 280 + } if h > w { - width, height = ScreenHeight, ScreenWidth + width, height = height, width } return [][2]string{ {"hw.lcd.width", strconv.Itoa(width)}, {"hw.lcd.height", strconv.Itoa(height)}, - {"hw.lcd.density", strconv.Itoa(ScreenDensity)}, + {"hw.lcd.density", strconv.Itoa(density)}, {"hw.mainKeys", "yes"}, } } diff --git a/internal/avd/avd_test.go b/internal/avd/avd_test.go index bc3225d..c39df45 100644 --- a/internal/avd/avd_test.go +++ b/internal/avd/avd_test.go @@ -658,7 +658,7 @@ func TestListAndGet(t *testing.T) { if d, ok := in.DataPartitionGB(); !ok || d != 64 { t.Errorf("DataPartitionGB %d", d) } - if w, h, dpi := in.Display(); in.FPS() != 60 || w != 1280 || h != 720 || dpi != 240 { + if w, h, dpi := in.Display(); in.FPS() != 60 || w != 1920 || h != 1080 || dpi != 280 { t.Errorf("FPS / Display %d %d %d %d", in.FPS(), w, h, dpi) } // An instance whose config.ini cannot be read (opened elsewhere without sharing) is listed with an empty config @@ -1066,7 +1066,7 @@ func TestApplyDisplay(t *testing.T) { if err != nil || !wrote { t.Fatalf("first: %v %v", wrote, err) } - want := "hw.lcd.width=720\r\nhw.lcd.height=1280\r\nhw.lcd.density=240\r\nx=1\r\nhw.mainKeys=yes\r\n" + want := "hw.lcd.width=1080\r\nhw.lcd.height=1920\r\nhw.lcd.density=280\r\nx=1\r\nhw.mainKeys=yes\r\n" if got := string(readFile(t, ConfigPath(r, 0))); got != want { t.Fatalf("got %q", got) } diff --git a/internal/avd/resolution_test.go b/internal/avd/resolution_test.go new file mode 100644 index 0000000..8eccbbb --- /dev/null +++ b/internal/avd/resolution_test.go @@ -0,0 +1,144 @@ +//go:build windows + +package avd + +import ( + "fmt" + "os" + "testing" + + "modavd/internal/errs" +) + +func assertDisplay(t *testing.T, in *Instance, want [3]int) { + t.Helper() + w, h, dpi := in.Display() + if got := [3]int{w, h, dpi}; got != want { + t.Fatalf("display = %v, want %v", got, want) + } + for n, key := range []string{"hw.lcd.width", "hw.lcd.height", "hw.lcd.density"} { + if in.Config.Value(key) != fmt.Sprint(want[n]) { + t.Fatalf("stored %s = %q, want %d", key, in.Config.Value(key), want[n]) + } + } +} + +func TestResolutionCreateSwitchAndReboot(t *testing.T) { + for _, preset := range []int{0, 1080, 720} { + for _, portrait := range []bool{false, true} { + t.Run(fmt.Sprintf("%d/portrait=%t", preset, portrait), func(t *testing.T) { + r := newRoot(t) + in, err := Create(r, 0, CreateOptions{MemoryMB: 6144, CPU: 6, DataPartitionGB: 64, FPS: 60, Resolution: preset}) + if err != nil { + t.Fatal(err) + } + initial := preset + if initial == 0 { + initial = 1080 + } + if portrait { + w, h, _ := in.Display() + in.Config.Set("hw.lcd.width", fmt.Sprint(h)) + in.Config.Set("hw.lcd.height", fmt.Sprint(w)) + if err := WriteIni(ConfigPath(r, 0), in.Config); err != nil { + t.Fatal(err) + } + } + other := 720 + if initial == 720 { + other = 1080 + } + for step, resolution := range []int{initial, other, initial} { + if step > 0 { + applied, err := Update(r, 0, UpdateOptions{Resolution: &resolution}) + if err != nil || applied["resolution"] != resolution { + t.Fatalf("update: %v %v", applied, err) + } + } + want := [3]int{1280, 720, 240} + if resolution == 1080 { + want = [3]int{1920, 1080, 280} + } + if portrait { + want[0], want[1] = want[1], want[0] + } + // Re-read from disk across repeated pre-boot writes, as independent starts do. + for boot := 0; boot < 3; boot++ { + if _, err := ApplyDisplay(r, 0); err != nil { + t.Fatal(err) + } + in, err = Get(r, 0) + if err != nil { + t.Fatal(err) + } + if in.Resolution() != resolution { + t.Fatalf("resolution = %d", in.Resolution()) + } + assertDisplay(t, in, want) + } + } + }) + } + } +} + +func TestResolutionLegacy(t *testing.T) { + for _, tc := range []struct { + name, cfg string + want [3]int + }{ + {"missing", "x=1\n", [3]int{1280, 720, 240}}, + {"missing width", "hw.lcd.height=720\n", [3]int{720, 1280, 240}}, + {"missing height", "hw.lcd.width=1920\n", [3]int{1280, 720, 240}}, + {"bad width", "hw.lcd.width=bad\nhw.lcd.height=1\n", [3]int{720, 1280, 240}}, + {"720 no dpi", "hw.lcd.width=1280\nhw.lcd.height=720\n", [3]int{1280, 720, 240}}, + {"1080 wrong dpi", "hw.lcd.width=1920\nhw.lcd.height=1080\nhw.lcd.density=240\n", [3]int{1920, 1080, 280}}, + {"portrait 1080", "hw.lcd.width=1080\nhw.lcd.height=1920\n", [3]int{1080, 1920, 280}}, + {"custom", "hw.lcd.width=1440\nhw.lcd.height=2560\nhw.lcd.density=400\n", [3]int{720, 1280, 240}}, + } { + t.Run(tc.name, func(t *testing.T) { + r := newRoot(t) + writeFile(t, ConfigPath(r, 0), []byte(tc.cfg)) + if _, err := ApplyDisplay(r, 0); err != nil { + t.Fatal(err) + } + cfg, err := ReadIni(ConfigPath(r, 0)) + if err != nil { + t.Fatal(err) + } + assertDisplay(t, &Instance{Config: cfg}, tc.want) + if wrote, err := ApplyDisplay(r, 0); err != nil || wrote { + t.Fatalf("repeat: %t %v", wrote, err) + } + }) + } +} + +func TestResolutionValidationBeforeWrite(t *testing.T) { + if got, err := ValidateResolution(nil); err != nil || got != 1080 { + t.Fatalf("default: %d %v", got, err) + } + r := newRoot(t) + in, err := Create(r, 0, CreateOptions{MemoryMB: 6144, CPU: 6, DataPartitionGB: 64, FPS: 60}) + if err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(ConfigPath(r, 0)) + for _, value := range []int{0, -1, 1440, 1920} { + _, err := ValidateResolution(&value) + wantCode(t, err, errs.InvalidArgument) + _, err = Update(r, 0, UpdateOptions{Resolution: &value}) + wantCode(t, err, errs.InvalidArgument) + after, _ := os.ReadFile(ConfigPath(r, 0)) + if string(before) != string(after) { + t.Fatal("invalid update changed config") + } + } + bad := 1440 + _, err = Create(r, 1, CreateOptions{MemoryMB: 6144, CPU: 6, DataPartitionGB: 64, FPS: 60, Resolution: bad}) + wantCode(t, err, errs.InvalidArgument) + if _, err := os.Stat(Dir(r, 1)); !os.IsNotExist(err) { + t.Fatal("invalid create left files") + } + assertDisplay(t, in, [3]int{1920, 1080, 280}) +} diff --git a/internal/avd/testdata/instances_ref.py b/internal/avd/testdata/instances_ref.py index e911e1c..adcebfc 100644 --- a/internal/avd/testdata/instances_ref.py +++ b/internal/avd/testdata/instances_ref.py @@ -2,8 +2,8 @@ # app/utils/common/emulator2/avd/instances.py (and the parts of constants.py, components.py and manager.py it uses) # so it runs without the app package. Keep it a copy: when AUTO-MAS changes, copy again. # -# The only change: create_instance_files takes created_at instead of calling datetime.now(), so the output can be -# compared byte for byte. +# Intentional differences: created_at is injected for byte comparisons. ModAVD#100 +# defaults new instances to 1080p and preserves either preset on existing instances. # # python -I instances_ref.py # @@ -35,7 +35,7 @@ DEFAULT_CPU = 6 DEFAULT_DATA_PARTITION_GB = 64 DATA_PARTITION_RANGE_GB = (16, 512) -SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_DENSITY = 1280, 720, 240 +SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_DENSITY = 1920, 1080, 280 FPS_CHOICES = (30, 60) DEFAULT_FPS = 60 @@ -283,14 +283,17 @@ def remove_instance_meta(root, native_index): def display_changes(config): width = _int_or_none(config.get("hw.lcd.width")) or 0 height = _int_or_none(config.get("hw.lcd.height")) or 0 - if height > width: - width, height = SCREEN_HEIGHT, SCREEN_WIDTH + portrait = height > width + if sorted((width, height)) == [1080, 1920]: + width, height, density = 1920, 1080, 280 else: - width, height = SCREEN_WIDTH, SCREEN_HEIGHT + width, height, density = 1280, 720, 240 + if portrait: + width, height = height, width return { "hw.lcd.width": str(width), "hw.lcd.height": str(height), - "hw.lcd.density": str(SCREEN_DENSITY), + "hw.lcd.density": str(density), "hw.mainKeys": "yes", } diff --git a/internal/cli/args_test.go b/internal/cli/args_test.go index 1db35e4..11d5277 100644 --- a/internal/cli/args_test.go +++ b/internal/cli/args_test.go @@ -253,17 +253,17 @@ func TestBuildCreateRequest(t *testing.T) { if err != nil { t.Fatal(err) } - if req.Index != nil || req.MemoryMB != nil || req.CPU != nil || req.DataPartitionGB != nil || req.FPS != nil || req.Title != "" { + if req.Index != nil || req.MemoryMB != nil || req.CPU != nil || req.DataPartitionGB != nil || req.FPS != nil || req.Resolution != nil || req.Title != "" { t.Errorf("defaults: %+v, want all nil", req) } req, err = buildCreateRequest(mustParse(t, "create", "--index", "mas_4", "--title", "星铁", "--memory", "6G", - "--cpu", "4", "--data-gb", "128", "--fps", "30")) + "--cpu", "4", "--data-gb", "128", "--fps", "30", "--resolution", "720")) if err != nil { t.Fatal(err) } if req.Index == nil || *req.Index != 4 || req.Title != "星铁" || req.MemoryMB == nil || *req.MemoryMB != 6144 || req.CPU == nil || *req.CPU != 4 || req.DataPartitionGB == nil || *req.DataPartitionGB != 128 || - req.FPS == nil || *req.FPS != 30 { + req.FPS == nil || *req.FPS != 30 || req.Resolution == nil || *req.Resolution != 720 { t.Errorf("all options: %+v", req) } // Zero is passed on (emu rejects it with the AUTO-MAS wording), not mistaken for "not given". @@ -327,6 +327,11 @@ func TestBuildUpdateRequest(t *testing.T) { if err != nil || req.DataPartitionGB == nil || *req.DataPartitionGB != 96 || req.MemoryMB != nil { t.Errorf("data-gb alone: %+v %v", req, err) } + req, err = buildUpdateRequest(mustParse(t, "set", "3", "--resolution", "1080")) + if err != nil || req.Resolution == nil || *req.Resolution != 1080 || req.MemoryMB != nil { + t.Fatalf("resolution alone: %+v %v", req, err) + } + } func TestBuildStartOptions(t *testing.T) { diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 3f55353..18b3cb3 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -62,6 +62,7 @@ var ( optMemory = flagSpec{"memory", kindString, "M", "内存:3072 / 4096 / 5120 / 6144 MB,也可写 6G"} optCPU = flagSpec{"cpu", kindInt, "N", "核数:2 / 4 / 6"} optDataGB = flagSpec{"data-gb", kindInt, "N", "数据盘大小(GB):16–512"} + optResolution = flagSpec{"resolution", kindInt, "", "分辨率:1080 / 720,DPI 随档位切换;新建默认 1080"} optFPS = flagSpec{"fps", kindInt, "F", "帧率:30 / 60"} optYes = flagSpec{"yes", kindBool, "", "确认删除(连同游戏数据)"} optWindow = flagSpec{"window", kindBool, "", "带窗口开机(默认无头);实例已经无头运行时报错,不接管"} @@ -125,24 +126,24 @@ func init() { run: runStatus, human: humanOf(formatStatus), }, { - name: "create", args: "[--index N] [--title T] [--memory 6G] [--cpu N] [--data-gb N] [--fps 30|60] [--no-bridge] [--timeout 1200] | --resume N", + name: "create", args: "[--index N] [--title T] [--memory 6G] [--cpu N] [--data-gb N] [--fps 30|60] [--resolution 1080|720] [--no-bridge] [--timeout 1200] | --resume N", summary: "新建一台实例,后台开机做完初始化后关机", - details: "不给的选项用默认值:内存 6 GB、6 核、数据盘 64 GB、60 帧。内存和核数只能取列出的档位,不会自动凑整。" + + details: "不给的选项用默认值:内存 6 GB、6 核、数据盘 64 GB、60 帧、1080p(1920×1080 / DPI 280)。内存和核数只能取列出的档位,不会自动凑整。" + "建好文件后在后台无头开机一次,做完开机设置、宿主输入法、首次初始化和 arm64 执行桥(只对内置名单里的游戏生效," + "其余应用照旧;--no-bridge 不装,以后也不装),再先 sync 后正常关机,一般要几分钟,进度逐步打印(「新建中 35%」,按做完的步骤算);" + "之后第一次开机和平常一样快。" + "这期间实例的状态是 creating(status、list 里 creation_progress 是百分比),不能开机、修改或删除;失败(或被打断)是 create_failed," + "用 create --resume N 重做初始化,或者 delete N --yes 删掉。", - flags: []flagSpec{optIndex, optTitle, optMemory, optCPU, optDataGB, optFPS, optNoBridge, optCreateTimeout, optResume}, + flags: []flagSpec{optIndex, optTitle, optMemory, optCPU, optDataGB, optFPS, optResolution, optNoBridge, optCreateTimeout, optResume}, final: true, run: runCreate, human: humanOf(formatCreate), }, { - name: "set", args: " [--title T] [--memory M] [--cpu N] [--fps F] [--data-gb N]", - summary: "修改实例的标题、内存、核数、帧率、数据盘大小", + name: "set", args: " [--title T] [--memory M] [--cpu N] [--fps F] [--resolution 1080|720] [--data-gb N]", + summary: "修改实例的标题、内存、核数、帧率、分辨率、数据盘大小", details: "至少给一个选项。改动写进实例配置,下次开机生效。" + "--data-gb 只能改大(16–512),游戏数据不动:下次开机时模拟器(0.36 起)扩大磁盘镜像,开机后再在线扩大文件系统。", - flags: []flagSpec{optTitle, optMemory, optCPU, optFPS, optDataGB}, + flags: []flagSpec{optTitle, optMemory, optCPU, optFPS, optResolution, optDataGB}, posNames: []string{"实例编号"}, minPos: 1, maxPos: 1, final: true, run: runSet, human: humanOf(formatUpdate), @@ -455,6 +456,7 @@ func buildCreateRequest(inv *invocation) (emu.CreateRequest, error) { req.CPU = intPtrOpt(inv, "cpu") req.DataPartitionGB = intPtrOpt(inv, "data-gb") req.FPS = intPtrOpt(inv, "fps") + req.Resolution = intPtrOpt(inv, "resolution") req.NoBridge = inv.boolOpt("no-bridge") if inv.isSet("timeout") { n := inv.intOpt("timeout") @@ -464,7 +466,7 @@ func buildCreateRequest(inv *invocation) (emu.CreateRequest, error) { req.Timeout = time.Duration(n) * time.Second } if inv.isSet("resume") { - for _, o := range []string{"index", "title", "memory", "cpu", "data-gb", "fps", "no-bridge"} { + for _, o := range []string{"index", "title", "memory", "cpu", "data-gb", "fps", "resolution", "no-bridge"} { if inv.isSet(o) { return req, errs.Newf(errs.Usage, "--resume 只重做初始化,不能再给 --%s", o) } @@ -494,9 +496,10 @@ func buildUpdateRequest(inv *invocation) (emu.UpdateRequest, error) { } req.CPU = intPtrOpt(inv, "cpu") req.FPS = intPtrOpt(inv, "fps") + req.Resolution = intPtrOpt(inv, "resolution") req.DataPartitionGB = intPtrOpt(inv, "data-gb") - if req.Title == nil && req.MemoryMB == nil && req.CPU == nil && req.FPS == nil && req.DataPartitionGB == nil { - return req, errs.New(errs.Usage, "没有要改的内容:至少给 --title、--memory、--cpu、--fps、--data-gb 中的一个"). + if req.Title == nil && req.MemoryMB == nil && req.CPU == nil && req.FPS == nil && req.Resolution == nil && req.DataPartitionGB == nil { + return req, errs.New(errs.Usage, "没有要改的内容:至少给 --title、--memory、--cpu、--fps、--resolution、--data-gb 中的一个"). WithAdvice(findCommand("set").usageAdvice()) } return req, nil diff --git a/internal/cli/format.go b/internal/cli/format.go index 3374052..f3fa8bd 100644 --- a/internal/cli/format.go +++ b/internal/cli/format.go @@ -294,7 +294,7 @@ func formatCreate(w io.Writer, in *emu.InstanceInfo) { } // appliedOrder is the order changes are listed in; unknown keys follow alphabetically. -var appliedOrder = []string{"title", "memory_mb", "cpu", "fps", "data_partition_gb"} +var appliedOrder = []string{"title", "memory_mb", "cpu", "fps", "resolution", "data_partition_gb"} func appliedParts(applied map[string]any) []string { keys := make([]string, 0, len(applied)) @@ -332,6 +332,8 @@ func appliedParts(applied map[string]any) []string { parts = append(parts, fmt.Sprintf("核数 %v", v)) case "fps": parts = append(parts, fmt.Sprintf("帧率 %v", v)) + case "resolution": + parts = append(parts, fmt.Sprintf("分辨率 %vp", v)) case "data_partition_gb": parts = append(parts, fmt.Sprintf("数据盘 %v GB(下次开机扩大)", v)) default: diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index ffd92c4..34c7bb4 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -977,9 +977,9 @@ func TestCreateFlags(t *testing.T) { in := sampleInstance(1) return &in, nil } - r := h.run(t, nil, "create", "--index", "6", "--timeout", "600", "--no-bridge") + r := h.run(t, nil, "create", "--index", "6", "--timeout", "600", "--no-bridge", "--resolution", "720") if r.code != 0 || len(got) != 1 || *got[0].Index != 6 || got[0].Timeout != 600*time.Second || !got[0].NoBridge || - got[0].Resume || !strings.Contains(r.stderr, "新建中 2%:后台无头开机") || + got[0].Resolution == nil || *got[0].Resolution != 720 || got[0].Resume || !strings.Contains(r.stderr, "新建中 2%:后台无头开机") || !strings.Contains(r.stderr, "新建中 35%:实例 6 宿主输入法") || strings.Contains(r.stderr, "新建中 100%") || !strings.Contains(r.stderr, "实例 6 新建完成") { t.Fatalf("create: exit %d, %+v, stderr %s", r.code, got, r.stderr) @@ -989,7 +989,7 @@ func TestCreateFlags(t *testing.T) { t.Fatalf("resume: exit %d, %+v", r.code, got) } for _, bad := range [][]string{{"create", "--resume", "6", "--memory", "4G"}, {"create", "--resume", "6", "--index", "6"}, - {"create", "--timeout", "0"}, {"create", "--resume", "5"}} { + {"create", "--resume", "6", "--resolution", "1080"}, {"create", "--timeout", "0"}, {"create", "--resume", "5"}} { if r := h.run(t, nil, bad...); r.code != 2 { t.Errorf("%v: exit %d, want 2 (%s)", bad, r.code, r.stderr) } diff --git a/internal/emu/crud.go b/internal/emu/crud.go index df4f94a..4eb7616 100644 --- a/internal/emu/crud.go +++ b/internal/emu/crud.go @@ -34,6 +34,10 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*InstanceInfo, if err != nil { return nil, err } + resolution, err := avd.ValidateResolution(req.Resolution) + if err != nil { + return nil, err + } // A root used by modavd alone has no mas-avd.json yet (AUTO-MAS writes it when the root is added). if _, err := m.Root.EnsureMetadata(); err != nil { return nil, err @@ -81,7 +85,7 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*InstanceInfo, return nil, err } in, err := avd.Create(m.Root, i, avd.CreateOptions{ - Title: req.Title, MemoryMB: memory, CPU: cpu, DataPartitionGB: dataGB, FPS: fps, Creating: !req.filesOnly, + Title: req.Title, MemoryMB: memory, CPU: cpu, DataPartitionGB: dataGB, FPS: fps, Resolution: resolution, Creating: !req.filesOnly, }) if err != nil { lock.Unlock() @@ -209,7 +213,7 @@ func (m *Manager) Update(ctx context.Context, i int, req UpdateRequest) (*Update return nil, err } applied, err := avd.Update(m.Root, i, avd.UpdateOptions{ - Title: req.Title, MemoryMB: req.MemoryMB, CPU: req.CPU, FPS: req.FPS, DataPartitionGB: req.DataPartitionGB, + Title: req.Title, MemoryMB: req.MemoryMB, CPU: req.CPU, FPS: req.FPS, Resolution: req.Resolution, DataPartitionGB: req.DataPartitionGB, }) if err != nil { return nil, err diff --git a/internal/emu/launch.go b/internal/emu/launch.go index 48c8cb9..fbdc7af 100644 --- a/internal/emu/launch.go +++ b/internal/emu/launch.go @@ -106,7 +106,7 @@ func (m *Manager) prepareLaunch(ctx context.Context, inst *avd.Instance, window } // launchPlanFor is the rest of prepareLaunch, once the checks passed: the instance's ports in the root's plan (free?), -// the private adb server, the fixed 720p display, old logs, the log file name, the emulator's arguments. +// the private adb server, the selected display preset, old logs, the log file name, the emulator's arguments. func (m *Manager) launchPlanFor(ctx context.Context, inst *avd.Instance, memoryMB int, window bool) (*launchPlan, error) { i := inst.Index port := m.Root.Ports().Console(i) @@ -139,7 +139,7 @@ func (m *Manager) launchPlanFor(ctx context.Context, inst *avd.Instance, memoryM return nil, errs.Wrap(errs.Internal, err, fmt.Sprintf("实例 %d 写显示设置(config.ini)失败: %v", i, err)) } if wrote { - m.Log.Infof("实例 %d 显示改回 720p、不显示导航栏(已写入 config.ini)", i) + m.Log.Infof("实例 %d 显示设为 %dp、不显示导航栏(已写入 config.ini)", i, inst.Resolution()) } headless := !window @@ -156,8 +156,8 @@ func (m *Manager) launchPlanFor(ctx context.Context, inst *avd.Instance, memoryM if n, ok := inst.CPU(); ok { cpu = strconv.Itoa(n) } - m.Log.Infof("启动魔改 AVD 实例 %d(%s,720p %d Hz,%d MB / %s 核,气球开,不带声卡,ANDROID_EMU_VK_HIDE_REBAR=1): emulator %s", - i, mode, inst.FPS(), memoryMB, cpu, strings.Join(args, " ")) + m.Log.Infof("启动魔改 AVD 实例 %d(%s,%dp %d Hz,%d MB / %s 核,气球开,不带声卡,ANDROID_EMU_VK_HIDE_REBAR=1): emulator %s", + i, mode, inst.Resolution(), inst.FPS(), memoryMB, cpu, strings.Join(args, " ")) return &launchPlan{args: args, logPath: logPath, memoryMB: memoryMB}, nil } diff --git a/internal/emu/ops_root_test.go b/internal/emu/ops_root_test.go index 2f3b763..35ddadf 100644 --- a/internal/emu/ops_root_test.go +++ b/internal/emu/ops_root_test.go @@ -61,7 +61,7 @@ func TestOpsInstanceLifecycle(t *testing.T) { if !root.ValidIndex(i) || created.Title != "测试实例" || created.Status != StatusOffline || created.Running || created.PID != 0 { t.Fatalf("created = %+v", created) } - want := InstanceConfig{MemoryMB: 6144, CPU: 6, FPS: 60, DataPartitionGB: 64, Width: 1280, Height: 720, DPI: 240} + want := InstanceConfig{MemoryMB: 6144, CPU: 6, FPS: 60, DataPartitionGB: 64, Resolution: 1080, Width: 1920, Height: 1080, DPI: 280} if created.Config != want { t.Fatalf("config = %+v, want %+v", created.Config, want) } @@ -94,12 +94,12 @@ func TestOpsInstanceLifecycle(t *testing.T) { t.Fatalf("list = %+v", list) } - mem, cpu, fps := 4096, 4, 30 - up, err := m.Update(ctx, i, UpdateRequest{MemoryMB: &mem, CPU: &cpu, FPS: &fps}) + mem, cpu, fps, resolution := 4096, 4, 30, 720 + up, err := m.Update(ctx, i, UpdateRequest{MemoryMB: &mem, CPU: &cpu, FPS: &fps, Resolution: &resolution}) if err != nil { t.Fatal(err) } - if up.AppliesNextBoot || up.Instance.Config.MemoryMB != 4096 || up.Instance.Config.CPU != 4 || up.Instance.Config.FPS != 30 { + if up.AppliesNextBoot || up.Instance.Config.MemoryMB != 4096 || up.Instance.Config.CPU != 4 || up.Instance.Config.FPS != 30 || up.Instance.Config.Resolution != 720 || up.Instance.Config.Width != 1280 || up.Instance.Config.DPI != 240 { t.Fatalf("update = %+v", up) } if len(up.Applied) == 0 { diff --git a/internal/emu/status.go b/internal/emu/status.go index 63c90eb..6412b5a 100644 --- a/internal/emu/status.go +++ b/internal/emu/status.go @@ -184,7 +184,7 @@ func (m *Manager) instanceInfo(ctx context.Context, in *avd.Instance, p *win.Pro info.Status = StatusCreateFailed } - cfg := InstanceConfig{MemoryMB: avd.MemoryFor(m.Root, i), FPS: in.FPS()} + cfg := InstanceConfig{MemoryMB: avd.MemoryFor(m.Root, i), FPS: in.FPS(), Resolution: in.Resolution()} if n, ok := in.CPU(); ok { cfg.CPU = n } diff --git a/internal/emu/types.go b/internal/emu/types.go index d82bcb8..e0f734c 100644 --- a/internal/emu/types.go +++ b/internal/emu/types.go @@ -35,6 +35,7 @@ type InstanceConfig struct { MemoryMB int `json:"memory_mb"` // what the next boot passes as -memory CPU int `json:"cpu"` FPS int `json:"fps"` + Resolution int `json:"resolution"` DataPartitionGB int `json:"data_partition_gb"` Width int `json:"width"` Height int `json:"height"` @@ -115,6 +116,7 @@ type CreateRequest struct { CPU *int DataPartitionGB *int FPS *int + Resolution *int // NoBridge: no execution bridge (bridge.go), recorded for good. By default a new instance gets the bridge's boot // marker and create's setup installs it. NoBridge bool @@ -129,10 +131,11 @@ type CreateRequest struct { } type UpdateRequest struct { - Title *string - MemoryMB *int - CPU *int - FPS *int + Title *string + MemoryMB *int + CPU *int + FPS *int + Resolution *int // DataPartitionGB raises the data partition (never lowers it); see avd.UpdateOptions. DataPartitionGB *int } diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 001b286..0b3a256 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -767,9 +767,9 @@ func TestToolCalls(t *testing.T) { {"check_host", map[string]any{"memory_mb": 4096}, "check 4096"}, {"check_host", map[string]any{}, "check 0"}, {"create_instance", map[string]any{"title": "星铁", "index": 3, "memory_mb": 4096, "cpu": 4, - "data_partition_gb": 128, "fps": 30}, "create"}, + "data_partition_gb": 128, "fps": 30, "resolution": 720}, "create"}, {"create_instance", map[string]any{}, "create"}, - {"update_instance", map[string]any{"index": 1, "memory_mb": 5120.0}, "update 1"}, + {"update_instance", map[string]any{"index": 1, "memory_mb": 5120.0, "resolution": 1080}, "update 1"}, {"delete_instance", map[string]any{"index": 2, "confirm": true}, "delete 2"}, {"start_instance", map[string]any{"index": 1, "window": true, "timeout_s": 120}, "start 1"}, {"start_instance", map[string]any{"index": 1}, "start 1"}, @@ -816,14 +816,14 @@ func TestToolCalls(t *testing.T) { f := h.f full := f.creates[0] if full.Title != "星铁" || *full.Index != 3 || *full.MemoryMB != 4096 || *full.CPU != 4 || *full.DataPartitionGB != 128 || - *full.FPS != 30 { + *full.FPS != 30 || full.Resolution == nil || *full.Resolution != 720 { t.Fatalf("create request %+v", full) } if empty := f.creates[1]; empty.Title != "" || empty.Index != nil || empty.MemoryMB != nil || empty.CPU != nil || - empty.DataPartitionGB != nil || empty.FPS != nil { + empty.DataPartitionGB != nil || empty.FPS != nil || empty.Resolution != nil { t.Fatalf("defaults must stay nil: %+v", empty) } - if u := f.updates[0]; *u.MemoryMB != 5120 || u.Title != nil || u.CPU != nil || u.FPS != nil { + if u := f.updates[0]; u.Resolution == nil || *u.Resolution != 1080 || *u.MemoryMB != 5120 || u.Title != nil || u.CPU != nil || u.FPS != nil { t.Fatalf("update request %+v", u) } if s := f.starts[0]; !s.Window || s.Headless || s.Timeout != 120*time.Second || s.Progress != nil { diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 2b69107..1f50801 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -93,6 +93,10 @@ func fpsProp(desc string) prop { return prop{"fps", false, map[string]any{"type": "integer", "enum": avd.FPSChoices, "description": desc}} } +func resolutionProp(desc string) prop { + return prop{"resolution", false, map[string]any{"type": "integer", "enum": avd.ResolutionChoices, "description": desc}} +} + func timeoutProp(def time.Duration, desc string) prop { return prop{"timeout_s", false, map[string]any{"type": "integer", "minimum": 1, "maximum": maxTimeoutS, "default": int(def / time.Second), "description": desc}} @@ -193,6 +197,7 @@ func toolDefs() []*toolDef { {"data_partition_gb", false, map[string]any{"type": "integer", "minimum": avd.MinDataPartitionGB, "maximum": avd.MaxDataPartitionGB, "description": "Size of the data partition (games) in GB."}}, fpsProp("Display refresh rate."), + resolutionProp("Display preset: 1080 = 1920×1080 / DPI 280 (default), 720 = 1280×720 / DPI 240."), }, func(ctx context.Context, b backend, a args, progress func(emu.Progress)) (any, error) { var req emu.CreateRequest @@ -218,6 +223,9 @@ func toolDefs() []*toolDef { if req.FPS, err = a.optInt("fps"); err != nil { return nil, err } + if req.Resolution, err = a.optInt("resolution"); err != nil { + return nil, err + } if progress != nil { req.Progress = func(p emu.Progress) { if p.Step != "done" { @@ -230,7 +238,7 @@ func toolDefs() []*toolDef { })), finishing(newTool("update_instance", "Update instance", - "Change the title, memory, CPU cores, frame rate or data partition size of an instance. The changes take "+ + "Change the title, memory, CPU cores, frame rate, resolution or data partition size of an instance. The changes take "+ "effect at its next boot (applies_next_boot=true when it is running now).", mutating(true, true), reflect.TypeFor[emu.UpdateResult](), []prop{ indexProp(true), @@ -238,6 +246,7 @@ func toolDefs() []*toolDef { memoryProp("New guest memory in MB."), cpuProp("New CPU core count."), fpsProp("New display refresh rate."), + resolutionProp("Display preset: 1080 = 1920×1080 / DPI 280, 720 = 1280×720 / DPI 240. Preserves orientation; next boot."), {"data_partition_gb", false, map[string]any{"type": "integer", "minimum": avd.MinDataPartitionGB, "maximum": avd.MaxDataPartitionGB, "description": "New data partition size in GB; can only be " + "raised. Game data is kept: the emulator grows the disk at the next boot and the file " + @@ -261,11 +270,14 @@ func toolDefs() []*toolDef { if req.FPS, err = a.optInt("fps"); err != nil { return nil, err } + if req.Resolution, err = a.optInt("resolution"); err != nil { + return nil, err + } if req.DataPartitionGB, err = a.optInt("data_partition_gb"); err != nil { return nil, err } - if req.Title == nil && req.MemoryMB == nil && req.CPU == nil && req.FPS == nil && req.DataPartitionGB == nil { - return nil, errs.New(errs.Usage, "没有要改的选项:title、memory_mb、cpu、fps、data_partition_gb 至少给一项") + if req.Title == nil && req.MemoryMB == nil && req.CPU == nil && req.FPS == nil && req.Resolution == nil && req.DataPartitionGB == nil { + return nil, errs.New(errs.Usage, "没有要改的选项:title、memory_mb、cpu、fps、resolution、data_partition_gb 至少给一项") } return b.Update(ctx, i, req) })), diff --git a/internal/ui/API.md b/internal/ui/API.md index cbe5891..21da6bb 100644 --- a/internal/ui/API.md +++ b/internal/ui/API.md @@ -35,8 +35,8 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…"," | GET | `/api/info` | | `{"version","protocol","root":RootInfo}` | | GET | `/api/instances` | | `ListResult` | | GET | `/api/instances/{i}` | | `InstanceInfo` | -| POST | `/api/instances` | `{title?, index?, memory_mb?, cpu?, data_partition_gb?, fps?}` | 202 `Job`(`kind` 是 `create`:后台开机做完初始化再关机,要几分钟;没给 `index` 时服务端先选好编号,`Job.index` 就是新实例的;结果是 `InstanceInfo`) | -| PATCH | `/api/instances/{i}` | `{title?, memory_mb?, cpu?, fps?}` | `UpdateResult` | +| POST | `/api/instances` | `{title?, index?, memory_mb?, cpu?, data_partition_gb?, fps?, resolution?}` | 202 `Job`(`kind` 是 `create`:后台开机做完初始化再关机,要几分钟;没给 `index` 时服务端先选好编号,`Job.index` 就是新实例的;结果是 `InstanceInfo`) | +| PATCH | `/api/instances/{i}` | `{title?, memory_mb?, cpu?, fps?, resolution?, data_partition_gb?}` | `UpdateResult` | | DELETE | `/api/instances/{i}?confirm=1` | 不带 `confirm=1` 回 400 `usage` | `DeleteResult` | | POST | `/api/instances/{i}/start` | `{}` | 202 `Job`:一律带窗口开机(管理器不开无头的,无头开机只走命令行);实例已经以无头方式在运行时,任务失败,code 是 `busy`(运行中不切换) | | POST | `/api/instances/{i}/stop` | `{force?: bool}` | 202 `Job` | @@ -45,7 +45,7 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…"," | POST | `/api/instances/{i}/disk-trim` | `{}` | 202 `Job`(`kind` 是 `disk-trim`):让开着的安卓把删掉的文件占的空间还给模拟器(电脑上的文件要整理后才变小);实例没开机完成、或模拟器版本不支持时任务失败,code 是 `unsupported`;结果是 `DiskTrimResult` | | POST | `/api/instances/{i}/disk-compact` | `{}` | 202 `Job`(`kind` 是 `disk-compact`):重写已关机实例的数据盘文件,只留在用的部分,要几分钟;实例没关机时任务失败,code 是 `busy`;结果是 `DiskCompactResult` | | POST | `/api/instances/{i}/resume` | `{}` | 202 `Job`(`kind` 是 `create`):重做新建失败的那台的初始化 | -| POST | `/api/instances/{i}/input` | `{type:"tap", x, y}`(坐标是 1280×720 显示的像素)或 `{type:"key", key:"back"\|"home"\|"recents"}` | `{}` | +| POST | `/api/instances/{i}/input` | `{type:"tap", x, y}`(坐标是这台实例显示的像素(1280×720 或 1920×1080,竖屏时宽高对调))或 `{type:"key", key:"back"\|"home"\|"recents"}` | `{}` | | GET | `/api/instances/{i}/screenshot` | | `image/png`(`adb exec-out screencap -p`),`Cache-Control: no-store`;没开机回 409 | | GET | `/api/instances/{i}/log?kind=emulator\|logcat&lines=300` | | `text/plain; charset=utf-8`,最新那份日志的末尾;没有回 404 | | GET | `/api/instances/{i}/proxy` | | `ProxyView`,见下 | @@ -140,3 +140,7 @@ data: {} 每 15 秒 - Edge 进程在启动后 10 秒内就退出:当作把窗口交给了同一配置下已在运行的 Edge,不算关窗,之后按事件流规则判断。 - 真正关窗后,5 秒内没有事件流连着就退出(有任务先等任务做完)。 - `--dev` 不会因为空闲退出。 + +`resolution` 只接受 1080 和 720。新建默认 1080,分别联动 1920×1080 / DPI 280 与 1280×720 / DPI 240;修改保留方向,下次开机生效。`InstanceInfo.config.resolution` 是按当前配置宽高识别的档位,运行中修改后表示下次开机的设置。 + +画面点击按当前截图的尺寸换算并校验。运行中改档时,`config` 表示下次开机设置,当前画面和点击坐标在重启前保持原尺寸。 diff --git a/internal/ui/device.go b/internal/ui/device.go index 395a4ee..7c87a48 100644 --- a/internal/ui/device.go +++ b/internal/ui/device.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "image/png" "math" "net/http" "strconv" @@ -23,8 +24,6 @@ const ( // statusMaxAge: how old a polled instance list may be to decide "not booted" without asking adb. statusMaxAge = 10 * time.Second inputTimeout = 10 * time.Second - // The display the page's coordinates refer to when the instance config does not say (API.md: 1280x720). - defaultWidth, defaultHeight = 1280, 720 ) var pngSignature = []byte("\x89PNG\r\n\x1a\n") @@ -227,11 +226,24 @@ func (s *server) handleInput(w http.ResponseWriter, r *http.Request) { writeError(w, err) return } + if !in.Booted { + writeError(w, notBooted(i)) + return + } if body.Type == "tap" { - wd, ht := in.Config.Width, in.Config.Height - if wd <= 0 || ht <= 0 { - wd, ht = defaultWidth, defaultHeight + // Config describes the next boot. A running guest may still use the previous + // preset, so validate against its screenshot, shared with the viewer cache. + data, err := s.screenshot(r.Context(), i) + if err != nil { + writeError(w, err) + return } + display, err := png.DecodeConfig(bytes.NewReader(data)) + if err != nil { + writeError(w, errs.New(errs.Internal, "截图的显示尺寸读不出")) + return + } + wd, ht := display.Width, display.Height x, y := *body.X, *body.Y if math.IsNaN(x) || math.IsNaN(y) || x < 0 || y < 0 || x >= float64(wd) || y >= float64(ht) { writeError(w, errs.Newf(errs.InvalidArgument, "点击位置 (%g, %g) 不在 %d×%d 的画面里", x, y, wd, ht)) @@ -239,10 +251,6 @@ func (s *server) handleInput(w http.ResponseWriter, r *http.Request) { } command = fmt.Sprintf("input tap %d %d", int(x), int(y)) } - if !in.Booted { - writeError(w, notBooted(i)) - return - } res, err := s.mgr.Shell(detached(r), i, command, inputTimeout) if err != nil { writeError(w, err) diff --git a/internal/ui/device_test.go b/internal/ui/device_test.go index 9609644..0d85dea 100644 --- a/internal/ui/device_test.go +++ b/internal/ui/device_test.go @@ -5,6 +5,9 @@ package ui import ( "bytes" "errors" + "fmt" + "image" + "image/png" "io" "io/fs" "mime/multipart" @@ -110,9 +113,19 @@ func TestScreenshot(t *testing.T) { expectError(t, h.do("GET", "/api/instances/8/screenshot", nil), http.StatusNotFound, errs.NotFound) } +func displayPNG(t *testing.T, width, height int) []byte { + t.Helper() + var b bytes.Buffer + if err := png.Encode(&b, image.NewGray(image.Rect(0, 0, width, height))); err != nil { + t.Fatal(err) + } + return b.Bytes() +} + func TestInputValidation(t *testing.T) { h := newHarness(t, nil) h.f.put(3, emu.StatusOnline) + h.f.shot = adb.Result{Code: 0, Stdout: displayPNG(t, 1280, 720)} ok := map[string]string{ `{"type":"tap","x":100,"y":200}`: "input tap 100 200", @@ -391,3 +404,31 @@ func TestCheckCached(t *testing.T) { t.Fatalf("refresh=1 did not check again (%d)", n) } } + +// Next-boot settings must neither scale nor reject taps in the still-running guest. +func TestInputUsesLiveDisplayAfterPresetChange(t *testing.T) { + for _, dimensions := range [][2]int{{1920, 1080}, {1280, 720}, {1080, 1920}, {720, 1280}} { + t.Run(fmt.Sprint(dimensions), func(t *testing.T) { + h := newHarness(t, nil) + h.f.put(3, emu.StatusOnline) + h.f.mu.Lock() + in := h.f.instances[3] + in.Config.Width, in.Config.Height = 720, 1280 + if dimensions[0] <= 1280 && dimensions[1] <= 1280 { + in.Config.Width, in.Config.Height = 1920, 1080 + } + h.f.instances[3] = in + h.f.shot = adb.Result{Code: 0, Stdout: displayPNG(t, dimensions[0], dimensions[1])} + h.f.mu.Unlock() + x, y := dimensions[0]-1, dimensions[1]-1 + expect(t, h.do("POST", "/api/instances/3/input", fmt.Sprintf(`{"type":"tap","x":%d,"y":%d}`, x, y)), 200, nil) + h.f.mu.Lock() + got := h.f.shells[len(h.f.shells)-1] + h.f.mu.Unlock() + if want := fmt.Sprintf("input tap %d %d", x, y); got != want { + t.Fatalf("%s, want %s", got, want) + } + expectError(t, h.do("POST", "/api/instances/3/input", fmt.Sprintf(`{"type":"tap","x":%d,"y":0}`, dimensions[0])), 400, errs.InvalidArgument) + }) + } +} diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index 904e815..775b652 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -56,12 +56,13 @@ func (s *server) handleCreate(w http.ResponseWriter, r *http.Request) { CPU *int `json:"cpu"` DataPartitionGB *int `json:"data_partition_gb"` FPS *int `json:"fps"` + Resolution *int `json:"resolution"` } if !decode(w, r, &body) { return } req := emu.CreateRequest{Index: body.Index, MemoryMB: body.MemoryMB, CPU: body.CPU, - DataPartitionGB: body.DataPartitionGB, FPS: body.FPS} + DataPartitionGB: body.DataPartitionGB, FPS: body.FPS, Resolution: body.Resolution} if body.Title != nil { req.Title = *body.Title } @@ -114,17 +115,18 @@ func (s *server) handleUpdate(w http.ResponseWriter, r *http.Request) { MemoryMB *int `json:"memory_mb"` CPU *int `json:"cpu"` FPS *int `json:"fps"` + Resolution *int `json:"resolution"` DataPartitionGB *int `json:"data_partition_gb"` } if !decode(w, r, &body) { return } - if body.Title == nil && body.MemoryMB == nil && body.CPU == nil && body.FPS == nil && body.DataPartitionGB == nil { - writeError(w, errs.New(errs.Usage, "没有要改的设置").WithAdvice("至少给出 title、memory_mb、cpu、fps、data_partition_gb 中的一项")) + if body.Title == nil && body.MemoryMB == nil && body.CPU == nil && body.FPS == nil && body.Resolution == nil && body.DataPartitionGB == nil { + writeError(w, errs.New(errs.Usage, "没有要改的设置").WithAdvice("至少给出 title、memory_mb、cpu、fps、resolution、data_partition_gb 中的一项")) return } res, err := s.mgr.Update(detached(r), i, emu.UpdateRequest{Title: body.Title, MemoryMB: body.MemoryMB, CPU: body.CPU, - FPS: body.FPS, DataPartitionGB: body.DataPartitionGB}) + FPS: body.FPS, Resolution: body.Resolution, DataPartitionGB: body.DataPartitionGB}) if err != nil { writeError(w, err) return diff --git a/internal/ui/http_test.go b/internal/ui/http_test.go index cea3fbe..c3302c6 100644 --- a/internal/ui/http_test.go +++ b/internal/ui/http_test.go @@ -133,7 +133,7 @@ func TestCreateUpdateDelete(t *testing.T) { } return h.waitJob(v.ID, stateIs(stateDone)) } - j := create(map[string]any{"title": "测试机", "index": 2, "memory_mb": 4096, "cpu": 4, "data_partition_gb": 32, "fps": 30}) + j := create(map[string]any{"title": "测试机", "index": 2, "memory_mb": 4096, "cpu": 4, "data_partition_gb": 32, "fps": 30, "resolution": 720}) if res, _ := j.Result.(map[string]any); j.Index != 2 || res["title"] != "测试机" { t.Fatalf("created %+v", j) } @@ -151,11 +151,11 @@ func TestCreateUpdateDelete(t *testing.T) { t.Fatalf("creates %d", len(c)) } r := c[0] - if r.Title != "测试机" || *r.Index != 2 || *r.MemoryMB != 4096 || *r.CPU != 4 || *r.DataPartitionGB != 32 || *r.FPS != 30 || + if r.Title != "测试机" || *r.Index != 2 || *r.MemoryMB != 4096 || *r.CPU != 4 || *r.DataPartitionGB != 32 || *r.FPS != 30 || r.Resolution == nil || *r.Resolution != 720 || r.Progress == nil || r.Resume { t.Fatalf("create request %+v", r) } - if r := c[1]; r.Title != "" || r.Index == nil || *r.Index != auto1.Index || r.MemoryMB != nil || r.CPU != nil || r.DataPartitionGB != nil || r.FPS != nil { + if r := c[1]; r.Title != "" || r.Index == nil || *r.Index != auto1.Index || r.MemoryMB != nil || r.CPU != nil || r.DataPartitionGB != nil || r.FPS != nil || r.Resolution != nil { t.Fatalf("empty create request %+v", r) } if r := c[3]; !r.Resume || r.Index == nil || *r.Index != 3 { @@ -177,7 +177,7 @@ func TestCreateUpdateDelete(t *testing.T) { // update expectError(t, h.do("PATCH", "/api/instances/2", "{}"), 400, errs.Usage) var up emu.UpdateResult - expect(t, h.do("PATCH", "/api/instances/2", `{"fps":30}`), http.StatusOK, &up) + expect(t, h.do("PATCH", "/api/instances/2", `{"fps":30,"resolution":1080}`), http.StatusOK, &up) if up.Applied["fps"] != float64(30) { t.Fatalf("update result %+v", up) } @@ -187,7 +187,7 @@ func TestCreateUpdateDelete(t *testing.T) { h.f.mu.Lock() u := h.f.updated h.f.mu.Unlock() - if len(u) != 4 || *u[0].FPS != 30 || u[0].Title != nil || u[0].MemoryMB != nil || u[0].CPU != nil || + if len(u) != 4 || u[0].Resolution == nil || *u[0].Resolution != 1080 || *u[0].FPS != 30 || u[0].Title != nil || u[0].MemoryMB != nil || u[0].CPU != nil || u[0].DataPartitionGB != nil { t.Fatalf("update requests %+v", u) } diff --git a/internal/ui/web/dev/mock.js b/internal/ui/web/dev/mock.js index 67be311..10f89b6 100644 --- a/internal/ui/web/dev/mock.js +++ b/internal/ui/web/dev/mock.js @@ -44,7 +44,7 @@ function mk(i, title, o = {}) { serial: 'emulator-' + console, adb_address: '127.0.0.1:' + (console + 1), ports: { console, adb: console + 1, grpc: console + 2 }, screencap: { shm: 'SHM_videmulator' + console, shm_ready: st === 'online' }, - config: { memory_mb: o.mem || 6144, cpu: o.cpu || 6, fps: o.fps || 60, data_partition_gb: o.disk || 64, width: 1280, height: 720, dpi: 240 }, + config: { memory_mb: o.mem || 6144, cpu: o.cpu || 6, fps: o.fps || 60, data_partition_gb: o.disk || 64, resolution: o.resolution || 720, width: o.resolution === 1080 ? 1920 : 1280, height: o.resolution === 1080 ? 1080 : 720, dpi: o.resolution === 1080 ? 280 : 240 }, initialized, launcher: initialized ? 'µLauncher' : '', renderer: initialized ? (o.sw ? SWIFT : GPU) : '', software_renderer: !!o.sw, path: `${ROOT}\\avd\\mas_${i}.avd`, @@ -645,6 +645,7 @@ const errResp = (code, message, advice) => fail(STATUS_OF[code] || 500, code, me function validate(b, create) { if (b.memory_mb != null && !MEM.includes(b.memory_mb)) return ['invalid_argument', `内存只能选 3、4、5、6 GB(收到 ${b.memory_mb} MB)`, '']; if (b.cpu != null && ![2, 4, 6].includes(b.cpu)) return ['invalid_argument', `CPU 核数只能是 2、4 或 6(收到 ${b.cpu})`, '']; + if (b.resolution != null && ![1080, 720].includes(b.resolution)) return ['invalid_argument', '分辨率只能是 1080 或 720', '']; if (b.fps != null && ![30, 60].includes(b.fps)) return ['invalid_argument', `帧率只能是 30 或 60(收到 ${b.fps})`, '']; if (create && b.data_partition_gb != null && (!Number.isInteger(b.data_partition_gb) || b.data_partition_gb < 16 || b.data_partition_gb > 512)) { return ['invalid_argument', `数据盘上限只能是 16–512 GB 的整数(收到 ${b.data_partition_gb})`, '']; @@ -673,7 +674,7 @@ async function handle(url, init) { if (!INDEXES.includes(i)) return errResp('invalid_argument', `实例编号 ${i} 不可用(只能是 0–9,5 除外)`); if (db.insts.has(i)) return errResp('already_exists', `实例 mas_${i} 已存在`); await sleep(500); - const inst = mk(i, body.title, { mem: body.memory_mb, cpu: body.cpu, fps: body.fps, disk: body.data_partition_gb, initialized: false, scene: i }); + const inst = mk(i, body.title, { mem: body.memory_mb, cpu: body.cpu, fps: body.fps, resolution: body.resolution || 1080, disk: body.data_partition_gb, initialized: false, scene: i }); log('info', `已新建魔改 AVD 实例 ${i}(内存 ${inst.config.memory_mb} MB / ${inst.config.cpu} 核 / 数据盘 ${inst.config.data_partition_gb} GB / 帧率 ${inst.config.fps})`); emit('instances', list()); return json(pub(inst), 201); @@ -723,6 +724,12 @@ async function handle(url, init) { if (bad) return errResp(...bad); const applied = {}; if (body.title != null) { inst.title = body.title || inst.name; applied.title = body.title; } + if (body.resolution != null) { + const portrait = inst.config.height > inst.config.width; + const w = body.resolution === 1080 ? 1920 : 1280, h = body.resolution; + Object.assign(inst.config, { resolution: body.resolution, width: portrait ? h : w, height: portrait ? w : h, dpi: body.resolution === 1080 ? 280 : 240 }); + applied.resolution = body.resolution; + } for (const k of ['memory_mb', 'cpu', 'fps']) if (body[k] != null) { inst.config[k] = body[k]; applied[k] = body[k]; } emit('instances', list()); return json({ instance: pub(inst), applied, applies_next_boot: inst.running }); diff --git a/internal/ui/web/js/display.js b/internal/ui/web/js/display.js new file mode 100644 index 0000000..52debec --- /dev/null +++ b/internal/ui/web/js/display.js @@ -0,0 +1,10 @@ +// Map a click through object-fit: contain, including portrait screenshots in a wide frame. +export function displayTap(rect, width, height, clientX, clientY) { + if (width <= 0 || height <= 0 || rect.width <= 0 || rect.height <= 0) return null; + const scale = Math.min(rect.width / width, rect.height / height); + const shownWidth = width * scale, shownHeight = height * scale; + const x = (clientX - rect.left - (rect.width - shownWidth) / 2) / shownWidth; + const y = (clientY - rect.top - (rect.height - shownHeight) / 2) / shownHeight; + if (x < 0 || x > 1 || y < 0 || y > 1) return null; + return { type: 'tap', x: Math.round(x * (width - 1)), y: Math.round(y * (height - 1)) }; +} diff --git a/internal/ui/web/js/forms.js b/internal/ui/web/js/forms.js index d585d41..81a8ec7 100644 --- a/internal/ui/web/js/forms.js +++ b/internal/ui/web/js/forms.js @@ -10,6 +10,7 @@ import * as A from './actions.js'; const MEM = [[3072, '3 GB'], [4096, '4 GB'], [5120, '5 GB'], [6144, '6 GB']]; const CPU = [[2, '2 核'], [4, '4 核'], [6, '6 核']]; const FPS = [[30, '30 Hz'], [60, '60 Hz']]; +const RESOLUTION = [[1080, '1080p'], [720, '720p']]; const INDEXES = [0, 1, 2, 3, 4, 6, 7, 8, 9]; const MIN12 = '约 1⁠–⁠2 分钟'; const CREATE_TIME = '一般要几分钟'; @@ -100,6 +101,7 @@ export function openCreate() { segRow('内存', 'memory', MEM, 6144), segRow('CPU', 'cpu', CPU, 6), segRow('帧率', 'fps', FPS, 60, hint('30 更省资源')), + segRow('分辨率', 'resolution', RESOLUTION, 1080, hint('720p 更省资源')), inputRow('数据盘上限', h('span', { class: 'with-suffix' }, disk, h('span', { class: 'suffix', text: 'GB' })), diskHint, diskErr), inputRow('编号', idx, idxHint)), box), actions: { ok }, @@ -119,7 +121,7 @@ export function openCreate() { // a job: the setup boots the instance headless for a few minutes; its card shows the progress const job = await api.post('/api/instances', { title: title.value.trim() || undefined, memory_mb: Number(f.memory.value), cpu: Number(f.cpu.value), - fps: Number(f.fps.value), data_partition_gb: n, index: idx.value === '' ? undefined : Number(idx.value), + fps: Number(f.fps.value), resolution: Number(f.resolution.value), data_partition_gb: n, index: idx.value === '' ? undefined : Number(idx.value), }); A.onJob(job); S.watch.add(job.id); @@ -151,7 +153,8 @@ export function openSettings(i, field) { inputRow('名称', title, hint(`不填就显示 ${inst0.name}`)), segRow('内存', 'memory', MEM, cfg.memory_mb), segRow('CPU', 'cpu', CPU, cfg.cpu), - segRow('帧率', 'fps', FPS, cfg.fps, hint('30 更省资源'))), + segRow('帧率', 'fps', FPS, cfg.fps, hint('30 更省资源')), + segRow('分辨率', 'resolution', RESOLUTION, cfg.resolution, hint('下次开机生效,自动化工具里的分辨率也要跟着改'))), box, h('h3', { class: 'sec-title', text: '实例信息' }), kv), actions: { left: del, ok: save }, @@ -168,7 +171,7 @@ export function openSettings(i, field) { const f = ctl.form.elements; const out = {}; if (title.value.trim() !== initTitle) out.title = title.value.trim(); - for (const [k, key] of [['memory', 'memory_mb'], ['cpu', 'cpu'], ['fps', 'fps']]) { + for (const [k, key] of [['memory', 'memory_mb'], ['cpu', 'cpu'], ['fps', 'fps'], ['resolution', 'resolution']]) { const v = f[k].value === '' ? null : Number(f[k].value); if (v != null && v !== cfg[key]) out[key] = v; } diff --git a/internal/ui/web/js/viewer.js b/internal/ui/web/js/viewer.js index 9c7a95c..f928732 100644 --- a/internal/ui/web/js/viewer.js +++ b/internal/ui/web/js/viewer.js @@ -6,6 +6,7 @@ import { S, subscribe, instance, rootBlocked } from './store.js'; import { openDialog } from './ui.js'; import { label } from './format.js'; import * as A from './actions.js'; +import { displayTap } from './display.js'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -102,17 +103,15 @@ export function openViewer(i) { frame.addEventListener('click', (e) => { if (frame.classList.contains('off')) return; const r = frame.getBoundingClientRect(); - const cfg = (instance(i) || inst).config || {}; - const fx = (e.clientX - r.left) / r.width; - const fy = (e.clientY - r.top) / r.height; - if (fx < 0 || fx > 1 || fy < 0 || fy > 1) return; + // The screenshot is the running display; config may already describe the next boot. + const tap = displayTap(r, img.naturalWidth, img.naturalHeight, e.clientX, e.clientY); + if (!tap) return; const rip = h('span', { class: 'ripple' }); - rip.style.left = fx * 100 + '%'; - rip.style.top = fy * 100 + '%'; + rip.style.left = (e.clientX - r.left) + 'px'; + rip.style.top = (e.clientY - r.top) + 'px'; frame.append(rip); setTimeout(() => rip.remove(), 600); - // the API takes pixels of the 1280×720 display - A.input(i, { type: 'tap', x: Math.round(fx * ((cfg.width || 1280) - 1)), y: Math.round(fy * ((cfg.height || 720) - 1)) }); + A.input(i, tap); }); const un = subscribe(() => { const cur = instance(i); diff --git a/scripts/test_display_tap.mjs b/scripts/test_display_tap.mjs new file mode 100644 index 0000000..58930a2 --- /dev/null +++ b/scripts/test_display_tap.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { displayTap } from '../internal/ui/web/js/display.js'; + +const frame = { left: 10, top: 20, width: 320, height: 180 }; + +test('both presets use the current screenshot size', () => { + assert.deepEqual(displayTap(frame, 1280, 720, 170, 110), { type: 'tap', x: 640, y: 360 }); + assert.deepEqual(displayTap(frame, 1920, 1080, 170, 110), { type: 'tap', x: 960, y: 540 }); + assert.deepEqual(displayTap(frame, 1920, 1080, 330, 200), { type: 'tap', x: 1919, y: 1079 }); +}); + +test('portrait taps exclude the empty side bands', () => { + assert.equal(displayTap(frame, 1080, 1920, 20, 110), null); + assert.equal(displayTap(frame, 720, 1280, 320, 110), null); + assert.deepEqual(displayTap(frame, 1080, 1920, 170, 110), { type: 'tap', x: 540, y: 960 }); + assert.deepEqual(displayTap(frame, 720, 1280, 170, 110), { type: 'tap', x: 360, y: 640 }); + assert.deepEqual(displayTap(frame, 1080, 1920, 119.375, 20), { type: 'tap', x: 0, y: 0 }); +}); + +test('a tall frame excludes empty top and bottom bands', () => { + const tall = { left: 0, top: 0, width: 180, height: 320 }; + assert.equal(displayTap(tall, 1920, 1080, 90, 10), null); + assert.deepEqual(displayTap(tall, 1920, 1080, 90, 160), { type: 'tap', x: 960, y: 540 }); +}); + +test('unloaded images and zero-size frames cannot send a tap', () => { + assert.equal(displayTap(frame, 0, 0, 170, 110), null); + assert.equal(displayTap({ ...frame, width: 0 }, 1280, 720, 170, 110), null); + assert.equal(displayTap(frame, 1280, 720, 9, 110), null); +}); diff --git a/scripts/test_resolution_guards.py b/scripts/test_resolution_guards.py new file mode 100644 index 0000000..a980869 --- /dev/null +++ b/scripts/test_resolution_guards.py @@ -0,0 +1,70 @@ +"""Check resolution regressions with Go source overlays, without editing the checkout. + +python scripts/test_resolution_guards.py --go --out +Each variant must compile and fail its named behavior test. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +CASES = [ + ("new default", "internal/avd/avd.go", "avd", "TestResolutionCreateSwitchAndReboot", + "DefaultResolution = 1080", "DefaultResolution = 720"), + ("1080 density", "internal/avd/avd.go", "avd", "TestResolutionCreateSwitchAndReboot", + "width, height, density = 1920, 1080, 280", "width, height, density = 1920, 1080, 240"), + ("portrait recognition", "internal/avd/avd.go", "avd", "TestResolutionLegacy", + "(w == 1920 && h == 1080) || (w == 1080 && h == 1920)", "w == 1920 && h == 1080"), + ("orientation", "internal/avd/avd.go", "avd", "TestResolutionCreateSwitchAndReboot", + "if h > w {", "if false && h > w {"), + ("update persistence", "internal/avd/avd.go", "avd", "TestResolutionCreateSwitchAndReboot", + "changes = append(changes, displayChanges(in.Config, resolution)...)", "_ = resolution"), + ("pre-boot preset", "internal/avd/avd.go", "avd", "TestResolutionCreateSwitchAndReboot", + "displayChanges(cfg, resolutionFor(cfg))", "displayChanges(cfg, 720)"), + ("CLI create wiring", "internal/cli/commands.go", "cli", "TestBuildCreateRequest", + 'req.Resolution = intPtrOpt(inv, "resolution")', "req.Resolution = nil"), + ("CLI update wiring", "internal/cli/commands.go", "cli", "TestBuildUpdateRequest", + 'req.Resolution = intPtrOpt(inv, "resolution")', "req.Resolution = nil"), + ("MCP wiring", "internal/mcp/tools.go", "mcp", "TestToolCalls", + 'a.optInt("resolution")', 'a.optInt("omitted_resolution")'), + ("HTTP wiring", "internal/ui/handlers.go", "ui", "TestCreateUpdateDelete", + "Resolution: body.Resolution", "Resolution: nil"), + ("live display bounds", "internal/ui/device.go", "ui", "TestInputUsesLiveDisplayAfterPresetChange", + "wd, ht := display.Width, display.Height", "wd, ht := in.Config.Width, in.Config.Height; _ = display"), +] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--go", default="go") + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args() + results = [] + with tempfile.TemporaryDirectory(prefix="resolution-guards-") as directory: + scratch = Path(directory) + for n, (name, source, package, test, before, after) in enumerate(CASES): + original = ROOT / source + text = original.read_text("utf-8") + assert before in text, name + changed = scratch / f"variant-{n}.go" + changed.write_text(text.replace(before, after), "utf-8") + overlay = scratch / "overlay.json" + overlay.write_text(json.dumps({"Replace": {str(original): str(changed)}}), "utf-8") + run = subprocess.run([args.go, "test", "-trimpath", "-buildvcs=false", "-ldflags=-buildid=", "-count=1", "-timeout=45s", + "-overlay", str(overlay), "./internal/" + package, "-run", "^" + test + "$"], + cwd=ROOT, capture_output=True, text=True, encoding="utf-8", timeout=90) + output = run.stdout + run.stderr + caught = run.returncode != 0 and f"--- FAIL: {test}" in output and "[build failed]" not in output + results.append({"variant": name, "test": test, "caught": caught, "output": output}) + print(f"{'caught' if caught else 'MISSED'}: {name}", flush=True) + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n", "utf-8") + return 0 if all(r["caught"] for r in results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_resolution_guest.py b/scripts/test_resolution_guest.py new file mode 100644 index 0000000..86b5927 --- /dev/null +++ b/scripts/test_resolution_guest.py @@ -0,0 +1,94 @@ +"""Run display regression checks on an isolated Windows root with a private SDK. + +python scripts/test_resolution_guest.py --root C:/resolution-lab --legacy-cli C:/old/tools/modavd.exe --out C:/evidence/display.json + +The root must start without instances, use non-default private ports, and contain a +complete SDK. The script keeps its instances for inspection and shuts them down. +Only query forms of wm size/density are used. No guest display overrides are set. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re +import subprocess + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--legacy-cli", type=Path, required=True) + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args() + root = args.root.resolve(strict=True) + meta = json.loads((root / "mas-avd.json").read_text("utf-8")) + assert meta.get("portBase") != 20000 and meta.get("portBase"), "use a private port plan" + assert meta.get("adbServerPort") not in (None, 20050, 5037) + assert meta.get("scriptAdbServerPort") not in (None, 20049, 5037) + assert not meta.get("sdkRoot"), "SDK must be copied into this isolated root" + assert (root / "sdk/emulator/emulator.exe").is_file() + assert not list((root / "avd").glob("mas_*")), "use a fresh root without instances" + cli = root / "tools/modavd.exe" + records: list[dict] = [] + args.out.parent.mkdir(parents=True, exist_ok=True) + + def call(*command: str, exe: Path = cli) -> dict: + run = subprocess.run([str(exe), "--root", str(root), "--json", *command], + capture_output=True, text=True, encoding="utf-8", timeout=1500) + records.append({"command": list(command), "legacy": exe != cli, + "code": run.returncode, "stdout": run.stdout, "stderr": run.stderr}) + args.out.write_text(json.dumps(records, ensure_ascii=False, indent=2) + "\n", "utf-8") + assert run.returncode == 0, (command, run.stdout, run.stderr) + result = json.loads(run.stdout) + assert result["ok"], result + return result["data"] + + def observe(index: int, width: int, height: int, dpi: int, stage: str) -> None: + call("start", str(index), "--headless", "--no-bridge") + output = call("shell", str(index), "wm size; wm density; dumpsys display")["output"] + assert f"Physical size: {width}x{height}" in output, output + assert f"Physical density: {dpi}" in output, output + assert "Override size:" not in output and "Override density:" not in output, output + # WindowManager query plus the guest's actual DisplayInfo, independent of config.ini. + assert re.search(rf"(?:real|supportedModes=.*?width=)\s*{width}(?:\s*x\s*|, height=){height}", output), output + print(f"{stage}: guest {width}x{height} / DPI {dpi}", flush=True) + call("stop", str(index)) + + try: + call("create", "--index", "0", "--memory", "3G", "--cpu", "2", "--no-bridge") + observe(0, 1920, 1080, 280, "new default") + call("start", "0", "--headless", "--no-bridge") + update = call("set", "0", "--resolution", "720") + assert update["applies_next_boot"] and update["instance"]["config"]["resolution"] == 720, update + live = call("shell", "0", "wm size; wm density")["output"] + assert "Physical size: 1920x1080" in live and "Physical density: 280" in live, live + print("running change: guest stays 1920x1080 / DPI 280 until restart", flush=True) + call("stop", "0") + observe(0, 1280, 720, 240, "running change after restart") + call("set", "0", "--resolution", "1080") + observe(0, 1920, 1080, 280, "restored 1080") + call("create", "--index", "1", "--memory", "3G", "--cpu", "2", "--no-bridge", exe=args.legacy_cli.resolve(strict=True)) + observe(1, 1280, 720, 240, "old 720 first upgraded boot") + observe(1, 1280, 720, 240, "old 720 repeated boot") + call("set", "1", "--resolution", "1080") + observe(1, 1920, 1080, 280, "720 to 1080") + call("set", "1", "--resolution", "720") + observe(1, 1280, 720, 240, "1080 to 720") + call("create", "--index", "2", "--resolution", "720", "--memory", "3G", "--cpu", "2", "--no-bridge") + observe(2, 1280, 720, 240, "new explicit 720") + # Seed a portrait legacy configuration, then change the preset through the CLI. + cfg_path = root / "avd/mas_2.avd/config.ini" + cfg = cfg_path.read_text("utf-8").replace("hw.lcd.width=1280", "hw.lcd.width=720").replace("hw.lcd.height=720", "hw.lcd.height=1280") + cfg_path.write_text(cfg, "utf-8") + call("set", "2", "--resolution", "1080") + observe(2, 1080, 1920, 280, "portrait 720 to 1080") + observe(2, 1080, 1920, 280, "portrait 1080 repeated boot") + call("set", "2", "--resolution", "720") + observe(2, 720, 1280, 240, "portrait 1080 to 720") + finally: + call("stop", "--all") + + +if __name__ == "__main__": + main()