diff --git a/.gitattributes b/.gitattributes index 33a00e9d8..eb5c51758 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,6 +5,7 @@ *.bats text eol=lf *.py text eol=lf *.json text eol=lf +*.filt text eol=lf build/openbor/*.patch text eol=lf whitespace=-trailing-space,-space-before-tab tools/patches/*.patch text eol=lf whitespace=-trailing-space,-space-before-tab tools/bloom-device text eol=lf diff --git a/.github/workflows/shell-test.yml b/.github/workflows/shell-test.yml index db10ae525..e564aacb9 100644 --- a/.github/workflows/shell-test.yml +++ b/.github/workflows/shell-test.yml @@ -31,6 +31,7 @@ on: - 'tools/validate_core_selection_policy.py' - 'tools/display_auto_baseline.py' - 'tools/display_profile_policy.py' + - 'tools/display_built_in_profiles.py' - 'tools/validate_content_identity_policy.py' - 'tools/validate_libretro_artwork_policy.py' - 'tools/ci-scope.sh' @@ -42,6 +43,7 @@ on: - 'build/core-selection-policy*.json' - 'build/display-auto-baseline.json' - 'build/display-profile-policy.json' + - 'build/display-built-in-profiles.json' - 'build/content-identity-policy.json' - 'build/libretro-artwork-policy.json' - 'build/ra-core-policy.json' diff --git a/.gitignore b/.gitignore index 0f0228667..6112a4198 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ __pycache__/ !/build/core-selection-policy.json !/build/display-auto-baseline.json !/build/display-profile-policy.json +!/build/display-built-in-profiles.json !/build/libretro-artwork-policy.json !/build/themes.sha256 !/build/provenance-policy.json diff --git a/Makefile b/Makefile index ae8384e0e..24e55a339 100644 --- a/Makefile +++ b/Makefile @@ -185,6 +185,7 @@ core: $(CACHE)/.setup shared-libs @cp $(ROOT_DIR)/build/transfer-policy.json $(BUILD_DIR)/.tmp_update/config/transfer-policy.json @cp $(ROOT_DIR)/build/system-catalog.json $(BUILD_DIR)/.tmp_update/config/system-catalog.json @cp $(ROOT_DIR)/build/libretro-artwork-policy.json $(BUILD_DIR)/.tmp_update/config/libretro-artwork-policy.json + @cp $(ROOT_DIR)/build/display-built-in-profiles.json $(BUILD_DIR)/.tmp_update/config/display-built-in-profiles.json # Build dependencies for installer @mkdir -p $(INSTALLER_DIR)/bin diff --git a/build/dependencies.lock b/build/dependencies.lock index 8f4190c63..2c427c820 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -209,7 +209,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 336 file_count = 2135 -byte_count = 314711917 +byte_count = 314717659 source_build_components = 232 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/display-built-in-profiles.json b/build/display-built-in-profiles.json new file mode 100644 index 000000000..780d72f8e --- /dev/null +++ b/build/display-built-in-profiles.json @@ -0,0 +1,173 @@ +{ + "schema": 1, + "policy_id": "bloomos-display-built-in-profiles-v1", + "scope": { + "product": "ssd202d-only", + "devices": [ + "miyoo-mini-v1", + "miyoo-mini-v2-v3", + "miyoo-mini-v4", + "miyoo-mini-plus", + "miyoo-mini-flip-my285" + ], + "runtime_device": "miyoo-mini-plus", + "auto_selection": false, + "auto_behavior": "preserve-disp01-baseline", + "physical_certification": false + }, + "authorities": { + "core_selection_policy": { + "path": "build/core-selection-policy.json", + "hash_mode": "raw", + "sha256": "2754e459eba3270b910a0335d5f7212c01242030bb3734defec86cc849015871" + }, + "auto_baseline": { + "path": "build/display-auto-baseline.json", + "hash_mode": "raw", + "sha256": "d56c3ae130f61c28632a3a0aa9b9cc16ec1cae2195366240b0d92d9f78e09d06" + }, + "filter_contract": { + "path": "static/build/RetroArch/RetroArch_Dingux_forMiyooMini.txt", + "hash_mode": "raw", + "sha256": "952a2656351cec8e4aed103c0c8c79d7a3c80def71d7628651381aa88a6bc651" + }, + "retroarch_license": { + "path": "third-party/RetroArch-patch/submodules/RetroArch/COPYING", + "hash_mode": "raw", + "sha256": "0b383d5a63da644f628d99c33976ea6487ed89aaa59f0b3257992deac1171e6b" + } + }, + "activation_contract": { + "selection_key": "exact-device-system-core", + "review_status": "source-reviewed", + "missing_profile": "unavailable-without-mutation", + "filter_path": "fixed-signed-runtime-path-never-user-input", + "filter_integrity": "exact-size-and-sha256-before-session-composition", + "axis_independence": "profile-supplies-capabilities-without-changing-selections" + }, + "profiles": [ + { + "id": "plus-gb-gambatte-lcd3x-v1", + "device_id": "miyoo-mini-plus", + "system_id": "gb", + "candidate_id": "gambatte", + "core_binary": "gambatte_libretro.so", + "source_geometry": { + "width": 160, + "height": 144, + "rotation_quarter_turns": 0, + "display_aspect": {"numerator": 10, "denominator": 9}, + "authority": "reviewed-system-core-profile" + }, + "effect": { + "id": "lcd3x-light", + "class": "lcd", + "descriptor": { + "source_path": "static/build/RetroArch/.retroarch/filters/video/Grids/LCD3x/LCD3x_light.filt", + "runtime_path": "/mnt/SDCARD/RetroArch/.retroarch/filters/video/Grids/LCD3x/LCD3x_light.filt", + "hash_mode": "text-lf", + "bytes": 21, + "sha256": "607c0fb49e6d3524972b9bfcbb88c5df644914e39f9474bcf9c9a86505e10bf9" + }, + "implementation": { + "source_path": "static/build/RetroArch/.retroarch/filters/video/Grids/LCD3x/lcd3x_light.so", + "runtime_path": "/mnt/SDCARD/RetroArch/.retroarch/filters/video/Grids/LCD3x/lcd3x_light.so", + "hash_mode": "raw", + "bytes": 9588, + "sha256": "939c552b50b02472b438c35c3036c6173bbbf2f610c7abcc0f3c8e1a9d37b13e" + } + }, + "qualification": { + "review_status": "source-reviewed", + "device_evidence_status": "pending", + "evidence_ids": ["retroarch-miyoomini-filter-contract"] + } + }, + { + "id": "plus-gbc-gambatte-lcd3x-v1", + "device_id": "miyoo-mini-plus", + "system_id": "gbc", + "candidate_id": "gambatte", + "core_binary": "gambatte_libretro.so", + "source_geometry": { + "width": 160, + "height": 144, + "rotation_quarter_turns": 0, + "display_aspect": {"numerator": 10, "denominator": 9}, + "authority": "reviewed-system-core-profile" + }, + "effect": { + "id": "lcd3x-light", + "class": "lcd", + "descriptor": { + "source_path": "static/build/RetroArch/.retroarch/filters/video/Grids/LCD3x/LCD3x_light.filt", + "runtime_path": "/mnt/SDCARD/RetroArch/.retroarch/filters/video/Grids/LCD3x/LCD3x_light.filt", + "hash_mode": "text-lf", + "bytes": 21, + "sha256": "607c0fb49e6d3524972b9bfcbb88c5df644914e39f9474bcf9c9a86505e10bf9" + }, + "implementation": { + "source_path": "static/build/RetroArch/.retroarch/filters/video/Grids/LCD3x/lcd3x_light.so", + "runtime_path": "/mnt/SDCARD/RetroArch/.retroarch/filters/video/Grids/LCD3x/lcd3x_light.so", + "hash_mode": "raw", + "bytes": 9588, + "sha256": "939c552b50b02472b438c35c3036c6173bbbf2f610c7abcc0f3c8e1a9d37b13e" + } + }, + "qualification": { + "review_status": "source-reviewed", + "device_evidence_status": "pending", + "evidence_ids": ["retroarch-miyoomini-filter-contract"] + } + }, + { + "id": "plus-gba-gpsp-lcd2x-v1", + "device_id": "miyoo-mini-plus", + "system_id": "gba", + "candidate_id": "gpsp", + "core_binary": "gpsp_libretro.so", + "source_geometry": { + "width": 240, + "height": 160, + "rotation_quarter_turns": 0, + "display_aspect": {"numerator": 3, "denominator": 2}, + "authority": "reviewed-system-core-profile" + }, + "effect": { + "id": "lcd2x", + "class": "lcd", + "descriptor": { + "source_path": "static/build/RetroArch/.retroarch/filters/video/Grids/LCD2x/LCD2x.filt", + "runtime_path": "/mnt/SDCARD/RetroArch/.retroarch/filters/video/Grids/LCD2x/LCD2x.filt", + "hash_mode": "text-lf", + "bytes": 15, + "sha256": "ad1d54093dab88a56ca91138b794e35702fec9ebbe3a1d865f868227e9432b28" + }, + "implementation": { + "source_path": "static/build/RetroArch/.retroarch/filters/video/Grids/LCD2x/lcd2x.so", + "runtime_path": "/mnt/SDCARD/RetroArch/.retroarch/filters/video/Grids/LCD2x/lcd2x.so", + "hash_mode": "raw", + "bytes": 5492, + "sha256": "31fec21ab644e300d2ce1367a1461da0eac4a8d1c369108edde2f493ef19b081" + } + }, + "qualification": { + "review_status": "source-reviewed", + "device_evidence_status": "pending", + "evidence_ids": ["retroarch-miyoomini-filter-contract"] + } + } + ], + "unsupported": { + "devices": [ + "miyoo-mini-v1", + "miyoo-mini-v2-v3", + "miyoo-mini-v4", + "miyoo-mini-flip-my285" + ], + "effect_classes": ["crt"], + "artwork": "no-reviewed-built-in-bezels", + "alternate_cores": "explicitly-unavailable-until-reviewed", + "behavior": "preserve-auto-or-report-unavailable-without-partial-config" + } +} diff --git a/build/display-profile-policy.json b/build/display-profile-policy.json index c9f24b0c4..f795e2b5f 100644 --- a/build/display-profile-policy.json +++ b/build/display-profile-policy.json @@ -10,8 +10,8 @@ "miyoo-mini-plus", "miyoo-mini-flip-my285" ], - "runtime_integration": "deferred-to-DISP-03", - "built_in_profiles": "deferred-to-DISP-04", + "runtime_integration": "enabled-in-DISP-03", + "built_in_profiles": "enabled-in-DISP-04", "custom_packs": "deferred-to-DISP-05", "permanent_retroarch_config_mutation": false, "physical_certification": false @@ -26,6 +26,11 @@ "path": "static/build/.tmp_update/bin/bloom-platform", "hash_mode": "text-lf", "sha256": "ccc3d1ad7efd29c8864a2b274c6af1ab6aff5efe9c5098dbe1b542ceaa11258d" + }, + "built_in_profiles": { + "path": "build/display-built-in-profiles.json", + "hash_mode": "raw", + "sha256": "c79a17aac3acd428cd21f1edffc0399e7917b73d38b12e490f7233368b53ee7b" } }, "public_controls": [ diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index 69b4567b3..6824ec7c3 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -488,8 +488,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-dev-deploy", "file_count": 1, - "byte_count": 33011, - "tree_sha256": "4dd16ce6d73174d683d2b6b43161024a1694de4b48c58842ce89e80b15fa394e", + "byte_count": 33126, + "tree_sha256": "225e52c83020c0ebd88732688e8731125cc56fd912b77a010eb9696f2ade7ec9", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -527,8 +527,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-display-session-prepare", "file_count": 1, - "byte_count": 3190, - "tree_sha256": "5c4dff01fab14bac6d77ae7d0109d04e6b6eb736038a7d145f8d62db57b8a9bc", + "byte_count": 8817, + "tree_sha256": "89c91893f9f61d8c58853ad6f3602041bfc7935911745d4e5c8b216d6727bdc3", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/docs/1.0_TEST_MATRIX.md b/docs/1.0_TEST_MATRIX.md index ba28ce545..0da4203c7 100644 --- a/docs/1.0_TEST_MATRIX.md +++ b/docs/1.0_TEST_MATRIX.md @@ -23,6 +23,7 @@ observable properties and never substitutes for physical UX review. | Display Auto compatibility | Deterministic per-system/core effective visual fixture plus explicit no-selectable-core catalog dispositions; exact policy/catalog/manifest, core binary, core metadata, global, core-override, and content-directory hashes; representative legacy geometry/effect assertions; runtime user and per-game overrides excluded | | Display profile semantics | Exact Scaling, Screen Effect, and Bezel controls; disjoint geometry/sampling/effect/artwork key ownership; PLAT-01 framebuffer capability; bounded centered Auto/integer/aspect/fill geometry fixtures for 640x480 and 752x560; invalid geometry and stale/coupled/unsafe policy mutations fail without partial config | | Display settings and session composer | Canonical backward-compatible display selections; exact enum rejection; no Onion projection; observed framebuffer input; independent axis fallback; one private `0600` RetroArch append config shared with achievements; no permanent-config mutation; decorator failure and timeout preserve the immutable base launch | +| Built-in display profiles | Versioned signed authority; exact Mini Plus device/system/default-core joins for GB, GBC, and GBA; reviewed source geometry/aspect and packaged LCD2x/LCD3x-light descriptor plus implementation hashes; exact filter byte/hash verification before use; Auto unchanged; alternate devices/cores, CRT, custom effects, and bezels fail unavailable | | Performance | Input/render timings, 30-minute shell soak, 500 open/back cycles | | Privacy | Allowlisted health/support export contains no private data | | Developer deployment | Dual-marker/update/session preflight; deterministic allowlisted manifest; exact byte/hash/mode transfer; sealed slots; atomic activation; readiness auto-rollback; active/previous overlay; bounded cleanup; ordinary network-helper activation denied | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ad15b87ec..ee433fe43 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1410,6 +1410,27 @@ and shell punctuation remain data. The runner retains all safety boundaries: explicit developer mode, supported-system allowlisting, ROM confinement, bounded duration, structured launch/session handling, and save flushing. +## Built-in display profile authority + +`build/display-built-in-profiles.json` is the schema-1 DISP-04 authority for +reviewed display identities. It is included in the signed payload and joined at +session preparation by exact device, canonical system, and selected core. The +initial authority is deliberately narrow: Mini Plus with the default Gambatte +core for GB/GBC and default gpSP core for GBA. Each row owns reviewed source +geometry and aspect plus the exact packaged LCD filter descriptor and +implementation size and SHA-256. Auto remains the complete DISP-01 baseline; +the catalog never performs implicit selection. + +`bloom-display-session-prepare` trusts the canonical launch request for system +and core identity and `bloom-platform` for the device. It reads only the signed +catalog location, resolves one exact row, verifies the selected filter bytes, +and passes reviewed numeric geometry and one fixed filter path to +`bloom-launch`. The native composer uses checked integer geometry and emits only +its display-owned keys into the private session config. It accepts no arbitrary +path or raw RetroArch setting. Unsupported devices, alternate cores, missing or +stale filter bytes, CRT, custom effects, and bezels resolve unavailable without +partially applying that axis or mutating global RetroArch configuration. + ## Development deployment overlay `bloom-dev-deploy` provides the device half of the guarded Hot/Warm development diff --git a/docs/DISPLAY_PROFILE_POLICY.md b/docs/DISPLAY_PROFILE_POLICY.md index d69ae5efd..cf7d1f1df 100644 --- a/docs/DISPLAY_PROFILE_POLICY.md +++ b/docs/DISPLAY_PROFILE_POLICY.md @@ -58,13 +58,28 @@ python3 tools/display_profile_policy.py geometry \ ## Capability and delivery boundaries -Explicit LCD and CRT modes require a qualified signed built-in effect profile; -those assets belong to DISP-04. Auto effect and Auto bezel resolve to None when -no reviewed profile exists. Custom effect and bezel selections accept only a -validated bounded pack ID after DISP-05, never an arbitrary filesystem path. - -DISP-02 does not add settings, assets, runtime config writes, or device code. -DISP-03 will integrate the policy with `bloom-settings` and the one trusted -temporary session config composer. Permanent `retroarch.cfg` mutation remains -forbidden. This host-only authority is not evidence of ideal visual output, -LCD quality, gameplay, physical controls, or certification for any device. +DISP-04 adds one signed, versioned built-in profile authority without changing +Auto. It reviews only the Mini Plus default GB/GBC Gambatte and GBA gpSP +selectors: GB/GBC use the packaged LCD3x-light filter and GBA uses the packaged +LCD2x filter, with exact source geometry, aspect, descriptor and implementation +hashes. Selection is an exact device/system/core join. Mini V1-V4, Flip, +alternate cores, CRT, bezels, and custom packs remain unavailable rather than +inferred. + +DISP-03 integrated the policy with `bloom-settings` and the one trusted +temporary session config composer. DISP-04 supplies reviewed source geometry +and a fixed packaged filter identity to that composer. Permanent +`retroarch.cfg` mutation remains forbidden. Host and remote Mini Plus evidence +does not certify ideal LCD output, gameplay, physical controls, audio, or any +other device revision. + +Validate or resolve the built-in authority with: + +```sh +python3 tools/display_built_in_profiles.py validate \ + --repository . --policy build/display-built-in-profiles.json + +python3 tools/display_built_in_profiles.py resolve \ + --repository . --policy build/display-built-in-profiles.json \ + --device miyoo-mini-plus --system gba --core gpsp_libretro.so +``` diff --git a/docs/DISPLAY_SESSION_COMPOSER.md b/docs/DISPLAY_SESSION_COMPOSER.md index 9b59aa1a5..b6b227bef 100644 --- a/docs/DISPLAY_SESSION_COMPOSER.md +++ b/docs/DISPLAY_SESSION_COMPOSER.md @@ -26,16 +26,20 @@ the validated display contribution and atomically replace the same file. `auto` does not rewrite an axis from the DISP-01 baseline. `fill`, nearest or linear sampling, effect `none`, and bezel `none` have complete typed composer -implementations. Integer Pixels and Correct Aspect remain unavailable until a -trusted source raster/aspect is connected. LCD, CRT, and custom effect or -bezel selections remain unavailable until DISP-04/DISP-05 provide reviewed -identities. An unavailable selection resolves that axis to `auto`, records a -bounded reason, and emits no partial keys for that axis. +implementations. DISP-04 connects trusted source raster/aspect and a fixed +filter identity for three exact Mini Plus default-core selectors: GB/GBC with +Gambatte and GBA with gpSP. Integer Pixels and Correct Aspect can therefore be +composed for those selectors, and LCD selects the reviewed packaged LCD3x-light +or LCD2x filter. Other devices, systems, cores, CRT, custom effects, and bezels +remain unavailable. An unavailable selection resolves that axis to `auto`, +records a bounded reason, and emits no partial keys for that axis. The display owner can emit only the allowlisted display keys in `bloom_retroarch_config.c`; it cannot emit achievement, input-safety, driver, -network, or arbitrary filesystem settings. Custom selections are bounded IDs -in settings and never become raw paths in DISP-03. +network, or arbitrary filesystem settings. The session helper accepts only a +catalog path from the signed payload and verifies the selected filter's exact +size and SHA-256 before passing its fixed `/mnt/SDCARD/RetroArch/.retroarch/filters/video/` +path. Custom selections are bounded IDs in settings and never become raw paths. Validation: @@ -43,6 +47,8 @@ Validation: make test GTEST_FILTER='BloomSettingsTest.*:BloomRetroarchConfigTest.*:BloomLaunchTest.*' docker run --rm -v "$PWD:/workspace" -w /workspace \ ghcr.io/boburning/bloomos-shell-test@sha256:36adeca7c265345688ebf47b575c56e350b9016257e0a9ed15c63dc7e0447d63 \ + test/shell/display_built_in_profiles.bats \ + test/shell/display_profile_policy.bats \ test/shell/display_session_prepare.bats \ test/shell/bloom_session.bats \ test/shell/ra_session_prepare.bats diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 04aede49f..71065deed 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -330,4 +330,14 @@ older-schema status cannot invoke any primary network operation. This prevents stale or lossy Boolean projection from repeatedly restarting a connection in progress or treating an unknown backend contract as authorization. +Built-in display profiles are data, not a path-selection capability. The signed +schema-1 authority permits only exact Mini Plus device/system/default-core +selectors and repository-owned filter identities. Session preparation verifies +the selected descriptor and implementation size and SHA-256, and the native +composer accepts only a fixed path below +`/mnt/SDCARD/RetroArch/.retroarch/filters/video/` with no traversal or unsafe +characters. User settings choose typed semantic modes; they never supply a raw +RetroArch key, path, geometry, or filter. The resulting contribution is private +session state and cannot mutate global RetroArch configuration. + Security-sensitive migrations and repair operations must be bounded, logged, reversible where possible, and validated before activation. diff --git a/docs/validation/display/disp04-20260831/shell-restored.png b/docs/validation/display/disp04-20260831/shell-restored.png new file mode 100644 index 000000000..3f9977211 Binary files /dev/null and b/docs/validation/display/disp04-20260831/shell-restored.png differ diff --git a/docs/validation/display/plus-disp04-built-in-profiles-20260831.json b/docs/validation/display/plus-disp04-built-in-profiles-20260831.json new file mode 100644 index 000000000..fc16636ae --- /dev/null +++ b/docs/validation/display/plus-disp04-built-in-profiles-20260831.json @@ -0,0 +1,127 @@ +{ + "schema": 1, + "date": "2026-08-31", + "program_slice": "DISP-04", + "operator": "Codex over maintainer-authorized key-only SSH", + "source_commit": "b36e2eb660a306e03e9e869a07c67f8dbc51bebc", + "base_commit": "c31d928db634f560bf1fd0d700fb7c20a2c21488", + "host_validation": { + "focused_native": "8/8 passed", + "full_shell": "799/799 passed across eight shards; slowest shard 94 seconds", + "focused_display_shell": "15/15 passed", + "focused_provenance_manifest_dependency": "passed after final regeneration", + "ci_scope_contract": "passed", + "static_analysis": "focused cppcheck exited 0", + "format": "clang-format dry-run passed", + "diff_check": "passed" + }, + "signed_build": { + "workflow_run": "https://github.com/boburning/BloomOS/actions/runs/33350464921", + "workflow_result": "passed", + "version": "0.1.0-dev-b36e2eb6", + "archive_size": 199861824, + "archive_sha256": "d957635c14d781d2101110b0fcb58fde6232e1b3167c96fb47887ff31b3ee3f8", + "manifest_sha256": "a75012b612ef9244f7ad7b16b27c09339a5dbd5bc23c4ff216075d7de9f71776", + "manifest_signature_sha256": "1d55c70c3c6c59f5a051bf21726dbc26114f8ef4d89adf4afc6601581a91256d", + "payload_manifest_sha256": "2212158d91b2bdd5b12a7f075ce4b974f1e080abec6298a29b9321c487384dbf", + "build_info_sha256": "8d0499c161e14062439e1efd75fa487a9bd4f2a3d6c047bf4e409f6a045f08bd", + "sha256sums_verified": true, + "ed25519_signature_verified": true, + "installed_as_full_update": false + }, + "device": { + "model": "miyoo-mini-plus", + "model_id": "354", + "display": "640x480", + "installed_baseline": "v0.1.0-dev-2933f706", + "update_phase_before_and_after": "known_good", + "developer_ssh": "running_key_only", + "physical_certification": false + }, + "deployment": { + "tier": "hash-differential Warm overlay", + "candidate_id": "disp04-launch-b36e2eb6", + "candidate_component": "launch-session", + "candidate_manifest_files": 12, + "candidate_manifest_exact_hashes_verified": true, + "network_component_deployed": false, + "full_update_performed": false, + "candidate_rolled_back": true, + "current_shell_overlay": "disp04-shell-restore-b36e2eb6", + "current_shell_source": "six exact files extracted from the verified signed artifact", + "temporary_deployer_removed": true, + "temporary_settings_fixture_removed": true + }, + "profile_gate": { + "system": "gb", + "emulator_id": "libretro:gambatte", + "profile_id": "plus-gb-gambatte-lcd3x-v1", + "selection_result": { + "status": "applied", + "scaling": "integer-pixels", + "screen_effect": "lcd", + "bezel": "auto" + }, + "source_geometry": "160x144", + "display_aspect": "10:9", + "viewport": "480x432 at 80,24", + "sampling": "nearest", + "filter_descriptor_sha256": "607c0fb49e6d3524972b9bfcbb88c5df644914e39f9474bcf9c9a86505e10bf9", + "filter_implementation_sha256": "939c552b50b02472b438c35c3036c6173bbbf2f610c7abcc0f3c8e1a9d37b13e", + "private_retroarch_config_sha256": "cf539c91ce78cdd96cb2c7f02252f4e4bb263c6c47879c22ce3f1457bce97190", + "retroarch_state_observed": "RUNNING", + "retroarch_process": "retroarch", + "core": "gambatte_libretro.so", + "recent_status": "recorded", + "global_retroarch_config_sha256_before": "336ee748ca06bba32582087efc867f811f465558df158056b9a25689bf36672e", + "global_retroarch_config_sha256_after": "336ee748ca06bba32582087efc867f811f465558df158056b9a25689bf36672e", + "canonical_settings_sha256_before": "31fe5ad0969bc8a9e831d10b77752da4712b30d0b96082c8243a6fa495316c9d", + "canonical_settings_sha256_after": "31fe5ad0969bc8a9e831d10b77752da4712b30d0b96082c8243a6fa495316c9d", + "wifi_state_before_during_and_after": "enabled and associated", + "rom_or_save_content_committed": false + }, + "teardown_limitation": { + "auto_baseline_result": "forced_kill_required", + "profile_result": "forced_kill_required", + "session_final_record": "FAILED", + "active_retroarch_processes_after": 0, + "aggregate_health_after": "healthy", + "clean_stop_certified": false, + "save_flush_certified": false, + "interpretation": "The identical Auto and profile teardown result indicates an existing game-smoke/session-stop limitation rather than evidence of a display-profile regression; it remains an explicit blocker for clean-stop and save-flush certification." + }, + "restoration": { + "candidate_launch_session_rolled_back": true, + "current_shell_manifest_exact_hashes_verified": true, + "final_shell_pid_count": 1, + "final_shell_rss_kib": 8960, + "final_shell_threads": 3, + "final_shell_file_descriptors": 9, + "final_wifi_state": "enabled and associated", + "final_update_phase": "known_good", + "final_aggregate_health": "healthy", + "final_retroarch_processes": 0, + "task_remote_temporary_files_removed": true + }, + "screenshots": { + "shell_restored": { + "path": "docs/validation/display/disp04-20260831/shell-restored.png", + "size": 17549, + "sha256": "37feba9ef9488b0e93caf4cff6264357dca6a51eb76f888630bca37be545b9d6", + "privacy_safe": true, + "framebuffer_pages_equal": true + }, + "running_game": { + "committed": false, + "usable_visual_evidence": false, + "reason": "The bounded framebuffer capture was black; no rendering claim is made and no private ROM-derived frame is published." + } + }, + "limitations": [ + "This is candidate-only SSH, process, config, integrity, and framebuffer evidence; it is not physical controls, LCD quality, audio, or gameplay certification.", + "The real RetroArch process reached canonical RUNNING, but neither the Auto comparison nor the selected profile completed a clean remote stop; save flush is not certified.", + "The running-game framebuffer capture was black, so this evidence does not certify visible filter output.", + "The GB profile was exercised on a Miyoo Mini Plus only; GBC and GBA remain host-validated, and Mini V1-V4 plus Mini Flip/my285 remain untested.", + "No Wi-Fi, network profile, credential, listener, route, ROM, Save, or global RetroArch configuration mutation was performed." + ] +} diff --git a/src/bloomLaunch/bloom_launch.c b/src/bloomLaunch/bloom_launch.c index d26b87f85..394a9f07a 100644 --- a/src/bloomLaunch/bloom_launch.c +++ b/src/bloomLaunch/bloom_launch.c @@ -972,7 +972,10 @@ int bloom_launch_copy_achievement_state(const char *source_path, const char *des int bloom_launch_write_display_config(const char *config_path, const char *scaling, const char *sampling, const char *screen_effect, const char *bezel, unsigned panel_width, - unsigned panel_height, char *effective_scaling, + unsigned panel_height, unsigned source_width, + unsigned source_height, unsigned aspect_numerator, + unsigned aspect_denominator, unsigned rotation_quarter_turns, + const char *effect_filter_path, char *effective_scaling, size_t effective_scaling_size, char *effective_effect, size_t effective_effect_size, char *effective_bezel, size_t effective_bezel_size, char *reason, @@ -985,7 +988,7 @@ int bloom_launch_write_display_config(const char *config_path, const char *scali BloomRetroarchConfig *composer = bloom_retroarch_config_create(); char config[4096]; size_t length = 0; - int composed = composer == NULL ? -1 : bloom_retroarch_config_apply_display(composer, scaling, sampling, screen_effect, bezel, panel_width, panel_height, effective_scaling, effective_scaling_size, effective_effect, effective_effect_size, effective_bezel, effective_bezel_size, reason, reason_size, error, error_size); + int composed = composer == NULL ? -1 : bloom_retroarch_config_apply_display(composer, scaling, sampling, screen_effect, bezel, panel_width, panel_height, source_width, source_height, aspect_numerator, aspect_denominator, rotation_quarter_turns, effect_filter_path, effective_scaling, effective_scaling_size, effective_effect, effective_effect_size, effective_bezel, effective_bezel_size, reason, reason_size, error, error_size); if (composer == NULL) set_error(error, error_size, "cannot allocate RetroArch config composer"); if (composed == 0) diff --git a/src/bloomLaunch/bloom_launch.h b/src/bloomLaunch/bloom_launch.h index 976332f0f..4390c5ea2 100644 --- a/src/bloomLaunch/bloom_launch.h +++ b/src/bloomLaunch/bloom_launch.h @@ -37,7 +37,10 @@ int bloom_launch_write_ra_config(const char *request_path, const char *config_pa int bloom_launch_write_display_config(const char *config_path, const char *scaling, const char *sampling, const char *screen_effect, const char *bezel, unsigned panel_width, - unsigned panel_height, char *effective_scaling, + unsigned panel_height, unsigned source_width, + unsigned source_height, unsigned aspect_numerator, + unsigned aspect_denominator, unsigned rotation_quarter_turns, + const char *effect_filter_path, char *effective_scaling, size_t effective_scaling_size, char *effective_effect, size_t effective_effect_size, char *effective_bezel, size_t effective_bezel_size, char *reason, diff --git a/src/bloomLaunch/bloom_retroarch_config.c b/src/bloomLaunch/bloom_retroarch_config.c index 349ad88fb..4ae2e51da 100644 --- a/src/bloomLaunch/bloom_retroarch_config.c +++ b/src/bloomLaunch/bloom_retroarch_config.c @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -206,10 +207,37 @@ static bool selection(const char *value, const char *const *allowed, size_t coun return false; } +static bool safe_filter_path(const char *path) +{ + static const char prefix[] = "/mnt/SDCARD/RetroArch/.retroarch/filters/video/"; + if (path == NULL || strncmp(path, prefix, sizeof(prefix) - 1) != 0 || + path[sizeof(prefix) - 1] == '\0' || strlen(path) >= 256 || strstr(path, "..") != NULL || + strcmp(path + strlen(path) - strlen(".filt"), ".filt") != 0) + return false; + for (const unsigned char *cursor = (const unsigned char *)path; *cursor; ++cursor) + if (*cursor < 0x21 || *cursor > 0x7e || *cursor == '"' || *cursor == '\\') + return false; + return true; +} + +static unsigned greatest_common_divisor(unsigned left, unsigned right) +{ + while (right != 0) { + unsigned remainder = left % right; + left = right; + right = remainder; + } + return left; +} + int bloom_retroarch_config_apply_display(BloomRetroarchConfig *config, const char *scaling, const char *sampling, const char *screen_effect, const char *bezel, unsigned panel_width, unsigned panel_height, + unsigned source_width, unsigned source_height, + unsigned aspect_numerator, unsigned aspect_denominator, + unsigned rotation_quarter_turns, + const char *effect_filter_path, char *effective_scaling, size_t effective_scaling_size, char *effective_effect, size_t effective_effect_size, char *effective_bezel, size_t effective_bezel_size, @@ -227,7 +255,12 @@ int bloom_retroarch_config_apply_display(BloomRetroarchConfig *config, !selection(sampling, sampling_values, sizeof(sampling_values) / sizeof(sampling_values[0])) || !selection(screen_effect, effect_values, sizeof(effect_values) / sizeof(effect_values[0])) || !selection(bezel, bezel_values, sizeof(bezel_values) / sizeof(bezel_values[0])) || - panel_width > 4096 || panel_height > 4096) { + panel_width > 4096 || panel_height > 4096 || source_width > 4096 || + source_height > 4096 || aspect_numerator > 65535 || aspect_denominator > 65535 || + rotation_quarter_turns > 3 || + ((source_width == 0 || source_height == 0 || aspect_numerator == 0 || aspect_denominator == 0) && + (source_width != 0 || source_height != 0 || aspect_numerator != 0 || aspect_denominator != 0)) || + (effect_filter_path != NULL && !safe_filter_path(effect_filter_path))) { set_error(error, error_size, "display config input is invalid"); return -1; } @@ -235,7 +268,14 @@ int bloom_retroarch_config_apply_display(BloomRetroarchConfig *config, snprintf(effective_effect, effective_effect_size, "%s", screen_effect); snprintf(effective_bezel, effective_bezel_size, "%s", bezel); reason[0] = '\0'; - if ((strcmp(scaling, "integer-pixels") == 0 || strcmp(scaling, "correct-aspect") == 0)) { + unsigned viewport_width = 0; + unsigned viewport_height = 0; + unsigned viewport_x = 0; + unsigned viewport_y = 0; + bool source_available = source_width != 0 && source_height != 0 && + aspect_numerator != 0 && aspect_denominator != 0; + if ((strcmp(scaling, "integer-pixels") == 0 || strcmp(scaling, "correct-aspect") == 0) && + (!source_available || panel_width == 0 || panel_height == 0)) { snprintf(effective_scaling, effective_scaling_size, "auto"); snprintf(reason, reason_size, "source_geometry_unavailable"); } @@ -243,8 +283,65 @@ int bloom_retroarch_config_apply_display(BloomRetroarchConfig *config, snprintf(effective_scaling, effective_scaling_size, "auto"); snprintf(reason, reason_size, "framebuffer_unavailable"); } - if (strcmp(screen_effect, "lcd") == 0 || strcmp(screen_effect, "crt") == 0 || - strcmp(screen_effect, "custom") == 0) { + if (strcmp(effective_scaling, "integer-pixels") == 0) { + unsigned raster_width = source_width; + unsigned raster_height = source_height; + if (rotation_quarter_turns % 2 != 0) { + raster_width = source_height; + raster_height = source_width; + } + unsigned integer_scale = panel_width / raster_width; + unsigned vertical_scale = panel_height / raster_height; + if (vertical_scale < integer_scale) + integer_scale = vertical_scale; + if (integer_scale == 0) { + snprintf(effective_scaling, effective_scaling_size, "auto"); + snprintf(reason, reason_size, "source_geometry_unavailable"); + } + else { + viewport_width = raster_width * integer_scale; + viewport_height = raster_height * integer_scale; + } + } + else if (strcmp(effective_scaling, "correct-aspect") == 0) { + unsigned numerator = aspect_numerator; + unsigned denominator = aspect_denominator; + unsigned divisor = greatest_common_divisor(numerator, denominator); + numerator /= divisor; + denominator /= divisor; + if (rotation_quarter_turns % 2 != 0) { + unsigned swap = numerator; + numerator = denominator; + denominator = swap; + } + if ((uint64_t)panel_width * denominator <= (uint64_t)panel_height * numerator) { + viewport_width = panel_width; + viewport_height = (unsigned)(((uint64_t)panel_width * denominator + numerator / 2) / numerator); + } + else { + viewport_height = panel_height; + viewport_width = (unsigned)(((uint64_t)panel_height * numerator + denominator / 2) / denominator); + } + if (viewport_width == 0) + viewport_width = 1; + if (viewport_height == 0) + viewport_height = 1; + if (viewport_width > panel_width) + viewport_width = panel_width; + if (viewport_height > panel_height) + viewport_height = panel_height; + } + if (viewport_width != 0 && viewport_height != 0) { + viewport_x = (panel_width - viewport_width) / 2; + viewport_y = (panel_height - viewport_height) / 2; + } + if ((strcmp(screen_effect, "lcd") == 0 || strcmp(screen_effect, "crt") == 0) && + effect_filter_path == NULL) { + snprintf(effective_effect, effective_effect_size, "auto"); + if (reason[0] == '\0') + snprintf(reason, reason_size, "effect_profile_unavailable"); + } + else if (strcmp(screen_effect, "custom") == 0) { snprintf(effective_effect, effective_effect_size, "auto"); if (reason[0] == '\0') snprintf(reason, reason_size, "effect_profile_unavailable"); @@ -288,6 +385,23 @@ int bloom_retroarch_config_apply_display(BloomRetroarchConfig *config, DISPLAY_BOOL("video_scale_integer", 0); DISPLAY_BOOL("video_scale_integer_overscale", 0); } + else if (strcmp(effective_scaling, "integer-pixels") == 0 || + strcmp(effective_scaling, "correct-aspect") == 0) { + DISPLAY_UINT("aspect_ratio_index", 23); + DISPLAY_UINT("custom_viewport_height", viewport_height); + DISPLAY_UINT("custom_viewport_width", viewport_width); + DISPLAY_UINT("custom_viewport_x", viewport_x); + DISPLAY_UINT("custom_viewport_y", viewport_y); + DISPLAY_BOOL("video_aspect_ratio_auto", 0); + DISPLAY_BOOL("video_crop_overscan", 0); + DISPLAY_BOOL("video_dingux_ipu_keep_aspect", 1); + DISPLAY_BOOL("video_force_aspect", 1); + DISPLAY_UINT("video_fullscreen_x", panel_width); + DISPLAY_UINT("video_fullscreen_y", panel_height); + DISPLAY_UINT("video_rotation", rotation_quarter_turns); + DISPLAY_BOOL("video_scale_integer", strcmp(effective_scaling, "integer-pixels") == 0); + DISPLAY_BOOL("video_scale_integer_overscale", 0); + } if (strcmp(sampling, "nearest") == 0) { DISPLAY_UINT("video_dingux_ipu_filter_type", 0); DISPLAY_BOOL("video_smooth", 0); @@ -300,6 +414,10 @@ int bloom_retroarch_config_apply_display(BloomRetroarchConfig *config, DISPLAY_STRING("video_filter", ""); DISPLAY_BOOL("video_shader_enable", 0); } + else if (strcmp(effective_effect, "lcd") == 0 || strcmp(effective_effect, "crt") == 0) { + DISPLAY_STRING("video_filter", effect_filter_path); + DISPLAY_BOOL("video_shader_enable", 0); + } if (strcmp(effective_bezel, "none") == 0) { DISPLAY_STRING("input_overlay", ""); DISPLAY_BOOL("input_overlay_enable", 0); diff --git a/src/bloomLaunch/bloom_retroarch_config.h b/src/bloomLaunch/bloom_retroarch_config.h index ef92e908f..b7c5ed262 100644 --- a/src/bloomLaunch/bloom_retroarch_config.h +++ b/src/bloomLaunch/bloom_retroarch_config.h @@ -29,6 +29,10 @@ int bloom_retroarch_config_apply_display(BloomRetroarchConfig *config, const char *scaling, const char *sampling, const char *screen_effect, const char *bezel, unsigned panel_width, unsigned panel_height, + unsigned source_width, unsigned source_height, + unsigned aspect_numerator, unsigned aspect_denominator, + unsigned rotation_quarter_turns, + const char *effect_filter_path, char *effective_scaling, size_t effective_scaling_size, char *effective_effect, size_t effective_effect_size, char *effective_bezel, size_t effective_bezel_size, diff --git a/src/bloomLaunch/main.c b/src/bloomLaunch/main.c index 08340d8ce..dbdface23 100644 --- a/src/bloomLaunch/main.c +++ b/src/bloomLaunch/main.c @@ -14,7 +14,7 @@ static void usage(void) " bloom-launch resolve-achievements REQUEST.json ENABLED MODE OFFLINE_CASUAL PROXY_READY RA_GAME_ID CERTIFICATION\n" " bloom-launch set-achievements-state REQUEST.json ENABLED REQUESTED EFFECTIVE TRANSPORT REASON|- RA_GAME_ID CERTIFICATION\n" " bloom-launch copy-achievements-state SOURCE.json DESTINATION.json\n" - " bloom-launch write-display-config CONFIG SCALING SAMPLING EFFECT BEZEL WIDTH HEIGHT\n" + " bloom-launch write-display-config CONFIG SCALING SAMPLING EFFECT BEZEL WIDTH HEIGHT SOURCE_WIDTH SOURCE_HEIGHT ASPECT_NUMERATOR ASPECT_DENOMINATOR ROTATION FILTER|-\n" " bloom-launch attach-config REQUEST.json CONFIG\n" " bloom-launch write-ra-config REQUEST.json CONFIG USERNAME PROXY_HOST|- < TOKEN\n" " bloom-launch write-legacy REQUEST.json COMMAND.sh\n"); @@ -76,22 +76,41 @@ int main(int argc, char **argv) result = bloom_launch_copy_achievement_state(argv[2], argv[3], error, sizeof(error)); else if (argc == 4 && strcmp(argv[1], "attach-config") == 0) result = bloom_launch_attach_session_config(argv[2], argv[3], error, sizeof(error)); - else if (argc == 9 && strcmp(argv[1], "write-display-config") == 0) { + else if (argc == 15 && strcmp(argv[1], "write-display-config") == 0) { char *width_end = NULL; char *height_end = NULL; + char *source_width_end = NULL; + char *source_height_end = NULL; + char *aspect_numerator_end = NULL; + char *aspect_denominator_end = NULL; + char *rotation_end = NULL; unsigned long width = strtoul(argv[7], &width_end, 10); unsigned long height = strtoul(argv[8], &height_end, 10); + unsigned long source_width = strtoul(argv[9], &source_width_end, 10); + unsigned long source_height = strtoul(argv[10], &source_height_end, 10); + unsigned long aspect_numerator = strtoul(argv[11], &aspect_numerator_end, 10); + unsigned long aspect_denominator = strtoul(argv[12], &aspect_denominator_end, 10); + unsigned long rotation = strtoul(argv[13], &rotation_end, 10); char effective_scaling[24] = {}; char effective_effect[16] = {}; char effective_bezel[16] = {}; char reason[48] = {}; if (width_end == argv[7] || *width_end != '\0' || height_end == argv[8] || - *height_end != '\0' || width > 4096 || height > 4096) { + *height_end != '\0' || source_width_end == argv[9] || *source_width_end != '\0' || + source_height_end == argv[10] || *source_height_end != '\0' || + aspect_numerator_end == argv[11] || *aspect_numerator_end != '\0' || + aspect_denominator_end == argv[12] || *aspect_denominator_end != '\0' || + rotation_end == argv[13] || *rotation_end != '\0' || width > 4096 || height > 4096 || + source_width > 4096 || source_height > 4096 || aspect_numerator > 65535 || + aspect_denominator > 65535 || rotation > 3) { usage(); return 2; } result = bloom_launch_write_display_config( argv[2], argv[3], argv[4], argv[5], argv[6], (unsigned)width, (unsigned)height, + (unsigned)source_width, (unsigned)source_height, (unsigned)aspect_numerator, + (unsigned)aspect_denominator, (unsigned)rotation, + strcmp(argv[14], "-") == 0 ? NULL : argv[14], effective_scaling, sizeof(effective_scaling), effective_effect, sizeof(effective_effect), effective_bezel, sizeof(effective_bezel), reason, sizeof(reason), error, sizeof(error)); diff --git a/static/build/.tmp_update/bin/bloom-dev-deploy b/static/build/.tmp_update/bin/bloom-dev-deploy index be05089f8..f76923abc 100755 --- a/static/build/.tmp_update/bin/bloom-dev-deploy +++ b/static/build/.tmp_update/bin/bloom-dev-deploy @@ -81,6 +81,7 @@ allowed_path() { launch-session:bin/bloom-launch-override|launch-session:bin/bloom-display-session-prepare|\ launch-session:bin/bloom-session|\ launch-session:bin/bloom-ra-session-prepare|\ + launch-session:config/display-built-in-profiles.json|\ launch-session:script/bloom-launch-trace.sh) return 0 ;; network-helpers:bin/bloom-settings|network-helpers:bin/bloom-network|\ network-helpers:bin/bloom-network-metadata|\ @@ -187,6 +188,7 @@ validate_manifest() { bin/bloom-ra-session-prepare \ bin/bloom-session \ bin/bloom-shell \ + config/display-built-in-profiles.json \ bin/gameSwitcher \ bin/keymon \ script/bloom-launch-trace.sh | sort)" diff --git a/static/build/.tmp_update/bin/bloom-display-session-prepare b/static/build/.tmp_update/bin/bloom-display-session-prepare index 0ae53e04b..b3553ac69 100755 --- a/static/build/.tmp_update/bin/bloom-display-session-prepare +++ b/static/build/.tmp_update/bin/bloom-display-session-prepare @@ -6,6 +6,8 @@ LAUNCH_BIN=${BLOOM_LAUNCH_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-launch} SETTINGS_BIN=${BLOOM_SETTINGS_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-settings} PLATFORM_BIN=${BLOOM_PLATFORM_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-platform} JQ_BIN=${BLOOM_JQ_BIN:-/mnt/SDCARD/.tmp_update/bin/jq} +PROFILE_POLICY=${BLOOM_DISPLAY_BUILT_IN_PROFILES:-/mnt/SDCARD/.tmp_update/config/display-built-in-profiles.json} +SHA256_BIN=${BLOOM_SHA256_BIN:-sha256sum} SESSION_ROOT=${BLOOM_SESSION_ROOT:-/tmp/bloom-session} fail() { printf 'bloom-display-session-prepare: %s\n' "$*" >&2; exit 1; } @@ -18,6 +20,10 @@ request=$1 emulator_type=$("$LAUNCH_BIN" get "$request" emulator_type 2>/dev/null) || fail "launch backend is unavailable" [ "$emulator_type" = retroarch ] || fail "display config requires RetroArch" +system_id=$("$LAUNCH_BIN" get "$request" system_id 2>/dev/null) || + fail "launch system is unavailable" +core=$("$LAUNCH_BIN" get "$request" core 2>/dev/null) || + fail "launch core is unavailable" settings_json=$("$SETTINGS_BIN" values 2>/dev/null) || fail "canonical settings are unavailable" display_json=$(printf '%s\n' "$settings_json" | "$JQ_BIN" -cer ' @@ -53,15 +59,118 @@ case "$panel_height" in ''|*[!0-9]*) panel_height=0 ;; esac [ "$panel_width" -le 4096 ] 2>/dev/null || panel_width=0 [ "$panel_height" -le 4096 ] 2>/dev/null || panel_height=0 +device_id=unknown +if [ -x "$PLATFORM_BIN" ]; then + case $("$PLATFORM_BIN" model 2>/dev/null || printf 'unknown') in + mini_plus) device_id=miyoo-mini-plus ;; + mini_flip) device_id=miyoo-mini-flip-my285 ;; + esac +fi +profile_id= +source_width=0 +source_height=0 +aspect_numerator=0 +aspect_denominator=0 +rotation=0 +effect_class= +effect_filter_path=- +effect_filter_bytes=0 +effect_filter_sha256= +effect_implementation_path= +effect_implementation_bytes=0 +effect_implementation_sha256= +if [ -f "$PROFILE_POLICY" ] && [ ! -L "$PROFILE_POLICY" ]; then + profile_fields=$("$JQ_BIN" -er --arg device "$device_id" --arg system "$system_id" --arg core "$core" ' + select( + .schema == 1 and .policy_id == "bloomos-display-built-in-profiles-v1" and + .scope.product == "ssd202d-only" and .scope.runtime_device == "miyoo-mini-plus" and + .scope.auto_selection == false and .scope.auto_behavior == "preserve-disp01-baseline" + ) | + [.profiles[] | + select( + .device_id == $device and .system_id == $system and .core_binary == $core and + .qualification.review_status == "source-reviewed" and + (.id | type) == "string" and (.source_geometry.width | type) == "number" and + (.source_geometry.height | type) == "number" and + (.source_geometry.rotation_quarter_turns | type) == "number" and + (.source_geometry.display_aspect.numerator | type) == "number" and + (.source_geometry.display_aspect.denominator | type) == "number" and + (.effect.class == "lcd" or .effect.class == "crt") and + (.effect.descriptor.runtime_path | type) == "string" and + (.effect.descriptor.bytes | type) == "number" and + (.effect.descriptor.sha256 | test("^[0-9a-f]{64}$")) and + (.effect.implementation.runtime_path | type) == "string" and + (.effect.implementation.bytes | type) == "number" and + (.effect.implementation.sha256 | test("^[0-9a-f]{64}$")) + ) + ] | + select(length == 1) | .[0] | + [.id, (.source_geometry.width | tostring), (.source_geometry.height | tostring), + (.source_geometry.display_aspect.numerator | tostring), + (.source_geometry.display_aspect.denominator | tostring), + (.source_geometry.rotation_quarter_turns | tostring), .effect.class, + .effect.descriptor.runtime_path, (.effect.descriptor.bytes | tostring), + .effect.descriptor.sha256, .effect.implementation.runtime_path, + (.effect.implementation.bytes | tostring), .effect.implementation.sha256] | + @tsv' "$PROFILE_POLICY" 2>/dev/null || true) + if [ -n "$profile_fields" ]; then + tab=$(printf '\t') + IFS="$tab" read -r profile_id source_width source_height aspect_numerator \ + aspect_denominator rotation effect_class effect_path effect_filter_bytes \ + effect_filter_sha256 effect_implementation_path effect_implementation_bytes \ + effect_implementation_sha256 </dev/null || profile_id= + [ "$source_height" -le 4096 ] 2>/dev/null || profile_id= + [ "$aspect_numerator" -le 65535 ] 2>/dev/null || profile_id= + [ "$aspect_denominator" -le 65535 ] 2>/dev/null || profile_id= + if [ -z "$profile_id" ]; then + source_width=0 + source_height=0 + aspect_numerator=0 + aspect_denominator=0 + rotation=0 + elif [ "$screen_effect" = "$effect_class" ] && command -v "$SHA256_BIN" >/dev/null 2>&1 && + [ -f "$effect_path" ] && [ ! -L "$effect_path" ] && + [ "$(wc -c <"$effect_path" | tr -d ' ')" = "$effect_filter_bytes" ] && + [ "$("$SHA256_BIN" "$effect_path" | awk '{print $1}')" = "$effect_filter_sha256" ] && + [ -f "$effect_implementation_path" ] && [ ! -L "$effect_implementation_path" ] && + [ "$(wc -c <"$effect_implementation_path" | tr -d ' ')" = "$effect_implementation_bytes" ] && + [ "$("$SHA256_BIN" "$effect_implementation_path" | awk '{print $1}')" = "$effect_implementation_sha256" ]; then + effect_filter_path=$effect_path + fi + fi +fi + +active_profile_id= +case "$scaling" in integer-pixels|correct-aspect) active_profile_id=$profile_id ;; esac +[ "$effect_filter_path" = - ] || active_profile_id=$profile_id + rm -f "$SESSION_ROOT/ra.cfg" result=$("$LAUNCH_BIN" write-display-config "$SESSION_ROOT/ra.cfg" "$scaling" "$sampling" \ - "$screen_effect" "$bezel" "$panel_width" "$panel_height") || + "$screen_effect" "$bezel" "$panel_width" "$panel_height" "$source_width" "$source_height" \ + "$aspect_numerator" "$aspect_denominator" "$rotation" "$effect_filter_path") || fail "display config could not be composed" +result=$(printf '%s\n' "$result" | "$JQ_BIN" -cer --arg profile "$active_profile_id" \ + '. + {profile_id: (if $profile == "" then null else $profile end)}') || + fail "display result could not be finalized" printf '%s\n' "$result" | "$JQ_BIN" -e \ '.schema == 1 and (.status == "applied" or .status == "degraded") and (.effective.scaling | type == "string") and (.effective.screen_effect | type == "string") and (.effective.bezel | type == "string") and + (.profile_id == null or (.profile_id | type) == "string") and (.reason == null or (.reason | type == "string"))' >/dev/null 2>&1 || fail "display result is invalid" printf '%s\n' "$result" diff --git a/test/shell/bloom_device.bats b/test/shell/bloom_device.bats index 381b116ec..ffa36fc97 100644 --- a/test/shell/bloom_device.bats +++ b/test/shell/bloom_device.bats @@ -316,6 +316,7 @@ PY bin/bloom-ra-session-prepare \ bin/bloom-session \ bin/bloom-shell \ + config/display-built-in-profiles.json \ bin/gameSwitcher \ bin/keymon \ script/bloom-launch-trace.sh; do @@ -323,12 +324,14 @@ PY printf '#!/bin/sh\nexit 0\n' >"$payload/$path" chmod 0755 "$payload/$path" done + cp /workspace/build/display-built-in-profiles.json \ + "$payload/config/display-built-in-profiles.json" export BLOOM_SOURCE_COMMIT=0123456789abcdef0123456789abcdef01234567 run "$DEVICE_TOOL" dev-stage test-plus launch-session "$payload" launch-bundle-1 [ "$status" -eq 0 ] - [ "$(jq '.files | length' "$BLOOM_TEST_ROOT/received-manifest.json")" -eq 11 ] + [ "$(jq '.files | length' "$BLOOM_TEST_ROOT/received-manifest.json")" -eq 12 ] rm "$payload/bin/keymon" : >"$MOCK_LOG" diff --git a/test/shell/dev_deploy.bats b/test/shell/dev_deploy.bats index fbfc885c0..08ea1020c 100644 --- a/test/shell/dev_deploy.bats +++ b/test/shell/dev_deploy.bats @@ -64,13 +64,14 @@ make_launch_manifest() { bin/bloom-ra-session-prepare \ bin/bloom-session \ bin/bloom-shell \ + config/display-built-in-profiles.json \ bin/gameSwitcher \ bin/keymon \ script/bloom-launch-trace.sh; do [ "$path" = "$omitted" ] || printf '%s\n' "$path" done | jq -Rn --arg id "$id" ' [inputs | select(length > 0) | - {path:.,size:0,sha256:"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",mode:"0755"}] + {path:.,size:0,sha256:"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",mode:(if startswith("bin/") or startswith("script/") then "0755" else "0644" end)}] | {schema:1,deployment_id:$id,component:"launch-session", base_commit:"0123456789abcdef0123456789abcdef01234567",files:.} ' >"$manifest" @@ -109,6 +110,7 @@ stage_launch_bundle() { bin/bloom-ra-session-prepare \ bin/bloom-session \ bin/bloom-shell \ + config/display-built-in-profiles.json \ bin/gameSwitcher \ bin/keymon \ script/bloom-launch-trace.sh; do @@ -119,6 +121,8 @@ exit 0 EOF chmod +x "$payload/$path" done + cp /workspace/build/display-built-in-profiles.json \ + "$payload/config/display-built-in-profiles.json" cat >"$payload/bin/bloom-session" <<'EOF' #!/bin/sh [ "$1:$2" = "status:--json" ] || exit 2 diff --git a/test/shell/display_built_in_profiles.bats b/test/shell/display_built_in_profiles.bats new file mode 100644 index 000000000..81c0b7d82 --- /dev/null +++ b/test/shell/display_built_in_profiles.bats @@ -0,0 +1,128 @@ +#!/usr/bin/env bats + +POLICY=/workspace/build/display-built-in-profiles.json +TOOL=/workspace/tools/display_built_in_profiles.py + +validate_profiles() { + python3 "$TOOL" validate --repository /workspace --policy "$1" +} + +resolve_profile() { + python3 "$TOOL" resolve --repository /workspace --policy "$POLICY" "$@" +} + +mutate_profiles() { + mode="$1" + output="$2" + python3 - "$POLICY" "$output" "$mode" <<'PY' +import json +import sys + +source, output, mode = sys.argv[1:] +policy = json.load(open(source, encoding="utf-8")) +if mode == "enable-auto": + policy["scope"]["auto_selection"] = True +elif mode == "unsupported-device": + policy["profiles"][0]["device_id"] = "rk3566-my355" +elif mode == "alternate-core": + policy["profiles"][0]["core_binary"] = "gearboy_libretro.so" +elif mode == "stale-filter": + policy["profiles"][0]["effect"]["descriptor"]["sha256"] = "0" * 64 +elif mode == "stale-implementation": + policy["profiles"][0]["effect"]["implementation"]["sha256"] = "0" * 64 +elif mode == "unsafe-path": + policy["profiles"][0]["effect"]["descriptor"]["runtime_path"] = "/tmp/evil.filt" +elif mode == "unreviewed": + policy["profiles"][0]["qualification"]["review_status"] = "assumed" +elif mode == "duplicate-selector": + policy["profiles"][1]["system_id"] = "gb" +elif mode == "unreduced-aspect": + policy["profiles"][0]["source_geometry"]["display_aspect"] = {"numerator": 20, "denominator": 18} +else: + raise SystemExit("unknown mutation") +json.dump(policy, open(output, "w", encoding="utf-8"), indent=2) +PY +} + +@test "built-in display authority validates exact reviewed Plus profiles and preserves Auto" { + run validate_profiles "$POLICY" + [ "$status" -eq 0 ] + [ "$output" = "built-in display profiles valid: 3 Plus profiles, 2 filter identities, Auto preserved" ] +} + +@test "signed payload packaging includes the exact built-in profile authority" { + grep -F '@cp $(ROOT_DIR)/build/display-built-in-profiles.json $(BUILD_DIR)/.tmp_update/config/display-built-in-profiles.json' /workspace/Makefile +} + +@test "pinned uninitialized RetroArch source retains exact reviewed license authority" { + repository="$BATS_TEST_TMPDIR/uninitialized-retroarch" + python3 - /workspace "$repository" <<'PY' +import pathlib +import shutil +import sys + +source = pathlib.Path(sys.argv[1]) +destination = pathlib.Path(sys.argv[2]) +paths = [ + ".gitmodules", + "build/core-selection-policy.json", + "build/dependencies.lock", + "build/display-auto-baseline.json", + "build/display-built-in-profiles.json", + "static/build/RetroArch/RetroArch_Dingux_forMiyooMini.txt", + "static/build/RetroArch/.retroarch/filters/video/Grids/LCD2x/LCD2x.filt", + "static/build/RetroArch/.retroarch/filters/video/Grids/LCD2x/lcd2x.so", + "static/build/RetroArch/.retroarch/filters/video/Grids/LCD3x/LCD3x_light.filt", + "static/build/RetroArch/.retroarch/filters/video/Grids/LCD3x/lcd3x_light.so", +] +for relative in paths: + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source / relative, target) +PY + + run python3 "$TOOL" validate --repository "$repository" \ + --policy "$repository/build/display-built-in-profiles.json" + [ "$status" -eq 0 ] + [ "$output" = "built-in display profiles valid: 3 Plus profiles, 2 filter identities, Auto preserved" ] +} + +@test "built-in profile resolution is exact by device system and core" { + run resolve_profile --device miyoo-mini-plus --system gb --core gambatte_libretro.so + [ "$status" -eq 0 ] + printf '%s' "$output" | jq -e ' + .status == "reviewed" and .profile_id == "plus-gb-gambatte-lcd3x-v1" and + .source_geometry == {width:160,height:144,rotation_quarter_turns:0, + display_aspect:{numerator:10,denominator:9},authority:"reviewed-system-core-profile"} and + .effect.class == "lcd" and .effect.bytes == 21 and + .effect.runtime_path == "/mnt/SDCARD/RetroArch/.retroarch/filters/video/Grids/LCD3x/LCD3x_light.filt"' >/dev/null + + run resolve_profile --device miyoo-mini-plus --system gba --core gpsp_libretro.so + [ "$status" -eq 0 ] + printf '%s' "$output" | jq -e '.status == "reviewed" and .source_geometry.width == 240 and .effect.id == "lcd2x"' >/dev/null +} + +@test "unreviewed devices systems and alternate cores resolve unavailable" { + for selector in \ + "miyoo-mini-v4 gb gambatte_libretro.so" \ + "miyoo-mini-flip-my285 gba gpsp_libretro.so" \ + "miyoo-mini-plus gb gearboy_libretro.so" \ + "miyoo-mini-plus nes fceumm_libretro.so"; do + set -- $selector + run resolve_profile --device "$1" --system "$2" --core "$3" + [ "$status" -eq 0 ] + [ "$output" = '{"profile_id":null,"schema":1,"status":"unavailable"}' ] + done +} + +@test "unsafe stale guessed coupled and Auto-changing profile mutations fail closed" { + for mode in enable-auto unsupported-device alternate-core stale-filter stale-implementation \ + unsafe-path unreviewed \ + duplicate-selector unreduced-aspect; do + candidate="$BATS_TEST_TMPDIR/$mode.json" + mutate_profiles "$mode" "$candidate" + run validate_profiles "$candidate" + [ "$status" -ne 0 ] + [[ "$output" == *"built-in display profiles:"* ]] + done +} diff --git a/test/shell/display_profile_policy.bats b/test/shell/display_profile_policy.bats index b32a093c9..1bdb7e9bf 100644 --- a/test/shell/display_profile_policy.bats +++ b/test/shell/display_profile_policy.bats @@ -35,7 +35,9 @@ elif mode == "unsafe-framebuffer-fallback": elif mode == "raw-custom-authority": policy["capability_contract"]["signed-custom-effect"]["authority"] = "raw-filesystem-path" elif mode == "premature-integration": - policy["scope"]["runtime_integration"] = "enabled" + policy["scope"]["runtime_integration"] = "deferred-to-DISP-03" +elif mode == "stale-built-ins": + policy["authorities"]["built_in_profiles"]["sha256"] = "0" * 64 else: raise SystemExit("unknown mutation") json.dump(policy, open(output, "w", encoding="utf-8"), indent=2) @@ -67,8 +69,8 @@ assert len(owned) == len(set(owned)) == 23 assert set(owned) == set(baseline["display_keys"]) assert policy["semantic_axes"]["sampling"]["public_control"] is None assert policy["scope"]["permanent_retroarch_config_mutation"] is False -assert policy["scope"]["runtime_integration"] == "deferred-to-DISP-03" -assert policy["scope"]["built_in_profiles"] == "deferred-to-DISP-04" +assert policy["scope"]["runtime_integration"] == "enabled-in-DISP-03" +assert policy["scope"]["built_in_profiles"] == "enabled-in-DISP-04" assert policy["scope"]["custom_packs"] == "deferred-to-DISP-05" payload = open(root + policy["authorities"]["auto_baseline"]["path"], "rb").read() assert hashlib.sha256(payload).hexdigest() == policy["authorities"]["auto_baseline"]["sha256"] @@ -146,7 +148,7 @@ PY @test "display profile policy rejects stale unsafe coupled and premature mutations" { for mode in stale-baseline unsupported-device cross-axis-key missing-mode custom-path \ - unsafe-framebuffer-fallback raw-custom-authority premature-integration; do + unsafe-framebuffer-fallback raw-custom-authority premature-integration stale-built-ins; do candidate="$BATS_TEST_TMPDIR/$mode.json" mutate_policy "$mode" "$candidate" run validate_policy "$candidate" diff --git a/test/shell/display_session_prepare.bats b/test/shell/display_session_prepare.bats index a7b517cb0..d8a551e34 100644 --- a/test/shell/display_session_prepare.bats +++ b/test/shell/display_session_prepare.bats @@ -8,6 +8,8 @@ setup() { export BLOOM_SETTINGS_BIN="$MOCK_BIN/bloom-settings" export BLOOM_PLATFORM_BIN="$MOCK_BIN/bloom-platform" export BLOOM_JQ_BIN="$(command -v jq)" + export BLOOM_SHA256_BIN="$(command -v sha256sum)" + export BLOOM_DISPLAY_BUILT_IN_PROFILES=/workspace/build/display-built-in-profiles.json mkdir -p "$BLOOM_SESSION_ROOT" export REQUEST="$BLOOM_TEST_ROOT/request.json" printf '%s\n' '{"schema":2}' >"$REQUEST" @@ -15,12 +17,19 @@ setup() { #!/bin/sh case "$1" in validate) [ -f "$2" ] ;; - get) [ "$3" = emulator_type ] && printf '%s\n' retroarch ;; + get) + case "$3" in + emulator_type) printf '%s\n' retroarch ;; + system_id) printf '%s\n' gb ;; + core) printf '%s\n' gambatte_libretro.so ;; + *) exit 1 ;; + esac + ;; write-display-config) printf '%s\n' "$*" >>"$MOCK_LOG" printf '%s\n' 'video_fullscreen = "true"' >"$2" chmod 600 "$2" - printf '%s\n' '{"schema":1,"status":"applied","effective":{"scaling":"fill","screen_effect":"none","bezel":"none"},"reason":null}' + printf '{"schema":1,"status":"applied","effective":{"scaling":"%s","screen_effect":"%s","bezel":"%s"},"reason":null}\n' "$3" "$5" "$6" ;; *) exit 1 ;; esac @@ -34,7 +43,7 @@ EOF chmod +x "$BLOOM_SETTINGS_BIN" cat >"$BLOOM_PLATFORM_BIN" <<'EOF' #!/bin/sh -case "$1" in display-width) printf '%s\n' 640 ;; display-height) printf '%s\n' 480 ;; *) exit 1 ;; esac +case "$1" in display-width) printf '%s\n' 640 ;; display-height) printf '%s\n' 480 ;; model) printf '%s\n' mini_plus ;; *) exit 1 ;; esac EOF chmod +x "$BLOOM_PLATFORM_BIN" } @@ -46,7 +55,7 @@ teardown() { teardown_bloom_fixture; } [ "$status" -eq 0 ] printf '%s' "$output" | "$BLOOM_JQ_BIN" -e '.status == "applied"' >/dev/null - grep -F "write-display-config $BLOOM_SESSION_ROOT/ra.cfg fill nearest none none 640 480" "$MOCK_LOG" + grep -F "write-display-config $BLOOM_SESSION_ROOT/ra.cfg fill nearest none none 640 480 160 144 10 9 0 -" "$MOCK_LOG" [ "$(stat -c '%a' "$BLOOM_SESSION_ROOT/ra.cfg")" = 600 ] } @@ -61,10 +70,53 @@ EOF run "$PREPARE" "$REQUEST" [ "$status" -eq 0 ] - grep -F "write-display-config $BLOOM_SESSION_ROOT/ra.cfg auto auto auto auto 640 480" "$MOCK_LOG" + grep -F "write-display-config $BLOOM_SESSION_ROOT/ra.cfg auto auto auto auto 640 480 160 144 10 9 0 -" "$MOCK_LOG" [ "$(stat -c '%a' "$BLOOM_SESSION_ROOT/ra.cfg")" = 600 ] } +@test "reviewed Plus geometry and exact LCD filter feed the composer without changing Auto" { + mkdir -p /mnt/SDCARD/RetroArch/.retroarch/filters/video/Grids/LCD3x + tr -d '\r' \ + /mnt/SDCARD/RetroArch/.retroarch/filters/video/Grids/LCD3x/LCD3x_light.filt + cp /workspace/static/build/RetroArch/.retroarch/filters/video/Grids/LCD3x/lcd3x_light.so \ + /mnt/SDCARD/RetroArch/.retroarch/filters/video/Grids/LCD3x/lcd3x_light.so + cat >"$BLOOM_SETTINGS_BIN" <<'EOF' +#!/bin/sh +printf '%s\n' '{"schema":1,"service":"bloom-settings","display":{"scaling":"integer-pixels","sampling":"nearest","screen_effect":"lcd","bezel":"auto"}}' +EOF + chmod +x "$BLOOM_SETTINGS_BIN" + + run "$PREPARE" "$REQUEST" + + [ "$status" -eq 0 ] + printf '%s' "$output" | "$BLOOM_JQ_BIN" -e \ + '.profile_id == "plus-gb-gambatte-lcd3x-v1" and .effective.screen_effect == "lcd"' >/dev/null + grep -F "write-display-config $BLOOM_SESSION_ROOT/ra.cfg integer-pixels nearest lcd auto 640 480 160 144 10 9 0 /mnt/SDCARD/RetroArch/.retroarch/filters/video/Grids/LCD3x/LCD3x_light.filt" "$MOCK_LOG" + rm -f /mnt/SDCARD/RetroArch/.retroarch/filters/video/Grids/LCD3x/LCD3x_light.filt + rm -f /mnt/SDCARD/RetroArch/.retroarch/filters/video/Grids/LCD3x/lcd3x_light.so +} + +@test "missing filter implementation withholds the effect path" { + mkdir -p /mnt/SDCARD/RetroArch/.retroarch/filters/video/Grids/LCD3x + tr -d '\r' \ + /mnt/SDCARD/RetroArch/.retroarch/filters/video/Grids/LCD3x/LCD3x_light.filt + cat >"$BLOOM_SETTINGS_BIN" <<'EOF' +#!/bin/sh +printf '%s\n' '{"schema":1,"service":"bloom-settings","display":{"scaling":"integer-pixels","sampling":"nearest","screen_effect":"lcd","bezel":"auto"}}' +EOF + chmod +x "$BLOOM_SETTINGS_BIN" + + run "$PREPARE" "$REQUEST" + + [ "$status" -eq 0 ] + printf '%s' "$output" | "$BLOOM_JQ_BIN" -e \ + '.profile_id == "plus-gb-gambatte-lcd3x-v1" and .effective.scaling == "integer-pixels"' >/dev/null + grep -F "write-display-config $BLOOM_SESSION_ROOT/ra.cfg integer-pixels nearest lcd auto 640 480 160 144 10 9 0 -" "$MOCK_LOG" + rm -f /mnt/SDCARD/RetroArch/.retroarch/filters/video/Grids/LCD3x/LCD3x_light.filt +} + @test "malformed canonical display settings fail without a session config" { cat >"$BLOOM_SETTINGS_BIN" <<'EOF' #!/bin/sh diff --git a/test/test_bloom_launch.cpp b/test/test_bloom_launch.cpp index c20ab47f4..0a83301f7 100644 --- a/test/test_bloom_launch.cpp +++ b/test/test_bloom_launch.cpp @@ -504,6 +504,7 @@ TEST_F(BloomLaunchTest, DisplayAndAchievementsShareOneTrustedSessionConfig) char reason[48] = {}; ASSERT_EQ(0, bloom_launch_write_display_config( config_path.c_str(), "fill", "linear", "none", "none", 640, 480, + 0, 0, 0, 0, 0, nullptr, scaling, sizeof(scaling), effect, sizeof(effect), bezel, sizeof(bezel), reason, sizeof(reason), error_, sizeof(error_))) << error_; diff --git a/test/test_bloom_retroarch_config.cpp b/test/test_bloom_retroarch_config.cpp index 8d0bf6387..5830c3feb 100644 --- a/test/test_bloom_retroarch_config.cpp +++ b/test/test_bloom_retroarch_config.cpp @@ -146,6 +146,7 @@ TEST_F(BloomRetroarchConfigTest, AppliesIndependentDisplayAxesAndSafeUnavailable char reason[48] = {}; ASSERT_EQ(0, bloom_retroarch_config_apply_display( config, "fill", "nearest", "none", "none", 640, 480, + 0, 0, 0, 0, 0, nullptr, scaling, sizeof(scaling), effect, sizeof(effect), bezel, sizeof(bezel), reason, sizeof(reason), error_, sizeof(error_))) << error_; @@ -172,6 +173,7 @@ TEST_F(BloomRetroarchConfigTest, AppliesIndependentDisplayAxesAndSafeUnavailable ASSERT_NE(nullptr, config); ASSERT_EQ(0, bloom_retroarch_config_apply_display( config, "integer-pixels", "auto", "lcd", "custom", 640, 480, + 0, 0, 0, 0, 0, nullptr, scaling, sizeof(scaling), effect, sizeof(effect), bezel, sizeof(bezel), reason, sizeof(reason), error_, sizeof(error_))) << error_; @@ -187,6 +189,82 @@ TEST_F(BloomRetroarchConfigTest, AppliesIndependentDisplayAxesAndSafeUnavailable bloom_retroarch_config_destroy(config); } +TEST_F(BloomRetroarchConfigTest, AppliesReviewedSourceGeometryAndFixedEffectPath) +{ + static const char filter[] = + "/mnt/SDCARD/RetroArch/.retroarch/filters/video/Grids/LCD3x/LCD3x_light.filt"; + BloomRetroarchConfig *config = bloom_retroarch_config_create(); + ASSERT_NE(nullptr, config); + char scaling[24] = {}; + char effect[16] = {}; + char bezel[16] = {}; + char reason[48] = {}; + ASSERT_EQ(0, bloom_retroarch_config_apply_display( + config, "integer-pixels", "nearest", "lcd", "auto", 640, 480, + 160, 144, 10, 9, 0, filter, + scaling, sizeof(scaling), effect, sizeof(effect), bezel, sizeof(bezel), + reason, sizeof(reason), error_, sizeof(error_))) + << error_; + EXPECT_STREQ("integer-pixels", scaling); + EXPECT_STREQ("lcd", effect); + EXPECT_STREQ("auto", bezel); + EXPECT_STREQ("", reason); + char output[4096] = {}; + size_t written = 0; + ASSERT_EQ(0, bloom_retroarch_config_render(config, output, sizeof(output), &written, + error_, sizeof(error_))) + << error_; + std::string rendered(output, written); + EXPECT_NE(rendered.find("custom_viewport_width = \"480\""), std::string::npos); + EXPECT_NE(rendered.find("custom_viewport_height = \"432\""), std::string::npos); + EXPECT_NE(rendered.find("custom_viewport_x = \"80\""), std::string::npos); + EXPECT_NE(rendered.find("custom_viewport_y = \"24\""), std::string::npos); + EXPECT_NE(rendered.find("video_scale_integer = \"true\""), std::string::npos); + EXPECT_NE(rendered.find(std::string("video_filter = \"") + filter + "\""), std::string::npos); + EXPECT_NE(rendered.find("video_shader_enable = \"false\""), std::string::npos); + bloom_retroarch_config_destroy(config); + + config = bloom_retroarch_config_create(); + ASSERT_NE(nullptr, config); + ASSERT_EQ(0, bloom_retroarch_config_apply_display( + config, "correct-aspect", "auto", "none", "none", 752, 560, + 256, 224, 4, 3, 0, nullptr, + scaling, sizeof(scaling), effect, sizeof(effect), bezel, sizeof(bezel), + reason, sizeof(reason), error_, sizeof(error_))) + << error_; + memset(output, 0, sizeof(output)); + written = 0; + ASSERT_EQ(0, bloom_retroarch_config_render(config, output, sizeof(output), &written, + error_, sizeof(error_))) + << error_; + rendered.assign(output, written); + EXPECT_NE(rendered.find("custom_viewport_width = \"747\""), std::string::npos); + EXPECT_NE(rendered.find("custom_viewport_height = \"560\""), std::string::npos); + EXPECT_NE(rendered.find("custom_viewport_x = \"2\""), std::string::npos); + EXPECT_NE(rendered.find("video_scale_integer = \"false\""), std::string::npos); + bloom_retroarch_config_destroy(config); +} + +TEST_F(BloomRetroarchConfigTest, RejectsUnreviewedEffectPathsBeforeAnyContribution) +{ + BloomRetroarchConfig *config = bloom_retroarch_config_create(); + ASSERT_NE(nullptr, config); + char scaling[24] = {}; + char effect[16] = {}; + char bezel[16] = {}; + char reason[48] = {}; + EXPECT_NE(0, bloom_retroarch_config_apply_display( + config, "auto", "auto", "lcd", "auto", 640, 480, + 160, 144, 10, 9, 0, "/tmp/unreviewed.filt", + scaling, sizeof(scaling), effect, sizeof(effect), bezel, sizeof(bezel), + reason, sizeof(reason), error_, sizeof(error_))); + char output[64] = {}; + size_t written = 0; + EXPECT_NE(0, bloom_retroarch_config_render(config, output, sizeof(output), &written, + error_, sizeof(error_))); + bloom_retroarch_config_destroy(config); +} + TEST_F(BloomRetroarchConfigTest, ImportsDisplayConfigBeforeAddingOtherOwnedContributions) { auto existing = root_ / "display.cfg"; diff --git a/test/test_ci_scope.sh b/test/test_ci_scope.sh index c6532c853..204bb1f51 100755 --- a/test/test_ci_scope.sh +++ b/test/test_ci_scope.sh @@ -79,6 +79,10 @@ expect "none" static-analysis "build/content-identity-policy.json tools/validate_content_identity_policy.py test/shell/content_identity_policy.bats" expect "bloomLaunch bloomShell gameSwitcher" build "src/bloomLaunch/bloom_launch.c" +expect "none" build "build/display-built-in-profiles.json +tools/display_built_in_profiles.py" +expect "none" static-analysis "build/display-built-in-profiles.json +tools/display_built_in_profiles.py" expect "playActivity gameSwitcher" build "src/playActivity/playActivityModel.c" expect "bloomSettings bloomShell" build "src/bloomSettings/bloom_settings.c src/bloomShell/main.c @@ -151,6 +155,13 @@ expect "/workspace/test/shell/libretro_artwork_policy.bats" shell-tests "build/l tools/validate_libretro_artwork_policy.py" expect "2" risk "build/libretro-artwork-policy.json tools/validate_libretro_artwork_policy.py" +expect "2" risk "build/display-built-in-profiles.json +tools/display_built_in_profiles.py" +expect "BloomRetroarchConfig*:BloomLaunch*" native-tests "build/display-built-in-profiles.json" +expect "/workspace/test/shell/display_built_in_profiles.bats /workspace/test/shell/display_profile_policy.bats /workspace/test/shell/display_session_prepare.bats" shell-tests "tools/display_built_in_profiles.py" +expect "BloomRetroarchConfig*:BloomLaunch*" native-tests "src/bloomLaunch/bloom_retroarch_config.c" +expect "/workspace/test/shell/display_session_prepare.bats" shell-tests "src/bloomLaunch/bloom_retroarch_config.h" +expect "/workspace/test/shell/display_built_in_profiles.bats /workspace/test/shell/display_session_prepare.bats" shell-tests "static/build/.tmp_update/bin/bloom-display-session-prepare" expect "full" native-tests "src/common/system/display.h" expect "none" native-tests "docs/ARCHITECTURE.md" expect "2" risk "src/sendkeys/sendkeys.c" diff --git a/tools/ci-scope.sh b/tools/ci-scope.sh index d86668af8..71bba9d6a 100755 --- a/tools/ci-scope.sh +++ b/tools/ci-scope.sh @@ -275,9 +275,11 @@ while IFS= read -r path; do build/core-inventory-disposition*.json|\ build/save-migration-policy*.json|\ build/transfer-policy*.json|\ + build/display-profile-policy.json|build/display-built-in-profiles.json|\ static/*|assets/ui/*|test/*|docs/*|tools/legacy_manifest.py|\ tools/generate_bloom_ui_assets.py|tools/project_bloom_lifecycle_assets.py|tools/validate_bloom_ui_assets.py|tools/requirements-ui-assets.txt|\ - tools/validate_core_selection_policy.py|tools/validate_core_inventory_disposition.py|tools/validate_save_migration_policy.py|tools/validate_transfer_policy.py|tools/ci-scope.sh) + tools/validate_core_selection_policy.py|tools/validate_core_inventory_disposition.py|tools/validate_save_migration_policy.py|tools/validate_transfer_policy.py|\ + tools/display_profile_policy.py|tools/display_built_in_profiles.py|tools/ci-scope.sh) ;; *) full=1 @@ -346,6 +348,29 @@ while IFS= read -r path; do add_native_filter 'BloomShellMedia*' add_shell_target /workspace/test/shell/libretro_artwork_policy.bats ;; + build/display-profile-policy.json|tools/display_profile_policy.py|\ + build/display-built-in-profiles.json|tools/display_built_in_profiles.py) + raise_risk 2 + add_native_filter 'BloomRetroarchConfig*' + add_native_filter 'BloomLaunch*' + add_shell_target /workspace/test/shell/display_built_in_profiles.bats + add_shell_target /workspace/test/shell/display_profile_policy.bats + add_shell_target /workspace/test/shell/display_session_prepare.bats + ;; + src/bloomLaunch/bloom_retroarch_config.c|src/bloomLaunch/bloom_retroarch_config.h|\ + test/test_bloom_retroarch_config.cpp) + raise_risk 2 + add_native_filter 'BloomRetroarchConfig*' + add_native_filter 'BloomLaunch*' + add_shell_target /workspace/test/shell/display_session_prepare.bats + ;; + static/build/.tmp_update/bin/bloom-display-session-prepare) + raise_risk 2 + add_native_filter 'BloomRetroarchConfig*' + add_native_filter 'BloomLaunch*' + add_shell_target /workspace/test/shell/display_built_in_profiles.bats + add_shell_target /workspace/test/shell/display_session_prepare.bats + ;; src/bloomLibrary/bloom_library_jobs.c|src/bloomLibrary/bloom_library_jobs.h|\ test/test_bloom_library_jobs.cpp) raise_risk 2 @@ -546,7 +571,12 @@ while IFS= read -r path; do raise_risk 2 add_shell_target /workspace/test/shell/bloom_device.bats ;; - static/build/.tmp_update/bin/bloom-dev-deploy|static/build/.tmp_update/runtime.sh) + static/build/.tmp_update/bin/bloom-dev-deploy) + raise_risk 3 + add_shell_target /workspace/test/shell/bloom_device.bats + add_shell_target /workspace/test/shell/dev_deploy.bats + ;; + static/build/.tmp_update/runtime.sh) raise_risk 3 ;; static/build/.tmp_update/script/network/*) diff --git a/tools/dev-deploy-manifest.py b/tools/dev-deploy-manifest.py index 075177fcb..dc13e9ddb 100755 --- a/tools/dev-deploy-manifest.py +++ b/tools/dev-deploy-manifest.py @@ -40,6 +40,7 @@ "bin/bloom-session", "bin/bloom-ra", "bin/bloom-ra-session-prepare", + "config/display-built-in-profiles.json", "script/bloom-launch-trace.sh", }, "network-helpers": { diff --git a/tools/display_built_in_profiles.py b/tools/display_built_in_profiles.py new file mode 100644 index 000000000..5dc7e07ce --- /dev/null +++ b/tools/display_built_in_profiles.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +"""Validate and resolve the DISP-04 built-in display profile authority.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import re +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional + + +DEVICES = [ + "miyoo-mini-v1", + "miyoo-mini-v2-v3", + "miyoo-mini-v4", + "miyoo-mini-plus", + "miyoo-mini-flip-my285", +] +TOP_LEVEL_KEYS = { + "schema", + "policy_id", + "scope", + "authorities", + "activation_contract", + "profiles", + "unsupported", +} +AUTHORITY_KEYS = {"core_selection_policy", "auto_baseline", "filter_contract", "retroarch_license"} +PROFILE_KEYS = { + "id", + "device_id", + "system_id", + "candidate_id", + "core_binary", + "source_geometry", + "effect", + "qualification", +} +SAFE_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$") +SAFE_CORE = re.compile(r"^[a-z0-9_]+_libretro\.so$") +SAFE_SHA256 = re.compile(r"^[0-9a-f]{64}$") +RUNTIME_FILTER_PREFIX = "/mnt/SDCARD/RetroArch/.retroarch/filters/video/" +SOURCE_FILTER_PREFIX = "static/build/RetroArch/.retroarch/filters/video/" +RETROARCH_LICENSE_AUTHORITY = { + "path": "third-party/RetroArch-patch/submodules/RetroArch/COPYING", + "hash_mode": "raw", + "sha256": "0b383d5a63da644f628d99c33976ea6487ed89aaa59f0b3257992deac1171e6b", +} +RETROARCH_PATCH_REVISION = "f9e959f7445d2ba0a4dd6279da41a095163767f2" +RETROARCH_SOURCE_REVISION = "69a4f0ea1e8aaf442ae4858f2e7f2b31a1776576" + + +def load_json(path: Path, label: str) -> Any: + if path.is_symlink() or not path.is_file(): + raise ValueError(f"{label} is unavailable or linked: {path}") + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise ValueError(f"{label} is unreadable: {error}") from error + + +def safe_repository_path(root: Path, relative: Any, label: str) -> Path: + if not isinstance(relative, str) or not relative or "\\" in relative: + raise ValueError(f"{label} path is invalid") + candidate = Path(relative) + if candidate.is_absolute() or any(part in ("", ".", "..") for part in candidate.parts): + raise ValueError(f"{label} path is unsafe") + resolved = root.joinpath(*candidate.parts) + if resolved.is_symlink() or not resolved.is_file(): + raise ValueError(f"{label} is unavailable or linked") + return resolved + + +def verified_file(root: Path, record: Any, label: str, include_size: bool = False) -> Path: + keys = {"source_path", "hash_mode", "bytes", "sha256"} if include_size else {"path", "hash_mode", "sha256"} + if not isinstance(record, dict) or set(record) != keys: + raise ValueError(f"{label} record is invalid") + relative = record.get("source_path") if include_size else record.get("path") + path = safe_repository_path(root, relative, label) + payload = path.read_bytes() + mode = record.get("hash_mode") + if mode == "text-lf": + payload = payload.replace(b"\r\n", b"\n") + if b"\r" in payload: + raise ValueError(f"{label} contains a bare carriage return") + elif mode != "raw": + raise ValueError(f"{label} hash mode is invalid") + if include_size: + size = record.get("bytes") + if not isinstance(size, int) or isinstance(size, bool) or size <= 0 or len(payload) != size: + raise ValueError(f"{label} byte count is stale") + else: + pass + expected = record.get("sha256") + if not isinstance(expected, str) or not SAFE_SHA256.fullmatch(expected) or hashlib.sha256(payload).hexdigest() != expected: + raise ValueError(f"{label} SHA-256 is stale") + return path + + +def locked_uninitialized_retroarch_license(root: Path, record: Any) -> bool: + """Accept the reviewed license hash only when its source submodule is absent but pinned.""" + if record != RETROARCH_LICENSE_AUTHORITY: + return False + patch_root = root / "third-party/RetroArch-patch" + source_root = patch_root / "submodules/RetroArch" + if patch_root.is_symlink() or source_root.is_symlink(): + return False + if (patch_root / ".git").exists() and (source_root / ".git").exists(): + return False + try: + modules = safe_repository_path(root, ".gitmodules", "submodule registry").read_text( + encoding="utf-8" + ) + lock = safe_repository_path( + root, "build/dependencies.lock", "dependency lock" + ).read_text(encoding="utf-8") + except (OSError, UnicodeError, ValueError): + return False + if "\tpath = third-party/RetroArch-patch\n" not in modules.replace("\r\n", "\n"): + return False + required_lock_lines = { + '"third-party/RetroArch-patch" = "{}"'.format(RETROARCH_PATCH_REVISION), + '"third-party/RetroArch-patch/submodules/RetroArch" = "{}"'.format( + RETROARCH_SOURCE_REVISION + ), + 'source_revision = "{}"'.format(RETROARCH_SOURCE_REVISION), + } + return required_lock_lines.issubset(set(lock.splitlines())) + + +def core_candidate(policy: Any, system_id: str, candidate_id: str) -> Optional[Dict[str, Any]]: + if not isinstance(policy, dict): + return None + for system in policy.get("systems", []): + if isinstance(system, dict) and system.get("id") == system_id: + for candidate in system.get("candidates", []): + if isinstance(candidate, dict) and candidate.get("id") == candidate_id: + return candidate + return None + + +def validate(policy: Any, root: Path) -> List[str]: + failures: List[str] = [] + if not isinstance(policy, dict) or set(policy) != TOP_LEVEL_KEYS: + return ["exact schema-1 built-in profile fields are required"] + if policy.get("schema") != 1 or policy.get("policy_id") != "bloomos-display-built-in-profiles-v1": + failures.append("stable schema-1 built-in profile authority is required") + + if policy.get("scope") != { + "product": "ssd202d-only", + "devices": DEVICES, + "runtime_device": "miyoo-mini-plus", + "auto_selection": False, + "auto_behavior": "preserve-disp01-baseline", + "physical_certification": False, + }: + failures.append("scope must remain Plus-only at runtime and preserve Auto without certification claims") + if policy.get("activation_contract") != { + "selection_key": "exact-device-system-core", + "review_status": "source-reviewed", + "missing_profile": "unavailable-without-mutation", + "filter_path": "fixed-signed-runtime-path-never-user-input", + "filter_integrity": "exact-size-and-sha256-before-session-composition", + "axis_independence": "profile-supplies-capabilities-without-changing-selections", + }: + failures.append("activation, integrity, and axis-independence contracts drift") + if policy.get("unsupported") != { + "devices": ["miyoo-mini-v1", "miyoo-mini-v2-v3", "miyoo-mini-v4", "miyoo-mini-flip-my285"], + "effect_classes": ["crt"], + "artwork": "no-reviewed-built-in-bezels", + "alternate_cores": "explicitly-unavailable-until-reviewed", + "behavior": "preserve-auto-or-report-unavailable-without-partial-config", + }: + failures.append("unsupported device, CRT, bezel, and alternate-core dispositions must remain explicit") + + authorities = policy.get("authorities") + if not isinstance(authorities, dict) or set(authorities) != AUTHORITY_KEYS: + failures.append("complete core, Auto, filter-contract, and license authorities are required") + authorities = {} + core_policy: Any = {} + baseline: Any = {} + filter_contract = "" + license_text = "" + license_authority_deferred = False + for key, label in ( + ("core_selection_policy", "core-selection policy"), + ("auto_baseline", "Auto baseline"), + ("filter_contract", "RetroArch filter contract"), + ("retroarch_license", "RetroArch license"), + ): + try: + path = verified_file(root, authorities.get(key), label) + if key == "core_selection_policy": + core_policy = load_json(path, label) + elif key == "auto_baseline": + baseline = load_json(path, label) + elif key == "filter_contract": + filter_contract = path.read_text(encoding="utf-8") + else: + license_text = path.read_text(encoding="utf-8") + except (OSError, UnicodeError, ValueError) as error: + if key == "retroarch_license" and locked_uninitialized_retroarch_license( + root, authorities.get(key) + ): + license_authority_deferred = True + else: + failures.append(str(error)) + if core_policy.get("policy_id") != "bloomos-core-selection-v1": + failures.append("profiles must join the canonical core-selection authority") + if baseline.get("policy_id") != "bloomos-display-auto-baseline-v1": + failures.append("profiles must join the DISP-01 Auto authority") + if "LCD filters should always be used with Integer:ON Aspect:ON" not in filter_contract or "up to 320pixels width for 2x" not in filter_contract or "up to 213pixels width for 3x" not in filter_contract: + failures.append("packaged Miyoo filter constraints are unavailable") + if not license_authority_deferred and ( + "GNU GENERAL PUBLIC LICENSE" not in license_text or "Version 3" not in license_text + ): + failures.append("RetroArch GPL-3.0 license authority is unavailable") + + profiles = policy.get("profiles") + if not isinstance(profiles, list) or not profiles: + return failures + ["at least one reviewed built-in profile is required"] + seen_ids = set() + seen_keys = set() + baseline_pairs = { + (row.get("system_id"), row.get("core_binary")) + for row in baseline.get("systems", []) + if isinstance(row, dict) + } + for index, profile in enumerate(profiles): + label = f"profile {index}" + if not isinstance(profile, dict) or set(profile) != PROFILE_KEYS: + failures.append(f"{label}: exact fields are required") + continue + profile_id = profile.get("id") + device_id = profile.get("device_id") + system_id = profile.get("system_id") + candidate_id = profile.get("candidate_id") + core_binary = profile.get("core_binary") + if not isinstance(profile_id, str) or not SAFE_ID.fullmatch(profile_id) or profile_id in seen_ids: + failures.append(f"{label}: profile ID is invalid or duplicated") + seen_ids.add(profile_id) + selection_key = (device_id, system_id, core_binary) + if selection_key in seen_keys: + failures.append(f"{label}: exact device/system/core selector is duplicated") + seen_keys.add(selection_key) + if device_id != "miyoo-mini-plus" or not isinstance(system_id, str) or not SAFE_ID.fullmatch(system_id) or not isinstance(candidate_id, str) or not SAFE_ID.fullmatch(candidate_id) or not isinstance(core_binary, str) or not SAFE_CORE.fullmatch(core_binary): + failures.append(f"{label}: selector is outside the reviewed Plus/system/core boundary") + candidate = core_candidate(core_policy, system_id, candidate_id) + if candidate is None or candidate.get("emulator_id") != "libretro:" + core_binary[:-len("_libretro.so")] or "miyoo-mini-plus" not in candidate.get("default_for_devices", []): + failures.append(f"{label}: selector does not name the canonical Plus default candidate") + if (system_id, core_binary) not in baseline_pairs: + failures.append(f"{label}: selector is absent from the exact Auto baseline") + + geometry = profile.get("source_geometry") + if not isinstance(geometry, dict) or set(geometry) != {"width", "height", "rotation_quarter_turns", "display_aspect", "authority"}: + failures.append(f"{label}: exact source geometry is required") + else: + width = geometry.get("width") + height = geometry.get("height") + rotation = geometry.get("rotation_quarter_turns") + aspect = geometry.get("display_aspect") + if not isinstance(width, int) or isinstance(width, bool) or not 1 <= width <= 4096 or not isinstance(height, int) or isinstance(height, bool) or not 1 <= height <= 4096 or rotation not in (0, 1, 2, 3) or geometry.get("authority") != "reviewed-system-core-profile": + failures.append(f"{label}: source geometry bounds or authority are invalid") + if not isinstance(aspect, dict) or set(aspect) != {"numerator", "denominator"}: + failures.append(f"{label}: reduced display aspect is required") + else: + numerator = aspect.get("numerator") + denominator = aspect.get("denominator") + if not isinstance(numerator, int) or isinstance(numerator, bool) or not isinstance(denominator, int) or isinstance(denominator, bool) or not 1 <= numerator <= 65535 or not 1 <= denominator <= 65535 or math.gcd(numerator, denominator) != 1: + failures.append(f"{label}: display aspect must be positive, bounded, and reduced") + + effect = profile.get("effect") + if not isinstance(effect, dict) or set(effect) != {"id", "class", "descriptor", "implementation"} or effect.get("class") not in ("lcd", "crt") or not isinstance(effect.get("id"), str) or not SAFE_ID.fullmatch(effect.get("id", "")): + failures.append(f"{label}: exact bounded LCD/CRT effect identity is required") + else: + descriptor = effect.get("descriptor") + implementation = effect.get("implementation") + descriptor_text = None + if not isinstance(descriptor, dict) or set(descriptor) != {"source_path", "runtime_path", "hash_mode", "bytes", "sha256"}: + failures.append(f"{label}: exact filter descriptor identity is required") + else: + source_path = descriptor.get("source_path") + runtime_path = descriptor.get("runtime_path") + expected_runtime = "/mnt/SDCARD/" + source_path[len("static/build/"):] if isinstance(source_path, str) and source_path.startswith(SOURCE_FILTER_PREFIX) else "" + if runtime_path != expected_runtime or not isinstance(runtime_path, str) or not runtime_path.startswith(RUNTIME_FILTER_PREFIX) or ".." in runtime_path or "\\" in runtime_path or any(ord(character) < 0x21 or ord(character) > 0x7e or character == '"' for character in runtime_path): + failures.append(f"{label}: runtime filter path is not fixed and safe") + try: + descriptor_path = verified_file(root, { + "source_path": source_path, + "hash_mode": descriptor.get("hash_mode"), + "bytes": descriptor.get("bytes"), + "sha256": descriptor.get("sha256"), + }, f"{label} descriptor", True) + descriptor_text = descriptor_path.read_text(encoding="utf-8") + if not re.fullmatch(r"filter = [a-z0-9_]+\n", descriptor_text): + failures.append(f"{label}: filter descriptor is not a single bounded built-in identity") + except (OSError, UnicodeError, ValueError) as error: + failures.append(str(error)) + if not isinstance(implementation, dict) or set(implementation) != {"source_path", "runtime_path", "hash_mode", "bytes", "sha256"}: + failures.append(f"{label}: exact filter implementation identity is required") + else: + implementation_source = implementation.get("source_path") + implementation_runtime = implementation.get("runtime_path") + expected_implementation_runtime = "/mnt/SDCARD/" + implementation_source[len("static/build/"):] if isinstance(implementation_source, str) and implementation_source.startswith(SOURCE_FILTER_PREFIX) else "" + if implementation_runtime != expected_implementation_runtime or not isinstance(implementation_runtime, str) or not implementation_runtime.startswith(RUNTIME_FILTER_PREFIX) or ".." in implementation_runtime or "\\" in implementation_runtime or any(ord(character) < 0x21 or ord(character) > 0x7e or character == '"' for character in implementation_runtime): + failures.append(f"{label}: runtime implementation path is not fixed and safe") + try: + implementation_path = verified_file(root, { + "source_path": implementation_source, + "hash_mode": implementation.get("hash_mode"), + "bytes": implementation.get("bytes"), + "sha256": implementation.get("sha256"), + }, f"{label} implementation", True) + if implementation_path.suffix != ".so": + failures.append(f"{label}: implementation is not a packaged filter binary") + elif descriptor_text is not None and descriptor_text != f"filter = {implementation_path.stem}\n": + failures.append(f"{label}: descriptor does not select its exact implementation") + except ValueError as error: + failures.append(str(error)) + + qualification = profile.get("qualification") + if not isinstance(qualification, dict) or set(qualification) != {"review_status", "device_evidence_status", "evidence_ids"} or qualification.get("review_status") != "source-reviewed" or qualification.get("device_evidence_status") not in ("pending", "remote-pass", "physical-pass") or qualification.get("evidence_ids") != ["retroarch-miyoomini-filter-contract"]: + failures.append(f"{label}: source review and honest device-evidence status are required") + + if [profile.get("id") for profile in profiles if isinstance(profile, dict)] != [ + "plus-gb-gambatte-lcd3x-v1", + "plus-gbc-gambatte-lcd3x-v1", + "plus-gba-gpsp-lcd2x-v1", + ]: + failures.append("profiles must retain the stable GB, GBC, and GBA order") + required = { + ("miyoo-mini-plus", "gb", "gambatte_libretro.so"), + ("miyoo-mini-plus", "gbc", "gambatte_libretro.so"), + ("miyoo-mini-plus", "gba", "gpsp_libretro.so"), + } + if seen_keys != required: + failures.append("exact reviewed Plus GB, GBC, and GBA default profile set is required") + return failures + + +def resolve(policy: Dict[str, Any], device: str, system: str, core: str) -> Dict[str, Any]: + matches = [ + profile + for profile in policy["profiles"] + if profile["device_id"] == device and profile["system_id"] == system and profile["core_binary"] == core and profile["qualification"]["review_status"] == "source-reviewed" + ] + if len(matches) != 1: + return {"schema": 1, "status": "unavailable", "profile_id": None} + profile = matches[0] + return { + "schema": 1, + "status": "reviewed", + "profile_id": profile["id"], + "source_geometry": profile["source_geometry"], + "effect": { + "id": profile["effect"]["id"], + "class": profile["effect"]["class"], + "runtime_path": profile["effect"]["descriptor"]["runtime_path"], + "bytes": profile["effect"]["descriptor"]["bytes"], + "sha256": profile["effect"]["descriptor"]["sha256"], + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("command", choices=("validate", "resolve")) + parser.add_argument("--repository", type=Path, required=True) + parser.add_argument("--policy", type=Path, required=True) + parser.add_argument("--device") + parser.add_argument("--system") + parser.add_argument("--core") + args = parser.parse_args() + try: + root = args.repository.resolve() + if not root.is_dir(): + raise ValueError("repository root is unavailable") + policy = load_json(args.policy.resolve(), "built-in display profiles") + failures = validate(policy, root) + if failures: + for failure in failures: + print(f"built-in display profiles: {failure}", file=sys.stderr) + return 1 + if args.command == "validate": + print("built-in display profiles valid: 3 Plus profiles, 2 filter identities, Auto preserved") + return 0 + if not args.device or not args.system or not args.core: + raise ValueError("resolve requires device, system, and core") + print(json.dumps(resolve(policy, args.device, args.system, args.core), separators=(",", ":"), sort_keys=True)) + return 0 + except (OSError, UnicodeError, ValueError) as error: + print(f"built-in display profiles: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/display_profile_policy.py b/tools/display_profile_policy.py index 7ae81a7e9..c768af7df 100644 --- a/tools/display_profile_policy.py +++ b/tools/display_profile_policy.py @@ -219,8 +219,8 @@ def validate(policy: Any, root: Path) -> list[str]: expected_scope = { "product": "ssd202d-only", "devices": DEVICES, - "runtime_integration": "deferred-to-DISP-03", - "built_in_profiles": "deferred-to-DISP-04", + "runtime_integration": "enabled-in-DISP-03", + "built_in_profiles": "enabled-in-DISP-04", "custom_packs": "deferred-to-DISP-05", "permanent_retroarch_config_mutation": False, "physical_certification": False, @@ -229,11 +229,12 @@ def validate(policy: Any, root: Path) -> list[str]: failures.append("scope must remain SSD202D-only and preserve deferred integration boundaries") authorities = policy.get("authorities") - if not isinstance(authorities, dict) or set(authorities) != {"auto_baseline", "platform_helper"}: - failures.append("DISP-01 and PLAT-01 authority records are required") + if not isinstance(authorities, dict) or set(authorities) != {"auto_baseline", "platform_helper", "built_in_profiles"}: + failures.append("DISP-01, PLAT-01, and DISP-04 authority records are required") authorities = {} baseline = None platform_source = "" + built_in_profiles = None try: baseline_path = authority_file(root, authorities.get("auto_baseline"), "Auto baseline") baseline = load_json(baseline_path, "Auto baseline") @@ -244,6 +245,11 @@ def validate(policy: Any, root: Path) -> list[str]: platform_source = platform_path.read_text(encoding="utf-8") except (ValueError, OSError, UnicodeError) as error: failures.append(str(error)) + try: + profiles_path = authority_file(root, authorities.get("built_in_profiles"), "built-in profiles") + built_in_profiles = load_json(profiles_path, "built-in profiles") + except ValueError as error: + failures.append(str(error)) if not isinstance(baseline, dict) or baseline.get("policy_id") != "bloomos-display-auto-baseline-v1": failures.append("authority must join the DISP-01 Auto baseline") @@ -272,6 +278,13 @@ def validate(policy: Any, root: Path) -> list[str]: ): if token not in platform_source: failures.append(f"platform helper lacks required geometry contract: {token}") + if ( + not isinstance(built_in_profiles, dict) + or built_in_profiles.get("policy_id") != "bloomos-display-built-in-profiles-v1" + or built_in_profiles.get("scope", {}).get("auto_behavior") != "preserve-disp01-baseline" + or built_in_profiles.get("scope", {}).get("auto_selection") is not False + ): + failures.append("DISP-04 authority must preserve the DISP-01 Auto baseline") if policy.get("public_controls") != PUBLIC_CONTROLS: failures.append("public controls must be Scaling, Screen Effect, and Bezel with exact ordered values")