diff --git a/bin/raptor-rust b/bin/raptor-rust index af9e370e..bebc636f 100755 Binary files a/bin/raptor-rust and b/bin/raptor-rust differ diff --git a/bin/raptor-rust.exe b/bin/raptor-rust.exe index 20e5eb1b..93efff40 100755 Binary files a/bin/raptor-rust.exe and b/bin/raptor-rust.exe differ diff --git a/docs/hd-backlog.md b/docs/hd-backlog.md index e356e312..7fa526e1 100644 --- a/docs/hd-backlog.md +++ b/docs/hd-backlog.md @@ -1,6 +1,6 @@ # Original artwork HD backlog -Current artwork/runtime checkpoint — 17 October 2026: the registered and assembled pack contains **5,425 frames / 3,587 complete sequences**, including **4,300 Terrain pictures**. **328 originals await approval**: 235 Terrain, 59 enemies/ground objects, 33 UI/other and one boss. This includes held drafts and actual refusals. The six added families contain exactly eight frames: 1033c, 200e6, 30052, 30070–71, 30089–8a and 3009c. Their 416 retained Original logical cells are not newly generated HD. Six unchanged raw payloads add 8,827,834 bytes; all prior 5,417 bindings and the full 3,581-header/provenance prefix remain exact, including native canvases, crops and optional-field presence. Actual data contains **9,369 files / 8,267,536,572 bytes**; CLI validates 5,425 frames with zero rejects and 45 mods/one override (`de2bc703`). Five different context reviews cover the six families' recorded scopes. The 30070–71 complete2 has a genuine legal private validation-World scene using its authentic EnemyDef, with natural frames 0/1 at ticks 1/3 and no HP/frame edits. All 18 Original placements remain filtered: no enabled Original-map occurrence is claimed, and this is not catalog-only render approval. Offscreen twins of 200e6/3009c receive no visibility credit. Thirty directed other-target intersections are empty in the saved 48/62/49/3/21/10-ID picture-consumer sets; entire Source sets are not claimed disjoint. Material, overlap, phase, route/death and player/projectile/secondary/UI/Host limits remain. SourceC18, both executables and the current 341-file/45-mod/M2v1.0.2 guard are unchanged; the optional-palette patch is not installed. Three actual 1080p/HD4x SDL controls measured identical PNG/RGB/alpha over 6,220,800 pixels against H5417. Zero difference was a measured result, not a capture-runner requirement or target-visibility claim. Exact H5417/`ebc52724` native Windows passed 379 tests with 26 ignored and zero failures; H5425 publication/native Windows execution remain pending. All older qualifications, HOLDs, refusals and stopped preparations retain their histories. +Current artwork/runtime checkpoint — 17 October 2026: the published H5425 artwork (`4ab5f799`) contains **5,425 frames / 3,587 complete sequences**, including **4,300 Terrain pictures**. **328 originals await approval**: 235 Terrain, 59 enemies/ground objects, 33 UI/other and one boss. This includes held drafts and actual refusals. The six added families contain exactly eight frames: 1033c, 200e6, 30052, 30070–71, 30089–8a and 3009c. Their 416 retained Original logical cells are not newly generated HD. Six unchanged raw payloads add 8,827,834 bytes; all prior 5,417 bindings and the full 3,581-header/provenance prefix remain exact, including native canvases, crops and optional-field presence. Actual data contains **9,369 files / 8,267,536,572 bytes**; CLI validates 5,425 frames with zero rejects and 45 mods/one override (SourceE CLI `6c535911`). Five different context reviews cover the six families' recorded scopes. The 30070–71 complete2 has a genuine legal private validation-World scene using its authentic EnemyDef, with natural frames 0/1 at ticks 1/3 and no HP/frame edits. All 18 Original placements remain filtered: no enabled Original-map occurrence is claimed, and this is not catalog-only render approval. Offscreen twins of 200e6/3009c receive no visibility credit. Thirty directed other-target intersections are empty in the saved 48/62/49/3/21/10-ID picture-consumer sets; entire Source sets are not claimed disjoint. Material, overlap, phase, route/death and player/projectile/secondary/UI/Host limits remain. SourceE optional Original-palette support is now release-built and installed on Linux and cross-built/assembled for Windows: 106 Rust and 165 Python tests pass, with five existing Rust tests ignored. Only desktop/HD/Terrain HD Source files changed within Source18; the other 15, all artwork/data/defaults and current 341-file/45-mod/M2v1.0.2 guard remain exact. **No installed frame opts into the palette constraint.** Three actual SourceE 1080p/HD4x SDL controls measured identical PNG/RGB/alpha to SourceC at the same H5425 over 6,220,800 pixels; Root personally viewed all three complete images. This is default-path regression evidence, not opt-in-mode or target-art approval. Current Linux/Windows binaries are `c083714a`/`cb3e0414`; the compiled epoch is `2990dec2`, actual install receipts `227a75ed`/`6fc6309c`, and SDL report/strict `127760ca`/`c8aa3e5c`. [Runtime activation evidence](hd-runtime-release.json) separates SourceE from the unchanged SourceC artwork-release record. Exact H5417/`ebc52724` native Windows remains the latest closed execution proof (379 passed, 26 ignored, zero failures); H5425/`4ab5f799` SourceC CI is still in progress at this checkpoint, and Windows SourceE assembly is not native execution. All earlier artwork, context, HOLD and refusal epochs remain literal; no private constrained trial is installed by this runtime update. Preserved 17 October 2026 Source-update checkpoint at 5,384 (historical coverage): Current Source C+D runtime — 17 October 2026, published `a073f73f`: HD coverage remains **5,384 frames / 3,551 sequences**, with **369 originals remaining** (260 Terrain, 75 enemies/ground objects, 33 UI/other, one boss). Both executables are rebuilt; 90 Rust / 78 Python tests, HD validation and three exact 1080p regression controls pass. Five old Rust checks remain ignored. Source C+D native Windows CI is pending; the prior `58723547` result (361 passed / 26 ignored / zero failed) is a different epoch. Earlier material, context and QA records below remain historical; this source update adds no art approval. diff --git a/docs/hd-completion.json b/docs/hd-completion.json index 73b4e812..7a18a0ac 100644 --- a/docs/hd-completion.json +++ b/docs/hd-completion.json @@ -244655,6 +244655,113 @@ "sha256": "3bd5852752ed04bf1f3521d3e3c0e1e6ef4b4e0aafda5a66b5c7ce93d5aba0d5", "bytes": 46667 } + }, + "sourceE_runtime_activation": { + "checkpoint_utc": "2026-10-17T12:01:07.035317+00:00", + "status": "ACTUAL_SOURCE_E_BUILT_INSTALLED_H5425_CLI_AND_DEFAULT_SDL_VALIDATED", + "artwork_baseline": { + "published_commit": "4ab5f79909dcdf918f7c92b49bc064937135a3a6", + "registered_frames": 5425, + "complete_sequences": 3587, + "remaining_original_pictures": 328, + "bundled_data_files": 9369, + "bundled_data_bytes": 8267536572, + "HD_manifest_sha256": "7f262c6e0d7ec1ae3eea5cf9c4d2c95c53e0daa1b56f4e099590c2880c613fd1", + "provenance_sha256": "26f37754de014b0642e9883e48b29324cf525a2745785fc1aa2b8198c753c33f", + "complete_shared_data_map_unchanged": true, + "asset_status_transitions": 0 + }, + "source_changes_from_C_D": { + "src/desktop.rs": "7aa876c1f74ea7b82ff0a5aa399bbbfaa18ea87ca81cab8a17c763654dca3812", + "src/hd.rs": "8cd7c59458be53a1504ba8b165892d2874b9e04b2571f04b7cdfd7fe48007d00", + "src/terrain_hd.rs": "6a042765b6c17715e3c2378e5acecd30fbd8fe43d09dbe448a0aeea0114e914b" + }, + "other15_Source18_files_unchanged": true, + "compiled_epoch": { + "path": "/home/quintine/Projects/RaptorReplica/RustRaptor/.reference/hd-root-SourceE-original-palette-production-integration-20261017/compiled-epoch.json", + "sha256": "2990dec2b1466739446bb1598f15d73c3f7feb113c1fa15a1595189e7ed8df05" + }, + "compiled_library_sha256": "3b04e4733c1218f8e16d80231b899023149f8be40004a9787359e827a76f6d80", + "palette_review_helper_sha256": "aaed9fe89123322ae967e66774c1b515e4bdf4cb1c2b7c040e182d52d8bfc8d2", + "binary_sha256": { + "Linux": "c083714aa52c0f42337d2c1364a80b3e218b895a3522e2e58d4eef5945934f8d", + "Windows": "cb3e041430b1090ff1c4692dff27d8367520f73a747751aacbe80b7ea4b63ae9" + }, + "runtime_manifest_sha256": "5d49948778b79fe442c8f26347dda50b676e54bbb0f2efa4f094d5ad7fd6c9a0", + "Windows_manifest_sha256": "abdf1a403595f66d62aae3d75037263d641a1cb56a463a9de07bfc8d438cee7f", + "actual_receipts": { + "focused_build_and_checks": { + "path": "/home/quintine/Projects/RaptorReplica/RustRaptor/.reference/hd-root-SourceE-original-palette-production-integration-20261017/all-actual-build-and-focused-checks.json", + "sha256": "4e1d82381d8ba97a8e5f61dbf50082cc1959fbfe7345230dc18162fd21cd9e74" + }, + "Linux_install": { + "path": "/home/quintine/Projects/RaptorReplica/RustRaptor/.reference/hd-root-SourceE-original-palette-production-integration-20261017/linux-installed.json", + "sha256": "227a75edf279611fd19523434474ec5e4fb978a64812d2fd081725c90fdc3f95" + }, + "Windows_cross_build_and_assembly": { + "path": "/home/quintine/Projects/RaptorReplica/RustRaptor/.reference/hd-root-SourceE-original-palette-production-integration-20261017/windows-installed.json", + "sha256": "6fc6309c0c6bdd876d91e4dc2c0394998229dafd629c6391fc6e5c8d18012d5b" + }, + "full_HD_and_mod_CLI": { + "path": "/home/quintine/Projects/RaptorReplica/RustRaptor/.reference/hd-root-SourceE-original-palette-production-integration-20261017/cli-validation.json", + "sha256": "6c535911592308c4ae480b76906eeadcf162b88a59f37fd7eabfd64959471f69" + }, + "default_SDL_report": { + "path": "/home/quintine/Projects/RaptorReplica/RustRaptor/.reference/hd-campaign-actualH5425-sourceE-three-SDL-versus-sourceC-20261017-v1/CLOSED-ACTUAL-SOURCEE-H5425-THREE-SDL-MEASURED-AND-PHYSICAL-REGRESSION-VERSUS-ACTUAL-SOURCEC-H5425.json", + "sha256": "127760ca1f5dfb16f0c4cdc39a45fd4c05d767c799ba25e741ffea9c553d3060" + }, + "default_SDL_outside_strict": { + "path": "/home/quintine/Projects/RaptorReplica/RustRaptor/.reference/hd-campaign-actualH5425-sourceE-three-SDL-versus-sourceC-20261017-v1-strict-complete-sourceE-versus-sourceC-regression-closure.json", + "sha256": "c8aa3e5cf677942b916ff8c938a9fbe99191a844f388f4f86d0f64b4d072dd1c" + }, + "Root_personal_three_SDL_review": { + "path": "/home/quintine/Projects/RaptorReplica/RustRaptor/.reference/hd-root-SourceE-original-palette-production-integration-20261017/root-personal-three-SDL-review.json", + "sha256": "212e217797787967a828e42129c4f8190bf8001fc1f9ca4f595a5e130cbe9a44" + } + }, + "validation": { + "Rust_passed": 106, + "Rust_failed": 0, + "Rust_preexisting_ignored": 5, + "Python_passed": 165, + "Python_failed": 0, + "HD_registered": 5425, + "HD_validated": 5425, + "HD_rejected": 0, + "mods_validated": 45, + "overrides": 1, + "verification": "6449 assets; 27 missions, 528 enemies, 16 weapons, 24 items" + }, + "optional_original_palette_constraint": { + "support_installed": true, + "installed_asset_opt_ins": 0, + "canonical_palette_constraint_fields": 0, + "scope": "Optional runtime support only. No installed artwork uses the field; no palette-constrained artwork is approved or published by this activation." + }, + "SDL_regression_controls": { + "baseline": "Actual SourceC H5425, same artwork/data and 341-file/45-mod/M2v1.0.2 guard", + "cases": 3, + "exit_zero": 3, + "width": 1920, + "height": 1080, + "HD_scale": 4, + "pixels_compared": 6220800, + "PNG_bytes_equal_cases": 3, + "changed_RGB_pixels": 0, + "changed_alpha_pixels": 0, + "Root_personally_viewed_complete_images": 3, + "scope": "Default absent-field path only. Pixel equality was measured, not a capture requirement. No opt-in-mode, target visibility, intrinsic material, all-placement, campaign-playthrough or hardware-listening approval." + }, + "native_Windows": { + "latest_closed_execution": "H5417 / ebc52724: 379 passed, 26 ignored, 0 failed; exact receipts retained in latest_artwork_runtime.native_Windows", + "H5425_SourceC_4ab5_execution": "in progress at this activation checkpoint", + "SourceE_execution": "not claimed; Windows release cross-build and assembly passed, with no native Windows execution receipt" + }, + "history_scope": "All SourceC and older successful image-call, intrinsic, material, context, held-art and refusal authorities retain their original epochs and limits. The existing current_source_runtime object is a dated SourceC+D5384 checkpoint; latest_artwork_runtime is the unchanged SourceC H5425 artwork-release evidence. This activation changes runtime evidence only, with no asset/header/default/raw/status or family-review changes. Private constrained studies are not installed artwork." + }, + "current_runtime": { + "record": "sourceE_runtime_activation", + "scope": "Current installed runtime at unchanged published H5425 artwork; distinct from retained SourceC artwork/source-update receipts." } } } diff --git a/docs/hd-coordinator-checkpoint.md b/docs/hd-coordinator-checkpoint.md index 34cf4c85..c6e80c6e 100644 --- a/docs/hd-coordinator-checkpoint.md +++ b/docs/hd-coordinator-checkpoint.md @@ -1,6 +1,6 @@ # HD coordinator checkpoint — 17 October 2026 -Current artwork/runtime checkpoint — 17 October 2026: the registered and assembled pack contains **5,425 frames / 3,587 complete sequences**, including **4,300 Terrain pictures**. **328 originals await approval**: 235 Terrain, 59 enemies/ground objects, 33 UI/other and one boss. This includes held drafts and actual refusals. The six added families contain exactly eight frames: 1033c, 200e6, 30052, 30070–71, 30089–8a and 3009c. Their 416 retained Original logical cells are not newly generated HD. Six unchanged raw payloads add 8,827,834 bytes; all prior 5,417 bindings and the full 3,581-header/provenance prefix remain exact, including native canvases, crops and optional-field presence. Actual data contains **9,369 files / 8,267,536,572 bytes**; CLI validates 5,425 frames with zero rejects and 45 mods/one override (`de2bc703`). Five different context reviews cover the six families' recorded scopes. The 30070–71 complete2 has a genuine legal private validation-World scene using its authentic EnemyDef, with natural frames 0/1 at ticks 1/3 and no HP/frame edits. All 18 Original placements remain filtered: no enabled Original-map occurrence is claimed, and this is not catalog-only render approval. Offscreen twins of 200e6/3009c receive no visibility credit. Thirty directed other-target intersections are empty in the saved 48/62/49/3/21/10-ID picture-consumer sets; entire Source sets are not claimed disjoint. Material, overlap, phase, route/death and player/projectile/secondary/UI/Host limits remain. SourceC18, both executables and the current 341-file/45-mod/M2v1.0.2 guard are unchanged; the optional-palette patch is not installed. Three actual 1080p/HD4x SDL controls measured identical PNG/RGB/alpha over 6,220,800 pixels against H5417. Zero difference was a measured result, not a capture-runner requirement or target-visibility claim. Exact H5417/`ebc52724` native Windows passed 379 tests with 26 ignored and zero failures; H5425 publication/native Windows execution remain pending. All older qualifications, HOLDs, refusals and stopped preparations retain their histories. +Current artwork/runtime checkpoint — 17 October 2026: the published H5425 artwork (`4ab5f799`) contains **5,425 frames / 3,587 complete sequences**, including **4,300 Terrain pictures**. **328 originals await approval**: 235 Terrain, 59 enemies/ground objects, 33 UI/other and one boss. This includes held drafts and actual refusals. The six added families contain exactly eight frames: 1033c, 200e6, 30052, 30070–71, 30089–8a and 3009c. Their 416 retained Original logical cells are not newly generated HD. Six unchanged raw payloads add 8,827,834 bytes; all prior 5,417 bindings and the full 3,581-header/provenance prefix remain exact, including native canvases, crops and optional-field presence. Actual data contains **9,369 files / 8,267,536,572 bytes**; CLI validates 5,425 frames with zero rejects and 45 mods/one override (SourceE CLI `6c535911`). Five different context reviews cover the six families' recorded scopes. The 30070–71 complete2 has a genuine legal private validation-World scene using its authentic EnemyDef, with natural frames 0/1 at ticks 1/3 and no HP/frame edits. All 18 Original placements remain filtered: no enabled Original-map occurrence is claimed, and this is not catalog-only render approval. Offscreen twins of 200e6/3009c receive no visibility credit. Thirty directed other-target intersections are empty in the saved 48/62/49/3/21/10-ID picture-consumer sets; entire Source sets are not claimed disjoint. Material, overlap, phase, route/death and player/projectile/secondary/UI/Host limits remain. SourceE optional Original-palette support is now release-built and installed on Linux and cross-built/assembled for Windows: 106 Rust and 165 Python tests pass, with five existing Rust tests ignored. Only desktop/HD/Terrain HD Source files changed within Source18; the other 15, all artwork/data/defaults and current 341-file/45-mod/M2v1.0.2 guard remain exact. **No installed frame opts into the palette constraint.** Three actual SourceE 1080p/HD4x SDL controls measured identical PNG/RGB/alpha to SourceC at the same H5425 over 6,220,800 pixels; Root personally viewed all three complete images. This is default-path regression evidence, not opt-in-mode or target-art approval. Current Linux/Windows binaries are `c083714a`/`cb3e0414`; the compiled epoch is `2990dec2`, actual install receipts `227a75ed`/`6fc6309c`, and SDL report/strict `127760ca`/`c8aa3e5c`. [Runtime activation evidence](hd-runtime-release.json) separates SourceE from the unchanged SourceC artwork-release record. Exact H5417/`ebc52724` native Windows remains the latest closed execution proof (379 passed, 26 ignored, zero failures); H5425/`4ab5f799` SourceC CI is still in progress at this checkpoint, and Windows SourceE assembly is not native execution. All earlier artwork, context, HOLD and refusal epochs remain literal; no private constrained trial is installed by this runtime update. Preserved 17 October 2026 Source-update checkpoint at 5,384 (historical coverage): Current Source C+D checkpoint — 17 October 2026: remote-confirmed commit `a073f73f` installs the rational atlas sampler, protected terrain joins and bounded retained-Original policy. Coverage is unchanged at **5,384 frames / 3,551 sequences**, with **369 originals remaining** (260 Terrain, 75 enemies/ground objects, 33 UI/other, one boss); data remains **9,335 files / 8,179,315,857 bytes**. Both bundled executables were rebuilt. Validation passes 90 Rust and 78 Python tests (five pre-existing Rust checks ignored), all 5,384 HD frames with zero rejects, the 6,449-entry catalog and 45 mods/one override. Three fresh 1080p API-B/C+D controls match exactly across 6,220,800 pixels. Source C+D native Windows CI remains pending; `58723547` separately passed 361 tests, with 26 ignored and zero failed. No new artwork approval or full-HD completion follows. The following pre-C records retain their original epochs. diff --git a/docs/hd-full-review.md b/docs/hd-full-review.md index 8b62d395..745b2741 100644 --- a/docs/hd-full-review.md +++ b/docs/hd-full-review.md @@ -1,6 +1,6 @@ # Complete-original HD review -Current artwork/runtime checkpoint — 17 October 2026: the registered and assembled pack contains **5,425 frames / 3,587 complete sequences**, including **4,300 Terrain pictures**. **328 originals await approval**: 235 Terrain, 59 enemies/ground objects, 33 UI/other and one boss. This includes held drafts and actual refusals. The six added families contain exactly eight frames: 1033c, 200e6, 30052, 30070–71, 30089–8a and 3009c. Their 416 retained Original logical cells are not newly generated HD. Six unchanged raw payloads add 8,827,834 bytes; all prior 5,417 bindings and the full 3,581-header/provenance prefix remain exact, including native canvases, crops and optional-field presence. Actual data contains **9,369 files / 8,267,536,572 bytes**; CLI validates 5,425 frames with zero rejects and 45 mods/one override (`de2bc703`). Five different context reviews cover the six families' recorded scopes. The 30070–71 complete2 has a genuine legal private validation-World scene using its authentic EnemyDef, with natural frames 0/1 at ticks 1/3 and no HP/frame edits. All 18 Original placements remain filtered: no enabled Original-map occurrence is claimed, and this is not catalog-only render approval. Offscreen twins of 200e6/3009c receive no visibility credit. Thirty directed other-target intersections are empty in the saved 48/62/49/3/21/10-ID picture-consumer sets; entire Source sets are not claimed disjoint. Material, overlap, phase, route/death and player/projectile/secondary/UI/Host limits remain. SourceC18, both executables and the current 341-file/45-mod/M2v1.0.2 guard are unchanged; the optional-palette patch is not installed. Three actual 1080p/HD4x SDL controls measured identical PNG/RGB/alpha over 6,220,800 pixels against H5417. Zero difference was a measured result, not a capture-runner requirement or target-visibility claim. Exact H5417/`ebc52724` native Windows passed 379 tests with 26 ignored and zero failures; H5425 publication/native Windows execution remain pending. All older qualifications, HOLDs, refusals and stopped preparations retain their histories. +Current artwork/runtime checkpoint — 17 October 2026: the published H5425 artwork (`4ab5f799`) contains **5,425 frames / 3,587 complete sequences**, including **4,300 Terrain pictures**. **328 originals await approval**: 235 Terrain, 59 enemies/ground objects, 33 UI/other and one boss. This includes held drafts and actual refusals. The six added families contain exactly eight frames: 1033c, 200e6, 30052, 30070–71, 30089–8a and 3009c. Their 416 retained Original logical cells are not newly generated HD. Six unchanged raw payloads add 8,827,834 bytes; all prior 5,417 bindings and the full 3,581-header/provenance prefix remain exact, including native canvases, crops and optional-field presence. Actual data contains **9,369 files / 8,267,536,572 bytes**; CLI validates 5,425 frames with zero rejects and 45 mods/one override (SourceE CLI `6c535911`). Five different context reviews cover the six families' recorded scopes. The 30070–71 complete2 has a genuine legal private validation-World scene using its authentic EnemyDef, with natural frames 0/1 at ticks 1/3 and no HP/frame edits. All 18 Original placements remain filtered: no enabled Original-map occurrence is claimed, and this is not catalog-only render approval. Offscreen twins of 200e6/3009c receive no visibility credit. Thirty directed other-target intersections are empty in the saved 48/62/49/3/21/10-ID picture-consumer sets; entire Source sets are not claimed disjoint. Material, overlap, phase, route/death and player/projectile/secondary/UI/Host limits remain. SourceE optional Original-palette support is now release-built and installed on Linux and cross-built/assembled for Windows: 106 Rust and 165 Python tests pass, with five existing Rust tests ignored. Only desktop/HD/Terrain HD Source files changed within Source18; the other 15, all artwork/data/defaults and current 341-file/45-mod/M2v1.0.2 guard remain exact. **No installed frame opts into the palette constraint.** Three actual SourceE 1080p/HD4x SDL controls measured identical PNG/RGB/alpha to SourceC at the same H5425 over 6,220,800 pixels; Root personally viewed all three complete images. This is default-path regression evidence, not opt-in-mode or target-art approval. Current Linux/Windows binaries are `c083714a`/`cb3e0414`; the compiled epoch is `2990dec2`, actual install receipts `227a75ed`/`6fc6309c`, and SDL report/strict `127760ca`/`c8aa3e5c`. [Runtime activation evidence](hd-runtime-release.json) separates SourceE from the unchanged SourceC artwork-release record. Exact H5417/`ebc52724` native Windows remains the latest closed execution proof (379 passed, 26 ignored, zero failures); H5425/`4ab5f799` SourceC CI is still in progress at this checkpoint, and Windows SourceE assembly is not native execution. All earlier artwork, context, HOLD and refusal epochs remain literal; no private constrained trial is installed by this runtime update. Preserved 17 October 2026 Source-update checkpoint at 5,384 (historical coverage): Current runtime checkpoint — 17 October 2026: Source C+D is remote-confirmed at `a073f73f`, with **5,384 HD frames / 3,551 sequences** and 369 originals still outstanding. Both executables are rebuilt; 90 Rust / 78 Python tests and three fresh 1080p controls pass, with zero pixel differences from API-B. Source C+D native Windows CI remains pending. Historical 8ada, 386e and API-B material/context/QA evidence below retains its recorded epoch; these bounded runtime checks add no artwork or hybrid approval. See [the coordinator checkpoint](hd-coordinator-checkpoint.md) for current validation and Windows attribution. diff --git a/docs/hd-runtime-release.json b/docs/hd-runtime-release.json index 713aa0cc..fc5ecabc 100644 --- a/docs/hd-runtime-release.json +++ b/docs/hd-runtime-release.json @@ -883,7 +883,116 @@ }, "record_roles": { "current_source_runtime": "Dated published SourceC+D update checkpoint at5384/3551. Retained unchanged as historical source-update evidence, not latest artwork coverage.", - "latest_artwork_runtime": "Current5425/3587 assembled artwork registry; actual current counts/data and measured SDL evidence.", - "older_top_level_release_fields": "Historical2935 release and predecessor authority, not current5425 runtime facts." + "latest_artwork_runtime": "Published H5425/3587 artwork-release evidence measured under SourceC. Retained unchanged; its executable/source hashes are historical after SourceE activation.", + "older_top_level_release_fields": "Historical2935 release and predecessor authority, not current SourceE runtime or H5425 artwork facts.", + "current_runtime": "Selects sourceE_runtime_activation for the actual installed SourceE runtime; H5425 artwork and data are unchanged.", + "sourceE_runtime_activation": "Current SourceE build/install/CLI/default-SDL evidence; zero asset palette opt-ins and no native Windows SourceE execution claim." + }, + "sourceE_runtime_activation": { + "checkpoint_utc": "2026-10-17T12:01:07.035317+00:00", + "status": "ACTUAL_SOURCE_E_BUILT_INSTALLED_H5425_CLI_AND_DEFAULT_SDL_VALIDATED", + "artwork_baseline": { + "published_commit": "4ab5f79909dcdf918f7c92b49bc064937135a3a6", + "registered_frames": 5425, + "complete_sequences": 3587, + "remaining_original_pictures": 328, + "bundled_data_files": 9369, + "bundled_data_bytes": 8267536572, + "HD_manifest_sha256": "7f262c6e0d7ec1ae3eea5cf9c4d2c95c53e0daa1b56f4e099590c2880c613fd1", + "provenance_sha256": "26f37754de014b0642e9883e48b29324cf525a2745785fc1aa2b8198c753c33f", + "complete_shared_data_map_unchanged": true, + "asset_status_transitions": 0 + }, + "source_changes_from_C_D": { + "src/desktop.rs": "7aa876c1f74ea7b82ff0a5aa399bbbfaa18ea87ca81cab8a17c763654dca3812", + "src/hd.rs": "8cd7c59458be53a1504ba8b165892d2874b9e04b2571f04b7cdfd7fe48007d00", + "src/terrain_hd.rs": "6a042765b6c17715e3c2378e5acecd30fbd8fe43d09dbe448a0aeea0114e914b" + }, + "other15_Source18_files_unchanged": true, + "compiled_epoch": { + "path": "/home/quintine/Projects/RaptorReplica/RustRaptor/.reference/hd-root-SourceE-original-palette-production-integration-20261017/compiled-epoch.json", + "sha256": "2990dec2b1466739446bb1598f15d73c3f7feb113c1fa15a1595189e7ed8df05" + }, + "compiled_library_sha256": "3b04e4733c1218f8e16d80231b899023149f8be40004a9787359e827a76f6d80", + "palette_review_helper_sha256": "aaed9fe89123322ae967e66774c1b515e4bdf4cb1c2b7c040e182d52d8bfc8d2", + "binary_sha256": { + "Linux": "c083714aa52c0f42337d2c1364a80b3e218b895a3522e2e58d4eef5945934f8d", + "Windows": "cb3e041430b1090ff1c4692dff27d8367520f73a747751aacbe80b7ea4b63ae9" + }, + "runtime_manifest_sha256": "5d49948778b79fe442c8f26347dda50b676e54bbb0f2efa4f094d5ad7fd6c9a0", + "Windows_manifest_sha256": "abdf1a403595f66d62aae3d75037263d641a1cb56a463a9de07bfc8d438cee7f", + "actual_receipts": { + "focused_build_and_checks": { + "path": "/home/quintine/Projects/RaptorReplica/RustRaptor/.reference/hd-root-SourceE-original-palette-production-integration-20261017/all-actual-build-and-focused-checks.json", + "sha256": "4e1d82381d8ba97a8e5f61dbf50082cc1959fbfe7345230dc18162fd21cd9e74" + }, + "Linux_install": { + "path": "/home/quintine/Projects/RaptorReplica/RustRaptor/.reference/hd-root-SourceE-original-palette-production-integration-20261017/linux-installed.json", + "sha256": "227a75edf279611fd19523434474ec5e4fb978a64812d2fd081725c90fdc3f95" + }, + "Windows_cross_build_and_assembly": { + "path": "/home/quintine/Projects/RaptorReplica/RustRaptor/.reference/hd-root-SourceE-original-palette-production-integration-20261017/windows-installed.json", + "sha256": "6fc6309c0c6bdd876d91e4dc2c0394998229dafd629c6391fc6e5c8d18012d5b" + }, + "full_HD_and_mod_CLI": { + "path": "/home/quintine/Projects/RaptorReplica/RustRaptor/.reference/hd-root-SourceE-original-palette-production-integration-20261017/cli-validation.json", + "sha256": "6c535911592308c4ae480b76906eeadcf162b88a59f37fd7eabfd64959471f69" + }, + "default_SDL_report": { + "path": "/home/quintine/Projects/RaptorReplica/RustRaptor/.reference/hd-campaign-actualH5425-sourceE-three-SDL-versus-sourceC-20261017-v1/CLOSED-ACTUAL-SOURCEE-H5425-THREE-SDL-MEASURED-AND-PHYSICAL-REGRESSION-VERSUS-ACTUAL-SOURCEC-H5425.json", + "sha256": "127760ca1f5dfb16f0c4cdc39a45fd4c05d767c799ba25e741ffea9c553d3060" + }, + "default_SDL_outside_strict": { + "path": "/home/quintine/Projects/RaptorReplica/RustRaptor/.reference/hd-campaign-actualH5425-sourceE-three-SDL-versus-sourceC-20261017-v1-strict-complete-sourceE-versus-sourceC-regression-closure.json", + "sha256": "c8aa3e5cf677942b916ff8c938a9fbe99191a844f388f4f86d0f64b4d072dd1c" + }, + "Root_personal_three_SDL_review": { + "path": "/home/quintine/Projects/RaptorReplica/RustRaptor/.reference/hd-root-SourceE-original-palette-production-integration-20261017/root-personal-three-SDL-review.json", + "sha256": "212e217797787967a828e42129c4f8190bf8001fc1f9ca4f595a5e130cbe9a44" + } + }, + "validation": { + "Rust_passed": 106, + "Rust_failed": 0, + "Rust_preexisting_ignored": 5, + "Python_passed": 165, + "Python_failed": 0, + "HD_registered": 5425, + "HD_validated": 5425, + "HD_rejected": 0, + "mods_validated": 45, + "overrides": 1, + "verification": "6449 assets; 27 missions, 528 enemies, 16 weapons, 24 items" + }, + "optional_original_palette_constraint": { + "support_installed": true, + "installed_asset_opt_ins": 0, + "canonical_palette_constraint_fields": 0, + "scope": "Optional runtime support only. No installed artwork uses the field; no palette-constrained artwork is approved or published by this activation." + }, + "SDL_regression_controls": { + "baseline": "Actual SourceC H5425, same artwork/data and 341-file/45-mod/M2v1.0.2 guard", + "cases": 3, + "exit_zero": 3, + "width": 1920, + "height": 1080, + "HD_scale": 4, + "pixels_compared": 6220800, + "PNG_bytes_equal_cases": 3, + "changed_RGB_pixels": 0, + "changed_alpha_pixels": 0, + "Root_personally_viewed_complete_images": 3, + "scope": "Default absent-field path only. Pixel equality was measured, not a capture requirement. No opt-in-mode, target visibility, intrinsic material, all-placement, campaign-playthrough or hardware-listening approval." + }, + "native_Windows": { + "latest_closed_execution": "H5417 / ebc52724: 379 passed, 26 ignored, 0 failed; exact receipts retained in latest_artwork_runtime.native_Windows", + "H5425_SourceC_4ab5_execution": "in progress at this activation checkpoint", + "SourceE_execution": "not claimed; Windows release cross-build and assembly passed, with no native Windows execution receipt" + }, + "history_scope": "All SourceC and older successful image-call, intrinsic, material, context, held-art and refusal authorities retain their original epochs and limits. The existing current_source_runtime object is a dated SourceC+D5384 checkpoint; latest_artwork_runtime is the unchanged SourceC H5425 artwork-release evidence. This activation changes runtime evidence only, with no asset/header/default/raw/status or family-review changes. Private constrained studies are not installed artwork." + }, + "current_runtime": { + "record": "sourceE_runtime_activation", + "scope": "Current installed runtime at unchanged published H5425 artwork; distinct from retained SourceC artwork/source-update receipts." } } diff --git a/docs/hd-streaming.md b/docs/hd-streaming.md index 45d32d9c..95c9a401 100644 --- a/docs/hd-streaming.md +++ b/docs/hd-streaming.md @@ -16,6 +16,14 @@ All policies below default to disabled. They require explicit frame metadata and - `preserve_reserved_coverage: true` retains an original gameplay palette label 250–255 at a feathered edge only when its resident logical cell has mean alpha of at least 128 and at least half its area has alpha of 128 or greater. Missing or sparse artwork stays transparent. Ordinary colors, water/fire classes and static-card rendering keep their existing behavior. - `native_nearest_cues: [[x, y], ...]` samples the unchanged resident RGBA at the nearest pixel for up to 64 explicitly listed original cells, only at gameplay scale 1. Coordinates must be sorted lexicographically, unique, within an opaque original canvas, and refer to ordinary source indices below 240. This can preserve a small raw-supported warning color that averaging dulls. It does not recolor artwork or reconstruct absent details; other cells, higher scales and static-card rendering keep their existing sampler. +- `palette_constraint: "original-painted-ordinary-v1"` limits visible generated gameplay samples to the exact distinct indices painted in that frame's checked Original. Eligibility requires a nonempty, fully opaque Original canvas containing only ordinary indices 0–239. The list is derived from the catalog; authors cannot supply extra colors. The raw PNG, crop/atlas selection, logical canvas, origin, Lanczos/nearest sampling and alpha threshold are unchanged. An absent or null value keeps the previous serialization, cache keys and output. This is an optional runtime quantization treatment of existing generated artwork, not a new generation or copied Original detail. + +The palette constraint applies in SourceClasses gameplay mode at scales 1–4; Static-card sampling remains unchanged and cannot supply approval for this treatment. It chooses the closest allowed ordinary color by squared RGB distance; an exact tie selects the lowest allowed palette index. Sources containing transparency, water/fire or glow/blink labels 240–255 are ineligible for this version, so their existing functional palette behavior is preserved. Exact Original detail retention still takes priority, including authentic joined Original samples and transparent ScaleNx subpixels. + +Terrain must pass the trusted domain returned by `terrain_sprite_with_palette` into `compose_with_palette_constraints`. The domain is present in the detailed input before neighbor sampling and in each owner's shared/final join quantization. Default `Replacement` literals and composition APIs remain valid. The older `terrain_sprite` rejects opted-in frames rather than silently dropping their domains. A rejected or shadowed owner does not apply a domain and does not suppress a valid neighbor; invalid metadata on an accepted owner fails closed. Source boundary controls and explicitly retained Original samples are not generated contributions and are preserved. Different tile domains may have no common color: this policy cannot promise identical seams, correct geometry or spatial material placement. Whole-scale and genuine-neighbor review remains required. + +Palette-constrained frames have a separate versioned frame/indexed/prepared-terrain identity. Default keys remain exact. A validated domain costs 48 bytes on 64-bit targets and is included in the existing combined 32 MiB Source-reference budget; no additional full Source raster or unbounded per-domain transition table is retained. + - `original_detail_cells: [[x, y], ...]` retains explicitly listed **Original** logical details at gameplay scales 1–4. Legacy lists of up to 64 cells keep their existing behavior. Lists of 65–128 cells additionally require `4 * retained_cell_count <= painted_Source_cell_count`, counted from the decoded authentic Original payload (indices 0–255, excluding transparency), never canvas area or generated PNG alpha. All cells must remain sorted, unique, in bounds, painted and disjoint from `native_nearest_cues`; transparent Source cells are rejected. The runtime copies the exact Original ScaleNx footprint into the generated replacement only in SourceClasses/gameplay mode. Terrain conditions retained samples with the authentic joined Original control before blending neighbors, then restores their exact footprints after all joins. This can also change neighboring pixels within the existing join bands; unrelated pixels and fallback behavior remain unchanged. Static cards continue to sample the PNG. Raw PNG bytes, alpha, framing and loader eligibility do not change. Retained pixels are Original details, not generated HD. Original palette cycling, water/fire and blinking labels remain live Source indices; ScaleNx may also contain transparent subpixels shaped by Original neighbors. Coarse detail or palette effects can form visible seams and require explicit independent all-scale and genuine-context hybrid review. The ordinary-only predecessor is not a current hybrid approval. Registration accepts the existing `retained-original-details-v2-painted0255` declaration only for legacy lists of up to 64, or `retained-original-details-v3-max128-quarter-painted0255` with exact selected cells. Longer lists always require v3 and actual catalog Source validation by the registrar. Metadata-only candidate preparation cannot prove the density bound. Lists up to 64 preserve existing per-frame, indexed and rejected-fallback cache identities; longer lists use new indexed and rejected-fallback namespaces. Protected terrain joins follow the conditioning rule above for either list size. The combined 32 MiB Source-reference budget is unchanged. Retained cells never count as generated detail, and the area rule does not prove novelty in the unretained cells. A whole-Source fallback cannot gain artistic HD approval through this option; legacy acceptance is preserved without relabeling its pixels. Material, all-scale and genuine-context gates stay in force (the separately bounded unused-catalog scope still explicitly declares no genuine runtime context). Existing default artwork holds are preserved; this option does not rescue invalid framing, provider refusals or broad geometry/material failures. @@ -24,6 +32,16 @@ Opt-in metadata participates in both derived and indexed cache identities. Frame `HdAssets::load` performs full eager validation using the same bounded caches. `validate_all()` can also validate a lazily registered pack; its report distinguishes registered, validated and rejected frames. Full validation can therefore be used in a CLI or audit without raising runtime memory limits. It validates supplied artwork and does not infer coverage for absent originals. +## Reviewing a palette-constrained candidate + +Keep the untouched generated PNG and its complete generation inventory/history. Add only the explicit frame field in a new candidate; preserve every native field except any necessary file-only namespace. A later treatment of a selected historical raw does not make it the latest generated raw, repair a newer held result, or approve its unselected siblings. Retained Original cells remain separately declared as Original pixels. + +`hd_candidate_review.py` selects the maintained `tools/hd_palette_review.rs` successor when this field is present. It must be compiled against the actual newly built library; frozen historical helpers are not modified or relabeled. The successor records the derived Source indices/colors and measured visible/out-of-domain counts at every scale, in addition to the existing source/raw/frame-binding/current-library evidence. Pure loader or domain success is not material approval. Quantization can flatten tones, retain a wrong but allowed gray/brown color in the wrong place, or leave a boundary between disjoint domains. + +Registration keeps all existing approval, exact binding, freshness, whole-material and genuine-context requirements. A coordinator review additionally declares `palette_constraint_review` with `policy_version: "original-painted-ordinary-v1"`, its exact `frame_bindings_sha256`, `unchanged_generated_raw: true` and `independent_allscale_and_genuine_context_review: true`. The registrar derives the domain again from actual catalog bytes and checks the measured SourceClasses evidence; an old default-sampler QA or static-card result cannot authorize it. No asset receives this field automatically. + +Legacy terrain review constructors compiled against the new library must migrate to the domain-aware wrapper and composition method before reviewing these candidates. Recompiling an old constructor without that change is unsupported and fails closed. Intrinsic galleries do not prove terrain joins, Source reachability, animation or all-placement coverage. Actual build/CLI/default-continuity and asset-specific context reviews must be completed before publishing a new runtime or admitting a candidate. + ## Memory bounds Each PNG is limited to 32 MiB on disk, 4,096 pixels per dimension, 4,194,304 total pixels and a 64 MiB decoder allocation limit. Reads are bounded even if a file grows after metadata inspection. A loaded frame retains at most its logical canvas at 4× resolution. Larger generated originals remain unchanged on disk. diff --git a/runtime-manifest.json b/runtime-manifest.json index 02363ee1..826818b5 100644 --- a/runtime-manifest.json +++ b/runtime-manifest.json @@ -3,7 +3,7 @@ "platform": "Linux x86_64", "catalog_entries": 6449, "approved_hd_frames": 5425, - "binary_sha256": "499d450e94c1a3d62d94e4002afbc76fcc4fcfd33ec6b84ca99946f58de078d3", + "binary_sha256": "c083714aa52c0f42337d2c1364a80b3e218b895a3522e2e58d4eef5945934f8d", "data_files": 9369, "data_bytes": 8267536572, "files": { @@ -9378,11 +9378,11 @@ "data/ui/WINGAME_SWD": "304630a1223419d69723902ffd22c2c4eea0388491e37dfca663fcbe78e99aff" }, "system_dependencies": [ - "\tlinux-vdso.so.1 (0x00007fe0d25c8000)", - "\tlibSDL2-2.0.so.0 => /home/quintine/Projects/RaptorReplica/RustRaptor/lib/libSDL2-2.0.so.0 (0x00007fe0d1800000)", - "\tlibgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x00007fe0d2574000)", - "\tlibm.so.6 => /usr/lib/libm.so.6 (0x00007fe0d1ac9000)", - "\tlibc.so.6 => /usr/lib/libc.so.6 (0x00007fe0d1400000)", - "\t/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007fe0d25ca000)" + "\tlinux-vdso.so.1 (0x00007f1545313000)", + "\tlibSDL2-2.0.so.0 => /home/quintine/Projects/RaptorReplica/RustRaptor/lib/libSDL2-2.0.so.0 (0x00007f1544600000)", + "\tlibgcc_s.so.1 => /usr/lib/libgcc_s.so.1 (0x00007f15449d3000)", + "\tlibm.so.6 => /usr/lib/libm.so.6 (0x00007f154489c000)", + "\tlibc.so.6 => /usr/lib/libc.so.6 (0x00007f1544200000)", + "\t/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007f1545315000)" ] } diff --git a/runtime-windows-manifest.json b/runtime-windows-manifest.json index f808c443..f0af05e6 100644 --- a/runtime-windows-manifest.json +++ b/runtime-windows-manifest.json @@ -37,7 +37,7 @@ ] }, "files": { - "bin/raptor-rust.exe": "449ef2ac6d6a117092bdac276776798d238fa6eea8428668c31098e63a476bc7", + "bin/raptor-rust.exe": "cb3e041430b1090ff1c4692dff27d8367520f73a747751aacbe80b7ea4b63ae9", "bin/SDL2.dll": "53e8fa7e9ed43c30bd650c39396f0d9d2067d2fff0b926c9433b75202592e4aa", "bin/SDL2-WINDOWS-LICENSE.txt": "97f35b302b361680ec1e891e95d2d52097bb95abff361434916d99dc1305f127", "run.cmd": "b007382d9ed3560191856514a9ebd4e429801fc25428840e304bb297f3553269" diff --git a/src/desktop.rs b/src/desktop.rs index 80f1aa2f..c53bbf45 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -3096,6 +3096,8 @@ impl Host { key.extend_from_slice(b"terrain-retained-original-details-v2-painted0255"); key.extend_from_slice(&serde_json::to_vec(&details)?); } + append_terrain_palette_policy_key(&mut self.hd, + visible_tiles.iter().filter_map(|(art,_,_)|art.asset), &mut key)?; } let shift = if world.tick > 0 { alpha - 1.0 } else { 0.0 }; let translated_y = ((-34.0 + phase as f32 + shift) * canvas.scale as f32).round() as i32; @@ -3157,6 +3159,7 @@ impl Host { if self.config.hd_mode { let mut replacements = Vec::new(); let mut retained_original = Vec::new(); + let mut palette_constraints = Vec::new(); let mut labels = Vec::new(); for (art, x, y, detailed_original) in prepared_tiles { let replacement = @@ -3169,19 +3172,23 @@ impl Host { ), }; self.art(&mut tile, &art, &owner, 0, 0.0, 0.0)?; - Some((tile.image, None)) + Some((tile.image, None, None)) } else if let Some(id) = art.asset { - self.hd.terrain_sprite(id, canvas.scale, &self.palette)? + self.hd.terrain_sprite_with_palette(id, canvas.scale, &self.palette)? } else { None }; - let Some((replacement, details)) = replacement else { + let Some((replacement, details, domain)) = replacement else { continue; }; if let Some(details) = details { retained_original.push(crate::terrain_hd::RetainedOriginalPlacement { replacement_index: replacements.len(), details }); } + if let Some(palette) = domain { + palette_constraints.push(crate::terrain_hd::PaletteConstraintPlacement { + replacement_index: replacements.len(), palette }); + } labels.push(art.asset.map(|id| format!("{id:05x}")).unwrap_or_else(|| { art.hd_png.clone().or(art.png.clone()).unwrap_or_default() })); @@ -3194,7 +3201,7 @@ impl Host { if !replacements.is_empty() { let composition = self.terrain_hd - .compose_retaining_original(&bg, &replacements, &retained_original, canvas.scale, &self.palette); + .compose_with_palette_constraints(&bg, &replacements, &retained_original, &palette_constraints, canvas.scale, &self.palette); bg = composition.image; for (label, result) in labels.into_iter().zip(composition.results) { if result != OverlayResult::Applied { @@ -4303,6 +4310,18 @@ fn pilot_for_mods( Ok(current.clone()) } } +// Default/no-policy strips keep their exact existing key bytes. A visible +// opt-in binds raw/source/crop/mode identity, including rejected-frame fallback. +fn append_terrain_palette_policy_key(hd: &mut HdAssets, ids: impl IntoIterator, key: &mut Vec) -> Result<()> { + let domains: Vec<_> = ids.into_iter().collect::>() + .into_iter().filter_map(|id|hd.palette_constraint_cache_identity(id).map(|identity|(id,identity))).collect(); + if !domains.is_empty() { + key.extend_from_slice(b"terrain-original-painted-ordinary-v1"); + key.extend_from_slice(&serde_json::to_vec(&domains)?); + } + Ok(()) +} + fn restore_scripts(runtime: &mut modding::ScriptRuntime, pilot: &Pilot) { runtime.states = pilot.script_state.clone(); runtime.rng = pilot.script_rng.clone(); @@ -5042,6 +5061,34 @@ mod tests { .unwrap(); (dir, host) } + #[test] + fn terrain_palette_prepared_key_and_domain_reach_the_desktop_consumer() { + use sha2::{Digest,Sha256}; + let (_dir, mut host)=ui_fixture();let root=host.catalog.root.clone(); + let source=[1_i32,0,0,2,2].into_iter().flat_map(i32::to_le_bytes) + .chain([10_u8,20,10,20]).collect::>(); + std::fs::write(root.join("tile"),&source).unwrap(); + let mut index=std::fs::read_to_string(root.join("INDEX")).unwrap(); + index.push_str(&format!("3 {} tile\n",source.len()));std::fs::write(root.join("INDEX"),index).unwrap(); + let catalog=Catalog::open(&root).unwrap();let pack=root.join("private-hd");std::fs::create_dir(&pack).unwrap(); + image::RgbaImage::from_pixel(8,8,image::Rgba([200,170,120,255])).save(pack.join("raw.png")).unwrap(); + let mut frame=serde_json::json!({"frame":0,"file":"raw.png","logical_width":2,"logical_height":2, + "origin":[0,0],"source_sha256":format!("{:x}",Sha256::digest(&source))}); + let manifest=|frame:serde_json::Value|serde_json::json!({"format_version":1,"name":"synthetic terrain mode", + "sequences":[{"name":"tile","base_asset":3,"frame_count":1,"frames":[frame]}]}); + std::fs::write(pack.join("hd.json"),serde_json::to_vec(&manifest(frame.clone())).unwrap()).unwrap(); + let mut plain=HdAssets::load(&pack,&catalog);let mut plain_key=b"unchanged-prefix".to_vec(); + append_terrain_palette_policy_key(&mut plain,[3],&mut plain_key).unwrap();assert_eq!(plain_key,b"unchanged-prefix"); + frame["palette_constraint"]="original-painted-ordinary-v1".into(); + std::fs::write(pack.join("hd.json"),serde_json::to_vec(&manifest(frame)).unwrap()).unwrap(); + host.hd=HdAssets::load(&pack,&catalog);assert!(host.hd.diagnostics.is_empty()); + let mut mode_key=b"unchanged-prefix".to_vec();append_terrain_palette_policy_key(&mut host.hd,[3],&mut mode_key).unwrap(); + assert_ne!(plain_key,mode_key); + let mut duplicate_key=b"unchanged-prefix".to_vec();append_terrain_palette_policy_key(&mut host.hd,[3,3],&mut duplicate_key).unwrap();assert_eq!(mode_key,duplicate_key); + let (_,_,domain)=host.hd.terrain_sprite_with_palette(3,1,&host.palette).unwrap().unwrap();assert_eq!(domain.unwrap().indices(),[10,20]); + assert!(host.hd.terrain_sprite(3,1,&host.palette).is_err(),"old terrain consumer must not silently drop an opt-in domain"); + } + #[test] fn catalog_equipment_uses_hd_in_the_hud_and_loads_only_when_requested() { use sha2::{Digest, Sha256}; diff --git a/src/hd.rs b/src/hd.rs index 8a43f3ce..0a3bc1a3 100644 --- a/src/hd.rs +++ b/src/hd.rs @@ -55,6 +55,46 @@ pub struct AtlasGrid { pub column: u32, pub row: u32, } +/// Explicit opt-in; no payload colors or user-authored allowed list. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PaletteConstraint { + #[serde(rename = "original-painted-ordinary-v1")] + OriginalPaintedOrdinaryV1, +} +/// Validated, bounded Original-only index set; construction requires decoded Source. +#[derive(Debug, Clone)] +pub struct OriginalPalette { + mask: [u64; 4], + logical_size: [usize; 2], +} +impl OriginalPalette { + fn from_source(source: &Sprite) -> Result { + ensure!(source.width > 0 && source.height > 0 + && source.pixels.len() == source.width.saturating_mul(source.height) + && source.pixels.iter().all(|&p| p < 240), + "Original palette constraint needs an opaque ordinary Source canvas"); + let mut mask = [0_u64; 4]; + for &index in &source.pixels { mask[index as usize / 64] |= 1 << (index % 64); } + Ok(Self { mask, logical_size: [source.width, source.height] }) + } + pub fn contains(&self, index: u16) -> bool { + index < 240 && self.mask[index as usize / 64] & (1 << (index % 64)) != 0 + } + pub fn indices(&self) -> Vec { (0..240).filter(|&i| self.contains(i)).collect() } + pub fn logical_size(&self) -> [usize; 2] { self.logical_size } + pub fn identity(&self) -> [u8; 32] { + let mut digest=Sha256::new(); digest.update(b"original-painted-ordinary-v1"); + for word in self.mask { digest.update(word.to_le_bytes()); } + digest.finalize().into() + } + pub(crate) fn closest(&self, palette: &[[u8;3];256], rgb: [i32;3]) -> u16 { + (0_u16..240).filter(|&i| self.contains(i)).min_by_key(|&i| + (0..3).map(|c| (palette[i as usize][c] as i32-rgb[c]).pow(2)).sum::()) + .expect("validated Original palette is nonempty") + } + #[cfg(test)] + pub(crate) fn for_test(source: &Sprite) -> Self { Self::from_source(source).unwrap() } +} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Frame { @@ -89,6 +129,9 @@ pub struct Frame { /// no more than one quarter of the authentic painted Source cells. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub original_detail_cells: Vec<[u32; 2]>, + /// Indexed gameplay treatment of unchanged PNG samples; never enables Static. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub palette_constraint: Option, } /// Validated Source-only footprint metadata. Fields cannot be forged by callers. #[derive(Clone)] @@ -115,6 +158,7 @@ struct Replacement { source_labels: Option>, /// Only opt-in frames retain full indexed Source for Original ScaleNx. original_detail_source: Option, + original_palette: Option, validated: bool, rejected: bool, } @@ -347,7 +391,10 @@ impl HdAssets { } else { None }; - let label_bytes = source_labels.as_ref().map_or(0, Vec::len); + let original_palette = frame.palette_constraint + .map(|_| OriginalPalette::from_source(&original)).transpose()?; + let palette_bytes = if original_palette.is_some() { std::mem::size_of::() } else { 0 }; + let label_bytes = source_labels.as_ref().map_or(0, Vec::len) + palette_bytes; let detail_bytes = if frame.original_detail_cells.is_empty() { 0 } else { original.pixels.len() * std::mem::size_of::() }; ensure!(source_reference_budget(self.source_label_bytes, @@ -359,6 +406,7 @@ impl HdAssets { frame: frame.clone(), requires_alpha: original.pixels.contains(&TRANSPARENT), source_labels, + original_palette, original_detail_source: if frame.original_detail_cells.is_empty() { None } else { Some(original.clone()) }, validated: false, @@ -482,6 +530,9 @@ impl HdAssets { for coordinate in cell { digest.update(coordinate.to_le_bytes()); } } } + if frame.palette_constraint.is_some() { + digest.update(b"raptor-hd-original-painted-ordinary-palette-v1"); + } digest.finalize().into() } fn rgba_frame(&mut self, id: u32) -> Result> { @@ -811,8 +862,13 @@ impl HdAssets { /// Condition shared join inputs with joined Original samples, then restore /// exact retained footprints after all neighbor joins. pub fn terrain_sprite(&mut self, id: u32, scale: usize, palette: &[[u8; 3]; 256]) - -> Result)>> - { + -> Result)>> { + ensure!(!self.replacements.get(&id).is_some_and(|r|r.frame.palette_constraint.is_some()), + "constrained terrain requires terrain_sprite_with_palette and domain-aware joins"); + self.terrain_sprite_impl(id,scale,palette) + } + fn terrain_sprite_impl(&mut self, id:u32, scale:usize, palette:&[[u8;3];256]) + -> Result)>> { let has_details = self.replacements.get(&id) .is_some_and(|r| !r.frame.original_detail_cells.is_empty()); let policy = if has_details { PalettePolicy::SourceClassesBaseline } @@ -823,6 +879,21 @@ impl HdAssets { logical_size: [r.frame.logical_width as usize, r.frame.logical_height as usize] }); Ok(Some((image, details))) } + /// Existing API remains valid; terrain callers enabling the policy must use this wrapper. + pub fn terrain_sprite_with_palette(&mut self, id: u32, scale: usize, palette: &[[u8;3];256]) + -> Result, Option)>> { + Ok(self.terrain_sprite_impl(id, scale, palette)?.map(|(image,details)| + (image,details,self.replacements.get(&id).and_then(|r|r.original_palette.clone())))) + } + /// Append only for opt-ins to a caller's prepared-context key. + pub fn palette_constraint_cache_identity(&mut self, id: u32) -> Option<[u8;32]> { + let record=self.replacements.get(&id)?; + record.frame.palette_constraint?; + let frame=record.frame.clone(); + if let Ok(Some((key,_)))=self.rgba_frame(id) { return Some(key); } + let mut digest=Sha256::new();digest.update(b"rejected-original-painted-ordinary-v1"); + digest.update(serde_json::to_vec(&frame).ok()?);Some(digest.finalize().into()) + } pub fn original_detail_source_bytes(&self) -> usize { self.original_detail_source_bytes } /// None for all default frames, without opening/decoding their PNGs. /// Opt-ins bind actual resident raw identity; failed eligibility cannot reuse @@ -918,7 +989,10 @@ impl HdAssets { *destination = *colors .entry((rgb, source_label)) .or_insert_with(|| match policy { - PalettePolicy::SourceClasses | PalettePolicy::SourceClassesBaseline => closest(palette, rgb, source_label), + PalettePolicy::SourceClasses | PalettePolicy::SourceClassesBaseline => + replacement.original_palette.as_ref().map_or_else( + || closest(palette,rgb,source_label), + |domain| domain.closest(palette,rgb.map(i32::from))), PalettePolicy::Static => closest_in_range(palette, rgb, 0..256), }); } @@ -1195,6 +1269,7 @@ fn validate_rgba(rgba: &RgbaImage, requires_alpha: bool) -> Result<()> { } fn validate_frame(frame: &Frame, original: &Sprite, source: &[u8]) -> Result<()> { validate_grid_metadata(frame)?; + if frame.palette_constraint.is_some() { OriginalPalette::from_source(original)?; } ensure!( frame.logical_width as usize == original.width && frame.logical_height as usize == original.height, @@ -1318,7 +1393,7 @@ mod tests { crop: None, atlas_grid: None, preserve_reserved_coverage: false, native_nearest_cues: Vec::new(), - original_detail_cells: Vec::new(), + original_detail_cells: Vec::new(), palette_constraint: None, } } #[test] @@ -1356,7 +1431,7 @@ mod tests { frame: frame(source), requires_alpha: true, source_labels: None, - original_detail_source: None, + original_detail_source: None, original_palette: None, validated: false, rejected: false, }, @@ -2464,7 +2539,7 @@ mod policy_proposal_tests { crop: None, atlas_grid: None, preserve_reserved_coverage: false, native_nearest_cues: Vec::new(), - original_detail_cells: Vec::new(), + original_detail_cells: Vec::new(), palette_constraint: None, } } fn assets(label: u8, mode: bool, rgba: RgbaImage) -> (tempfile::TempDir, HdAssets) { @@ -2482,7 +2557,7 @@ mod policy_proposal_tests { frame: f, requires_alpha: true, source_labels: Some(vec![label, 0, 0, 0]), - original_detail_source: None, + original_detail_source: None, original_palette: None, validated: false, rejected: false, }, @@ -2737,7 +2812,7 @@ mod policy_proposal_tests { frame: f, requires_alpha: false, source_labels: None, - original_detail_source: None, + original_detail_source: None, original_palette: None, validated: false, rejected: false, }, @@ -2789,7 +2864,7 @@ mod retained_original_detail_tests { fn frame() -> Frame { Frame { frame: 0, file: "raw.png".into(), logical_width: 2, logical_height: 2, origin: [0,0], source_sha256: format!("{:x}",Sha256::digest(b"source")), crop: None, atlas_grid: None, preserve_reserved_coverage: false, native_nearest_cues: Vec::new(), - original_detail_cells: Vec::new() } } + original_detail_cells: Vec::new(), palette_constraint:None } } #[test] fn typed_serialization_defaults_and_source_constraints() { let f=frame();let j=serde_json::to_value(&f).unwrap(); @@ -2855,7 +2930,7 @@ mod retained_original_detail_tests { let mut h=HdAssets {root:dir.path().into(),..HdAssets::default()}; let mut f=frame();f.original_detail_cells=vec![[0,0]]; h.replacements.insert(1,Replacement {frame:f,requires_alpha:false,source_labels:None, - original_detail_source:Some(source()),validated:false,rejected:false}); + original_detail_source:Some(source()), original_palette: None,validated:false,rejected:false}); let mut palette=[[0;3];256];palette[80]=[255,0,0]; for scale in 1..=4 { let hybrid=h.sprite(1,scale,&palette).unwrap().unwrap(); @@ -2877,7 +2952,7 @@ mod retained_original_detail_tests { let raw_before=std::fs::read(dir.path().join("raw.png")).unwrap(); let original=Sprite{width:2,height:2,pixels:vec![252,253,80,TRANSPARENT]}; let mut h=HdAssets{root:dir.path().into(),..HdAssets::default()};let mut f=frame();f.original_detail_cells=vec![[0,0],[1,0]]; - h.replacements.insert(1,Replacement{frame:f,requires_alpha:true,source_labels:Some(vec![252,253,0,0]),original_detail_source:Some(original.clone()),validated:false,rejected:false}); + h.replacements.insert(1,Replacement{frame:f,requires_alpha:true,source_labels:Some(vec![252,253,0,0]),original_detail_source:Some(original.clone()),original_palette:None,validated:false,rejected:false}); let mut a=[[0;3];256];a[80]=[255,0,0];a[252]=[255,255,0];a[253]=[0,255,0]; let mut b=a;b[252]=[0,0,0];b[253]=[255,255,255]; for scale in 1..=4 { @@ -2929,7 +3004,7 @@ mod retained_original_detail_tests { raw.save(dir.path().join("raw.png")).unwrap(); let mut h=HdAssets{root:dir.path().into(),..HdAssets::default()};let mut f=frame();f.original_detail_cells=vec![[0,0]]; let original=Sprite{width:2,height:2,pixels:vec![252,80,80,TRANSPARENT]}; - h.replacements.insert(1,Replacement{frame:f,requires_alpha:true,source_labels:Some(vec![252,0,0,0]),original_detail_source:Some(original),validated:false,rejected:false}); + h.replacements.insert(1,Replacement{frame:f,requires_alpha:true,source_labels:Some(vec![252,0,0,0]),original_detail_source:Some(original),original_palette:None,validated:false,rejected:false}); assert!(h.sprite(1,1,&[[0;3];256]).unwrap().is_none()); assert!(h.terrain_sprite(1,1,&[[0;3];256]).unwrap().is_none()); assert!(!h.diagnostics.is_empty()); @@ -3002,7 +3077,7 @@ mod rational_atlas_grid_tests { logical_height: 1, origin: [0, 0], source_sha256: "00".repeat(32), crop: None, atlas_grid: Some(AtlasGrid { columns: 2, rows: 2, column: 0, row: 0 }), preserve_reserved_coverage: false, native_nearest_cues: Vec::new(), - original_detail_cells: Vec::new() } + original_detail_cells: Vec::new(), palette_constraint:None } } fn info() -> PngInfo { PngInfo { size: 1, modified: None, digest: [1; 32], width: 17, height: 17 } @@ -3133,3 +3208,82 @@ mod extended_retained_original_bound_tests { assert_eq!(original_detail_policy_tag(65,false),original_detail_policy_tag(128,false)); } } + +#[cfg(test)] +mod original_palette_constraint_tests { + use super::*; + fn source(pixels:Vec) -> Sprite { Sprite { width:2,height:2,pixels } } + fn frame() -> Frame { Frame { frame:0,file:"p.png".into(),logical_width:2,logical_height:2, + origin:[0,0],source_sha256:format!("{:x}",Sha256::digest(b"x")),crop:None,atlas_grid:None, + preserve_reserved_coverage:false,native_nearest_cues:vec![],original_detail_cells:vec![],palette_constraint:None } } + fn palette() -> [[u8;3];256] { let mut p=[[0;3];256];p[10]=[50,30,20];p[20]=[100,70,50];p[30]=[250,220,190];p } + fn assets(domain:bool,alpha:u8) -> (tempfile::TempDir,HdAssets) { + let dir=tempfile::tempdir().unwrap(); + let mut raw=RgbaImage::from_pixel(8,8,image::Rgba([230,200,160,alpha])); + for y in 0..8 { for x in 0..4 {raw.put_pixel(x,y,image::Rgba([5,5,5,alpha]));}} + raw.save(dir.path().join("p.png")).unwrap(); + let mut f=frame();if domain { f.palette_constraint=Some(PaletteConstraint::OriginalPaintedOrdinaryV1); } + let mut h=HdAssets {root:dir.path().into(),..HdAssets::default()}; + h.replacements.insert(1,Replacement {frame:f,requires_alpha:false,source_labels:None, + original_detail_source:None,original_palette:domain.then(||OriginalPalette::from_source(&source(vec![10,20,10,20])).unwrap()),validated:false,rejected:false}); + (dir,h) + } + #[test] fn exact_derived_domain_rejects_empty_transparent_reserved_and_bad_extent() { + for s in [source(vec![10,20,256,10]),source(vec![240,10,10,10]),source(vec![255,10,10,10]),source(vec![10])] { + assert!(OriginalPalette::from_source(&s).is_err()); + } + assert!(OriginalPalette::from_source(&Sprite::blank(0,0)).is_err()); + let d=OriginalPalette::from_source(&source(vec![20,10,10,20])).unwrap();assert_eq!(d.indices(),[10,20]); + assert!(!d.contains(240));assert!(!d.contains(256));assert!(!d.contains(u16::MAX)); + } + #[test] fn exact_domain_tie_and_palette_identity_are_deterministic() { + let d=OriginalPalette::from_source(&source(vec![10,20,10,20])).unwrap(); + let p=[[0;3];256];assert_eq!(d.closest(&p,[0;3]),10); + let mut p=palette();assert_eq!(d.closest(&p,[99,70,50]),20);p[10]=[99,70,50];p[20]=[0;3];assert_eq!(d.closest(&p,[99,70,50]),10); + } + #[test] fn absent_field_serialization_and_frame_keys_are_exact_legacy() { + let f=frame();let v=serde_json::to_value(&f).unwrap();assert!(v.get("palette_constraint").is_none()); + let info=PngInfo {size:1,modified:None,digest:[2;32],width:8,height:8}; + let key=HdAssets::frame_key(&f,&info,false); + let mut v=serde_json::to_value(&f).unwrap();v["palette_constraint"]=serde_json::Value::Null; + let f2:Frame=serde_json::from_value(v).unwrap();assert_eq!(serde_json::to_value(&f2).unwrap(),serde_json::to_value(&f).unwrap());assert_eq!(HdAssets::frame_key(&f2,&info,false),key); + let mut f3=f.clone();f3.palette_constraint=Some(PaletteConstraint::OriginalPaintedOrdinaryV1);assert_ne!(HdAssets::frame_key(&f3,&info,false),key); + } + #[test] fn unsupported_metadata_and_source_scope_reject() { + for value in [serde_json::json!(true),serde_json::json!(1),serde_json::json!("ordinary"),serde_json::json!([])] { + let mut v=serde_json::to_value(frame()).unwrap();v["palette_constraint"]=value;assert!(serde_json::from_value::(v).is_err()); + } + let mut f=frame();f.palette_constraint=Some(PaletteConstraint::OriginalPaintedOrdinaryV1); + assert!(validate_frame(&f,&source(vec![10,20,10,20]),b"x").is_ok()); + assert!(validate_frame(&f,&source(vec![10,240,10,20]),b"x").is_err()); + assert!(validate_frame(&f,&source(vec![10,256,10,20]),b"x").is_err()); + } + #[test] fn actual_sampler_all4_domain_static_and_alpha_are_separate() { + let (_a,mut plain)=assets(false,255);let (_b,mut constrained)=assets(true,255); + for scale in 1..=4 { + let p=plain.sprite(1,scale,&palette()).unwrap().unwrap();let c=constrained.sprite(1,scale,&palette()).unwrap().unwrap(); + assert!(p.pixels.contains(&30));assert!(c.pixels.iter().all(|i|[10,20].contains(i)));assert!(c.pixels.contains(&10)&&c.pixels.contains(&20)); + assert_eq!(plain.sprite_static_palette(1,scale,&palette()).unwrap(),constrained.sprite_static_palette(1,scale,&palette()).unwrap()); + assert!(constrained.terrain_sprite(1,scale,&palette()).is_err()); + let (terrain,_,domain)=constrained.terrain_sprite_with_palette(1,scale,&palette()).unwrap().unwrap();assert_eq!(c,terrain);assert_eq!(domain.unwrap().indices(),[10,20]); + } + assert!(plain.palette_constraint_cache_identity(1).is_none());assert!(constrained.palette_constraint_cache_identity(1).is_some()); + let (_a,mut plain)=assets(false,0);let (_b,mut constrained)=assets(true,0); + for scale in 1..=4 {assert_eq!(plain.sprite(1,scale,&palette()).is_err(),constrained.sprite(1,scale,&palette()).is_err());} + } + #[test] fn alpha_threshold_mask_does_not_gain_source_coverage() { + let (_a,mut p)=assets(false,255);let (_b,mut c)=assets(true,255); + for h in [&mut p,&mut c] {let path=h.root.join("p.png");let mut raw=image::open(&path).unwrap().to_rgba8();for y in 0..4 {for x in 0..4 {raw.put_pixel(x,y,image::Rgba([60,40,25,0]));}}raw.save(path).unwrap();} + for scale in 1..=4 {let a=p.sprite(1,scale,&palette()).unwrap().unwrap();let b=c.sprite(1,scale,&palette()).unwrap().unwrap();assert_eq!(a.pixels.iter().map(|&i|i==TRANSPARENT).collect::>(),b.pixels.iter().map(|&i|i==TRANSPARENT).collect::>());} + } +} + +#[cfg(test)] mod palette_collapse_truthful_test { + use super::*; + #[test] fn a_uniform_overbright_sample_can_collapse_even_a_valid_source_palette() { + let source=Sprite{width:2,height:1,pixels:vec![10,20]};let domain=OriginalPalette::from_source(&source).unwrap(); + let mut palette=[[0;3];256];palette[10]=[50,30,20];palette[20]=[100,70,50]; + let mapped:std::collections::BTreeSet<_>=(200..=255).map(|x|domain.closest(&palette,[x,x,x])).collect(); + assert_eq!(mapped,std::collections::BTreeSet::from([20])); + } +} diff --git a/src/terrain_hd.rs b/src/terrain_hd.rs index 00426cbc..018dee4e 100644 --- a/src/terrain_hd.rs +++ b/src/terrain_hd.rs @@ -71,6 +71,11 @@ pub struct RetainedOriginalPlacement { pub replacement_index: usize, pub details: crate::hd::OriginalDetailCells, } +/// Trusted per-owner domain. Existing Replacement literals and APIs stay intact. +pub struct PaletteConstraintPlacement { + pub replacement_index: usize, + pub palette: crate::hd::OriginalPalette, +} pub struct Composition { pub image: Sprite, pub results: Vec, @@ -132,6 +137,12 @@ impl TerrainHd { pub fn compose_retaining_original(&mut self, context: &Sprite, replacements: &[Replacement], retained: &[RetainedOriginalPlacement], scale: usize, palette: &[[u8; 3]; 256]) -> Composition { + self.compose_with_palette_constraints(context,replacements,retained,&[],scale,palette) + } + pub fn compose_with_palette_constraints(&mut self, context: &Sprite, + replacements: &[Replacement], retained: &[RetainedOriginalPlacement], + constraints: &[PaletteConstraintPlacement], scale: usize, + palette: &[[u8;3];256]) -> Composition { let mut image = context.clone(); let mut results = Vec::with_capacity(replacements.len()); let mut accepted = BTreeMap::new(); @@ -145,6 +156,22 @@ impl TerrainHd { if accepted.is_empty() { return Composition { image, results }; } + let mut domains = BTreeMap::new(); + for constraint in constraints { + let index=constraint.replacement_index; + let Some(tile)=replacements.get(index) else { + return Composition { image:context.clone(),results:vec![OverlayResult::InvalidOriginalDetails;replacements.len()] }; + }; + // Rejected or shadowed replacements cannot own a palette domain. + // Match retained-placement fallback: invalid accepted metadata still + // fails closed, but an ineligible tile must not suppress its neighbor. + if accepted.get(&(tile.position,tile.logical_size)) != Some(&index) { continue; } + if domains.insert(index,&constraint.palette).is_some() + || constraint.palette.logical_size()!=tile.logical_size + || tile.image.pixels.iter().any(|&p| !constraint.palette.contains(p)) { + return Composition { image:context.clone(),results:vec![OverlayResult::InvalidOriginalDetails;replacements.len()] }; + } + } // Validate/project all accepted protected footprints before composition. // Conflicting ownership fails the entire opt-in context to Original; // default/no-retention composition remains exactly the old route. @@ -228,7 +255,7 @@ impl TerrainHd { .min(tile.image.width / 2) .min(tile.image.height / 2) .max(1); - let edges = self.tile_edges(tile, &accepted, &detailed, context, scale, band, palette); + let edges = self.tile_edges(tile, &accepted, &detailed, context, scale, band, palette, domains.get(&index).copied()); let weights: [usize; 8] = std::array::from_fn(|distance| edge_weight(distance, band)); for sy in 0..tile.image.height { let yy = top as i64 + sy as i64; @@ -292,7 +319,7 @@ impl TerrainHd { ) }; image.pixels[destination] = - self.transition(target, raw, weights[distance], palette); + self.transition_with_palette(target, raw, weights[distance], palette, domains.get(&index).copied()); } } } @@ -371,6 +398,7 @@ impl TerrainHd { scale: usize, band: usize, palette: &[[u8; 3]; 256], + domain: Option<&crate::hd::OriginalPalette>, ) -> EdgeTargets { let [width, height] = tile.logical_size; let [left, top] = tile @@ -427,7 +455,7 @@ impl TerrainHd { [left + offset as i64, bottom + distance], ], }; - self.shared_target(detailed, context, &points, palette) + self.shared_target(detailed, context, &points, palette, domain) }) .collect() }); @@ -457,7 +485,7 @@ impl TerrainHd { [bx - 1 - dx, by + dy], [bx + dx, by + dy], ], - palette, + palette, domain, ) }) .collect() @@ -480,6 +508,7 @@ impl TerrainHd { context: &Sprite, points: &[[i64; 2]], palette: &[[u8; 3]; 256], + domain: Option<&crate::hd::OriginalPalette>, ) -> Option<(u16, u16)> { let mut values = [0_u16; 4]; let mut first = None; @@ -499,16 +528,29 @@ impl TerrainHd { } values[point] = detail; } - let top = self.transition(values[0], values[1], LEVELS / 2, palette); + let top = self.transition_with_palette(values[0], values[1], LEVELS / 2, palette, domain); let target = if points.len() == 2 { top } else { - let bottom = self.transition(values[2], values[3], LEVELS / 2, palette); - self.transition(top, bottom, LEVELS / 2, palette) + let bottom = self.transition_with_palette(values[2], values[3], LEVELS / 2, palette, domain); + self.transition_with_palette(top, bottom, LEVELS / 2, palette, domain) }; Some((target, first.unwrap())) } + // Opt-in per-owner search never uses the global transition cache. + // Weight0 is the authentic Original boundary; reserved/control labels keep + // the exact existing class route. Those are not generated contributions. + fn transition_with_palette(&mut self, original:u16, hd:u16, weight:usize, + palette:&[[u8;3];256], domain:Option<&crate::hd::OriginalPalette>) -> u16 { + let Some(domain)=domain else {return self.transition(original,hd,weight,palette)}; + if original>=240 || weight==0 { return self.transition(original,hd,weight,palette); } + if (original==hd || weight==LEVELS-1) && domain.contains(hd) { return hd; } + let rgb=std::array::from_fn(|channel| + ((palette[original as usize][channel] as usize*(LEVELS-1-weight) + +palette[hd as usize][channel] as usize*weight+(LEVELS-1)/2)/(LEVELS-1)) as i32); + domain.closest(palette,rgb) + } fn transition( &mut self, original: u16, @@ -935,3 +977,103 @@ mod protected_join_input_regression_tests { } } } + +#[cfg(test)] +mod palette_constraint_join_tests { + use super::*; + fn palette()->[[u8;3];256] { let mut p=[[0;3];256];p[10]=[40,40,40];p[20]=[120,120,120];p[30]=[80,80,80];p[40]=[60,60,60];p[50]=[100,100,100];p } + fn domain(a:u16,b:u16)->crate::hd::OriginalPalette {crate::hd::OriginalPalette::for_test(&Sprite {width:4,height:4,pixels:(0..16).map(|i|if i%2==0 {a}else{b}).collect()})} + fn tile(x:i32,a:u16,b:u16,s:usize)->Replacement {Replacement {image:Sprite {width:4*s,height:4*s,pixels:(0..16*s*s).map(|i|if (i/(4*s))%2==0 {a}else{b}).collect()},position:[x,0],logical_size:[4,4]}} + #[test] fn join_quantizes_direct_blended_rgb_to_owner_domain_not_global_range() { + let d=domain(10,20);let mut t=TerrainHd::default();let p=palette(); + t.prepare_palette(&p);assert_eq!(t.transition(10,20,8,&p),30); + assert!(d.contains(t.transition_with_palette(10,20,8,&p,Some(&d)))); + assert_eq!(t.transition_with_palette(10,20,0,&p,Some(&d)),10); + assert_eq!(t.transition_with_palette(252,20,8,&p,Some(&d)),252); + } + #[test] fn exact_domains_survive_all4_and_order_permutations_with_disjoint_sets() { + for scale in 1..=4 { + let context=Sprite {width:8*scale,height:4*scale,pixels:vec![10;32*scale*scale]}; + let tiles=[tile(0,10,20,scale),tile(4,40,50,scale)]; + let domains=[PaletteConstraintPlacement {replacement_index:0,palette:domain(10,20)},PaletteConstraintPlacement {replacement_index:1,palette:domain(40,50)}]; + let mut t=TerrainHd::default();let a=t.compose_with_palette_constraints(&context,&tiles,&[],&domains,scale,&palette());assert!(a.results.iter().all(|r|*r==OverlayResult::Applied)); + for y in 0..a.image.height { for x in 0..a.image.width {let i=a.image.pixels[y*a.image.width+x];if x<4*scale {assert!([10,20].contains(&i));}else{assert!([10,40,50].contains(&i));}}} + let perm=[tile(4,40,50,scale),tile(0,10,20,scale)];let domains=[PaletteConstraintPlacement {replacement_index:1,palette:domain(10,20)},PaletteConstraintPlacement {replacement_index:0,palette:domain(40,50)}]; + let b=t.compose_with_palette_constraints(&context,&perm,&[],&domains,scale,&palette());assert_eq!(a.image,b.image); + } + } + #[test] fn no_domain_is_exact_old_wrapper_and_mixed_neighbor_changes_are_bounded() { + for scale in 1..=4 {let context=Sprite {width:8*scale,height:4*scale,pixels:vec![10;32*scale*scale]};let tiles=[tile(0,10,20,scale),tile(4,40,50,scale)];let mut t=TerrainHd::default();let a=t.compose(&context,&tiles,scale,&palette());let b=t.compose_with_palette_constraints(&context,&tiles,&[],&[],scale,&palette());assert_eq!(a.image,b.image);let c=t.compose_with_palette_constraints(&context,&tiles,&[],&[PaletteConstraintPlacement {replacement_index:0,palette:domain(10,20)}],scale,&palette());for y in 0..c.image.height {for x in 6*scale..8*scale {assert_eq!(a.image.pixels[y*a.image.width+x],c.image.pixels[y*c.image.width+x]);}}} + } + #[test] fn domain_validation_and_retained_original_priority_preserve_source_labels() { + let context=Sprite {width:4,height:4,pixels:vec![10;16]};let mut t=TerrainHd::default();let tiles=[tile(0,10,30,1)];let invalid=t.compose_with_palette_constraints(&context,&tiles,&[],&[PaletteConstraintPlacement {replacement_index:0,palette:domain(10,20)}],1,&palette());assert_eq!(invalid.image,context);assert!(invalid.results.iter().all(|r|*r==OverlayResult::InvalidOriginalDetails)); + let mut context=context;context.pixels[5]=252;let tiles=[tile(0,10,20,1)];let details=RetainedOriginalPlacement {replacement_index:0,details:crate::hd::OriginalDetailCells::for_test(vec![[1,1]],[4,4])};let result=t.compose_with_palette_constraints(&context,&tiles,&[details],&[PaletteConstraintPlacement {replacement_index:0,palette:domain(10,20)}],1,&palette());assert_eq!(result.image.pixels[5],252); + } +} + +#[cfg(test)] +mod palette_rejected_owner_fallback_regression_tests { + use super::*; + fn palette()->[[u8;3];256] {std::array::from_fn(|i|[i as u8;3])} + fn tile(x:i32,index:u16,scale:usize)->Replacement { + Replacement {image:Sprite {width:4*scale,height:4*scale,pixels:vec![index;16*scale*scale]},position:[x,0],logical_size:[4,4]} + } + fn domain()->crate::hd::OriginalPalette { + crate::hd::OriginalPalette::for_test(&Sprite {width:4,height:4,pixels:vec![10;16]}) + } + // Empty-domain per-owner counterfactual, not an external old-library claim. + fn default_owner_baseline(context:&Sprite,tiles:&[Replacement],scale:usize)->Composition { + TerrainHd::default().compose(context,tiles,scale,&palette()) + } + #[test] + fn rejected_transparent_domain_is_local_fallback_at_all_scales_and_orders() { + for scale in 1..=4 { for reject_all in [false,true] {for rejected_first in [false,true] { + let context=Sprite {width:8*scale,height:4*scale,pixels:vec![10;32*scale*scale]}; + let mut rejected=tile(0,10,scale); + if reject_all {rejected.image.pixels.fill(TRANSPARENT);} else {rejected.image.pixels[scale*rejected.image.width+scale]=TRANSPARENT;} + let valid=tile(4,100,scale); + let (tiles,rejected_index)=if rejected_first {(vec![rejected,valid],0)} else {(vec![valid,rejected],1)}; + let expected=default_owner_baseline(&context,&tiles,scale); + let actual=TerrainHd::default().compose_with_palette_constraints(&context,&tiles,&[], + &[PaletteConstraintPlacement {replacement_index:rejected_index,palette:domain()}],scale,&palette()); + let reason=if rejected_first {vec![OverlayResult::NonOpaque,OverlayResult::Applied]} else {vec![OverlayResult::Applied,OverlayResult::NonOpaque]}; + assert_eq!(actual.results,reason); + assert_eq!(actual.image,expected.image,"scale{scale},all{reject_all},first{rejected_first}"); + let mut changed_valid=0; + for y in 0..4*scale {for x in 0..8*scale { + let i=y*actual.image.width+x; + if x<4*scale {assert_eq!(actual.image.pixels[i],context.pixels[i]);} + else if actual.image.pixels[i]!=context.pixels[i] {changed_valid+=1;} + }} + assert!(changed_valid>0,"valid neighbor must actually render, not pass via all-Original"); + }}} + } + #[test] + fn shadowed_duplicate_placement_domain_is_ignored_at_all_scales_and_orders() { + for scale in 1..=4 {for neighbor_first in [false,true] { + let context=Sprite {width:8*scale,height:4*scale,pixels:vec![10;32*scale*scale]}; + // Shadowed image is opaque, but deliberately outside its domain. + // It does not own the accepted slot; current fallback must ignore it. + let (tiles,shadowed)=if neighbor_first {(vec![tile(4,70,scale),tile(0,200,scale),tile(0,100,scale)],1)} + else {(vec![tile(0,200,scale),tile(0,100,scale),tile(4,70,scale)],0)}; + let expected=default_owner_baseline(&context,&tiles,scale); + let actual=TerrainHd::default().compose_with_palette_constraints(&context,&tiles,&[], + &[PaletteConstraintPlacement {replacement_index:shadowed,palette:domain()}],scale,&palette()); + assert_eq!(actual.results,vec![OverlayResult::Applied;3]);assert_eq!(actual.image,expected.image); + assert_ne!(actual.image,context,"last valid owner and neighbor must actually apply"); + }} + } + #[test] + fn invalid_accepted_domain_remains_whole_context_fail_closed_at_all_scales() { + for scale in 1..=4 { + let context=Sprite {width:8*scale,height:4*scale,pixels:vec![10;32*scale*scale]}; + let tiles=[tile(0,200,scale),tile(4,100,scale)]; + let actual=TerrainHd::default().compose_with_palette_constraints(&context,&tiles,&[], + &[PaletteConstraintPlacement {replacement_index:0,palette:domain()}],scale,&palette()); + assert_eq!(actual.image,context);assert_eq!(actual.results,vec![OverlayResult::InvalidOriginalDetails;2]); + let out_of_range=TerrainHd::default().compose_with_palette_constraints(&context,&tiles,&[], + &[PaletteConstraintPlacement {replacement_index:999,palette:domain()}],scale,&palette()); + assert_eq!(out_of_range.image,context);assert_eq!(out_of_range.results,vec![OverlayResult::InvalidOriginalDetails;2]); + } + } +} diff --git a/tests/hd_terrain.rs b/tests/hd_terrain.rs index 7762b431..91634b0b 100644 --- a/tests/hd_terrain.rs +++ b/tests/hd_terrain.rs @@ -1140,3 +1140,40 @@ fn canonical_terrain_candidate_preserves_all_declared_contexts_and_neighbor_pair output.display() ); } + +#[test] +fn checked_original_palette_domain_reaches_public_terrain_joins_without_changing_the_png() { + use sha2::{Digest,Sha256}; + use raptor_replica::terrain_hd::PaletteConstraintPlacement; + let temp=tempfile::tempdir().unwrap();let data=temp.path().join("catalog");let pack=temp.path().join("hd"); + std::fs::create_dir_all(data.join("misc")).unwrap();std::fs::create_dir(&pack).unwrap(); + let source=[1_i32,0,0,4,4].into_iter().flat_map(i32::to_le_bytes) + .chain((0..16).map(|i|if i%2==0 {10_u8}else{20_u8})).collect::>(); + std::fs::write(data.join("tile"),&source).unwrap(); + let raw_palette:Vec=(0..256).flat_map(|i|[i as u8%64;3]).collect(); + std::fs::write(data.join("misc/PALETTE_DAT"),&raw_palette).unwrap(); + std::fs::write(data.join("INDEX"),format!("1 768 misc/PALETTE_DAT\n120 {} tile\n",source.len())).unwrap(); + let raw=pack.join("raw.png");image::RgbaImage::from_pixel(16,16,image::Rgba([220,190,150,255])).save(&raw).unwrap(); + let raw_sha=Sha256::digest(std::fs::read(&raw).unwrap()); + let manifest=json!({"format_version":1,"name":"synthetic policy integration","sequences":[ + {"name":"checked Original","base_asset":0x120,"frame_count":1,"frames":[ + {"frame":0,"file":"raw.png","logical_width":4,"logical_height":4,"origin":[0,0], + "source_sha256":format!("{:x}",Sha256::digest(&source)),"palette_constraint":"original-painted-ordinary-v1"}]}]}); + std::fs::write(pack.join("hd.json"),serde_json::to_vec(&manifest).unwrap()).unwrap(); + let catalog=Catalog::open(&data).unwrap();let p=catalog.palette().unwrap();let mut hd=HdAssets::load(&pack,&catalog); + assert!(hd.diagnostics.is_empty()); + for scale in 1..=4 { + assert!(hd.terrain_sprite(0x120,scale,&p).is_err(),"a legacy consumer must not silently discard the domain"); + let (sprite,details,domain)=hd.terrain_sprite_with_palette(0x120,scale,&p).unwrap().unwrap(); + assert!(details.is_none());let domain=domain.unwrap();assert_eq!(domain.indices(),[10,20]); + assert!(sprite.pixels.iter().all(|&i|domain.contains(i))); + let context=solid(8*scale,4*scale,10);let neighbor=solid(4*scale,4*scale,100); + let tiles=[Replacement {image:sprite,position:[0,0],logical_size:[4,4]}, + Replacement {image:neighbor,position:[4,0],logical_size:[4,4]}]; + let result=TerrainHd::default().compose_with_palette_constraints(&context,&tiles,&[], + &[PaletteConstraintPlacement {replacement_index:0,palette:domain}],scale,&p); + assert_eq!(result.results,vec![OverlayResult::Applied,OverlayResult::Applied]); + for y in 0..4*scale {for x in 0..4*scale {assert!([10,20].contains(&result.image.pixels[y*result.image.width+x]));}} + } + assert_eq!(Sha256::digest(std::fs::read(raw).unwrap()),raw_sha,"quantization must not rewrite the source PNG"); +} diff --git a/tests/test_hd_extended_retained_bound.py b/tests/test_hd_extended_retained_bound.py index 7bd1f1d4..929d7497 100644 --- a/tests/test_hd_extended_retained_bound.py +++ b/tests/test_hd_extended_retained_bound.py @@ -127,10 +127,31 @@ class RustSourceCompatibility(unittest.TestCase): start=s.index('fn '+name+'(');left=s.index('{',start);depth=1;end=left+1 while depth:depth+=(s[end]=='{')-(s[end]=='}');end+=1 return s[start:end] - for name in ['source_reference_budget','retain_original_detail_samples','sprite_with_policy','terrain_sprite']: + # Keep the old fingerprints. Strip only the explicit new opt-in branch; + # the None/default sampler and Original-retention kernel must remain exact. + for name in ['source_reference_budget','retain_original_detail_samples']: self.assertEqual(expected[name],hashlib.sha256(function(new,name).encode()).hexdigest(),name) + sampler = function(new,'sprite_with_policy') + opt_in = """PalettePolicy::SourceClasses | PalettePolicy::SourceClassesBaseline => + replacement.original_palette.as_ref().map_or_else( + || closest(palette,rgb,source_label), + |domain| domain.closest(palette,rgb.map(i32::from))),""" + self.assertEqual(sampler.count(opt_in),1) + sampler=sampler.replace(opt_in,'PalettePolicy::SourceClasses | PalettePolicy::SourceClassesBaseline => closest(palette, rgb, source_label),') + self.assertEqual(expected['sprite_with_policy'],hashlib.sha256(sampler.encode()).hexdigest()) + implementation=function(new,'terrain_sprite_impl') + signature='fn terrain_sprite(&mut self, id: u32, scale: usize, palette: &[[u8; 3]; 256])\n -> Result)>>\n {' + normalized=signature+implementation[implementation.index('{')+1:] + self.assertEqual(expected['terrain_sprite'],hashlib.sha256(normalized.encode()).hexdigest()) + wrapper=function(new,'terrain_sprite') + self.assertIn('palette_constraint.is_some()',wrapper) + self.assertIn('self.terrain_sprite_impl(id,scale,palette)',wrapper) for name,old_bytes,call in [('frame_key','b"raptor-retained-original-details-v2-painted0255"','original_detail_policy_tag(frame.original_detail_cells.len(), false)'),('original_detail_cache_identity','b"retained-original-rejected-fallback-v2-painted0255"','original_detail_policy_tag(frame.original_detail_cells.len(), true)')]: - self.assertEqual(expected[name],hashlib.sha256(function(new,name).replace(call,old_bytes).encode()).hexdigest(),name) + body=function(new,name) + if name=='frame_key': + branch=' if frame.palette_constraint.is_some() {\n digest.update(b"raptor-hd-original-painted-ordinary-palette-v1");\n }\n' + self.assertEqual(body.count(branch),1);body=body.replace(branch,'') + self.assertEqual(expected[name],hashlib.sha256(body.replace(call,old_bytes).encode()).hexdigest(),name) def test_audit_still_calls_actual_payload_validator(self): tree=ast.parse((ROOT/'tools/hd_art_audit.py').read_text()) node=next(n for n in tree.body if isinstance(n,ast.FunctionDef) and n.name=='validate_hd_frame') diff --git a/tests/test_hd_palette_constraint.py b/tests/test_hd_palette_constraint.py new file mode 100644 index 00000000..25a0578a --- /dev/null +++ b/tests/test_hd_palette_constraint.py @@ -0,0 +1,97 @@ +import unittest,sys,struct,copy,importlib.util +from pathlib import Path +sys.dont_write_bytecode=True +sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools')) +import hd_frame_policy as policy +import hd_candidate_review as candidate +import hd_register_reviewed as registrar +def frame():return dict(frame=0,file='raw.png',logical_width=2,logical_height=2,origin=[0,0],source_sha256='0'*64) +def payload(indices):return struct.pack('>4);return dict(index=i,RGB=[v,v,v]) + qa=json.loads((self.root/'qa.json').read_text());qa['frame_bindings_sha256']=digest + report=dict(asset='00001',source_sha256=self.f['source_sha256'],source_palette='PALETTE_DAT',logical_canvas=[2,2],source_mask=dict(palette_indices_240_to_255_counts=[0]*16),hd_masks=[dict(scale=s,mask=dict(visible_pixels=4*s*s,palette_indices_240_to_255_counts=[0]*16)) for s in range(1,5)],palette_constraint=dict(policy='original-painted-ordinary-v1',applied=True,source_indices=[10,20],source_colors=[color(10),color(20)],scales=[dict(scale=s,visible_pixels=4*s*s,outside_original_palette=0) for s in range(1,5)])) + qa['frames']=[report];self.qa=qa;self.write_json(self.root/'qa.json',qa) + def approve(self):return retained_fixture.ActualRegistrarProvenance.approve(self) + def test_real_approved_job_exact_palette_scope_passes_under_all_writer_process_image_traps(self): + _,review,rows=self.approve();self.assertEqual(rows,self.rows);self.assertIs(review['palette_constraint_review']['unchanged_generated_raw'],True) + def test_old_raw_review_or_default_sampler_evidence_cannot_approve_the_new_mode(self): + original=self.review.pop('palette_constraint_review') + with self.assertRaisesRegex(ValueError,'explicit palette'):self.approve() + self.review['palette_constraint_review']=original;del self.qa['frames'][0]['palette_constraint'];self.write_json(self.root/'qa.json',self.qa) + with self.assertRaisesRegex(ValueError,'exact SourceClasses'):self.approve() + def test_each_treatment_scope_boolean_version_and_binding_is_required(self): + base=self.review['palette_constraint_review'] + for key,value in dict(policy_version='other',frame_bindings_sha256='0'*64,unchanged_generated_raw=1,independent_allscale_and_genuine_context_review='yes').items(): + self.review['palette_constraint_review']=dict(base,**{key:value}) + with self.subTest(key=key),self.assertRaises(ValueError):self.approve() + self.review['palette_constraint_review']=base + def test_measured_indices_colors_static_mode_and_typed_scales_cannot_alias(self): + original=copy.deepcopy(self.qa) + mutations=[lambda e:e.update(source_indices=[True,20]),lambda e:e.update(source_indices=[20,10]),lambda e:e.update(source_colors=[dict(index=10,RGB=[0,0,0]),e['source_colors'][1]]),lambda e:e.update(applied=1),lambda e:e['scales'][0].update(scale=True),lambda e:e['scales'][1].update(visible_pixels=16.0),lambda e:e['scales'][2].update(outside_original_palette=1),lambda e:e.update(scales=[{},None])] + for change in mutations: + self.qa=copy.deepcopy(original);change(self.qa['frames'][0]['palette_constraint']);self.write_json(self.root/'qa.json',self.qa) + with self.subTest(change=change),self.assertRaises(ValueError):self.approve() + def test_actual_source_and_rgb_domain_are_checked_from_catalog_not_supplied_qa(self): + (self.project/'data/tile').write_bytes(payload([10,240,10,20])) + with self.assertRaisesRegex(ValueError,'authentic opaque ordinary'):self.approve() + def test_candidate_digest_binds_policy_while_default_dictionary_stays_unchanged(self): + plain=copy.deepcopy(self.candidate);plain['sequences'][0]['frames'][0].pop('palette_constraint') + self.assertNotEqual(candidate.binding_digest(candidate.proposal_frames(plain)),candidate.binding_digest(self.rows)) + self.assertNotIn('palette_constraint',candidate.proposal_frames(plain)['00001']) + +if __name__=="__main__":unittest.main(verbosity=2) diff --git a/tools/hd_candidate_review.py b/tools/hd_candidate_review.py index b40d1d1d..a085488d 100644 --- a/tools/hd_candidate_review.py +++ b/tools/hd_candidate_review.py @@ -216,7 +216,9 @@ def main(): shutil.copyfile(source, destination) (isolated / "hd.json").write_text(json.dumps(manifest)) binary = isolated / "review-bin" - compiler_args = ["rustc", "--edition=2021", str(project / "tools/hd_candidate_review.rs"), "--extern", f"raptor_replica={library}", "--extern", f"serde_json={serde}", "-L", f"dependency={dependency_directory}", "-L", "native=" + str(Path.home() / ".local/lib"), "-o", str(binary)] + review_source = project / ("tools/hd_palette_review.rs" if any( + frame.get("palette_constraint") is not None for frame in rows.values()) else "tools/hd_candidate_review.rs") + compiler_args = ["rustc", "--edition=2021", str(review_source), "--extern", f"raptor_replica={library}", "--extern", f"serde_json={serde}", "-L", f"dependency={dependency_directory}", "-L", "native=" + str(Path.home() / ".local/lib"), "-o", str(binary)] if static_cards: compiler_args.extend(["--cfg", "static_card_api"]) subprocess.run(compiler_args, check=True) @@ -246,6 +248,9 @@ def main(): qa["frame_bindings_sha256"] = bindings_at_start qa["artwork_sha256"] = artwork_hashes qa["static_card_palette_mode"] = static_cards + if any(frame.get("palette_constraint") is not None for frame in rows.values()): + qa["review_helper_source"] = str(review_source.relative_to(project)) + qa["review_helper_source_sha256"] = hashlib.sha256(review_source.read_bytes()).hexdigest() qa["source_newer_than_library"] = any((project / "src" / name).stat().st_mtime_ns > library_at_start.st_mtime_ns for name in ["assets.rs", "hd.rs"]) qa["any_game_source_newer_than_library"] = any(path.stat().st_mtime_ns > library_at_start.st_mtime_ns for path in (project / "src").glob("*.rs")) qa_path.write_text(json.dumps(qa, indent=2) + "\n") diff --git a/tools/hd_frame_policy.py b/tools/hd_frame_policy.py index fe55701a..67378cdc 100644 --- a/tools/hd_frame_policy.py +++ b/tools/hd_frame_policy.py @@ -3,7 +3,7 @@ import struct from fractions import Fraction FRAME_KEYS = {"frame", "file", "logical_width", "logical_height", "origin", - "source_sha256", "crop", "atlas_grid", "preserve_reserved_coverage", "native_nearest_cues", "original_detail_cells"} + "source_sha256", "crop", "atlas_grid", "preserve_reserved_coverage", "native_nearest_cues", "original_detail_cells", "palette_constraint"} def validate_atlas_grid(frame): grid = frame.get("atlas_grid") @@ -90,6 +90,11 @@ def validate_frame_policy(frame, source_payload=None): if set(frame) - FRAME_KEYS: raise ValueError("unknown HD frame fields") validate_atlas_grid(frame) + constraint = frame.get("palette_constraint") + if constraint is not None and (type(constraint) is not str or constraint != "original-painted-ordinary-v1"): + raise ValueError("unknown palette constraint") + if constraint is not None and source_payload is not None: + original_palette_indices(frame, source_payload) if type(frame.get("preserve_reserved_coverage", False)) is not bool: raise ValueError("preserve_reserved_coverage must be a boolean") cues = frame.get("native_nearest_cues", []) @@ -141,3 +146,22 @@ def validate_frame_policy(frame, source_payload=None): # Runtime, audit and extended registrar approval supply actual payloads. if len(details) > 64 and len(details) * 4 > sum(0 <= p <= 255 for p in indices): raise ValueError("65..128 Original details may retain at most one quarter of painted Source") + + +def original_palette_indices(frame, source_payload): + """Derive the exact allowed set from authentic Source, never a supplied list. + + Metadata-only preparation cannot claim this validation. Rust and actual + audit/registration provide the checked payload and keep PNG alpha separate. + """ + constraint = frame.get("palette_constraint") + if constraint is None: + return None + if type(constraint) is not str or constraint != "original-painted-ordinary-v1": + raise ValueError("unknown palette constraint") + sw, sh, indices = _indices(source_payload) + if (type(frame.get("logical_width")) is not int or type(frame.get("logical_height")) is not int + or (sw, sh) != (frame["logical_width"], frame["logical_height"]) + or not indices or any(not 0 <= i < 240 for i in indices)): + raise ValueError("Original palette constraint requires opaque ordinary Source") + return sorted(set(indices)) diff --git a/tools/hd_palette_review.rs b/tools/hd_palette_review.rs new file mode 100644 index 00000000..8aff1d8a --- /dev/null +++ b/tools/hd_palette_review.rs @@ -0,0 +1,89 @@ +//! Diagnostic gallery linked against the already-built game library. +//! The Python wrapper prepares an isolated pack; global hd.json is never edited. +use raptor_replica::{assets::{Catalog, Sprite, TRANSPARENT}, hd::{HdAssets, Manifest}}; +use serde_json::{json, Value}; +use std::{env, fs, path::Path}; + +fn ppm(path: &Path, sprite: &Sprite, palette: &[[u8; 3]; 256]) { + let mut bytes = format!("P6\n{} {}\n255\n", sprite.width, sprite.height).into_bytes(); + for index in &sprite.pixels { + bytes.extend_from_slice(if *index == TRANSPARENT { &[20, 28, 38] } else { &palette[*index as usize] }); + } + fs::write(path, bytes).unwrap(); +} + +fn mask(sprite: &Sprite) -> Value { + let points: Vec<_> = sprite.pixels.iter().enumerate() + .filter(|(_, pixel)| **pixel != TRANSPARENT) + .map(|(index, _)| (index % sprite.width, index / sprite.width)).collect(); + if points.is_empty() { return json!({"visible_pixels": 0}); } + let bounds = [points.iter().map(|p| p.0).min().unwrap(), points.iter().map(|p| p.1).min().unwrap(), + points.iter().map(|p| p.0).max().unwrap() + 1, points.iter().map(|p| p.1).max().unwrap() + 1]; + let centroid = [points.iter().map(|p| p.0 as f64).sum::() / points.len() as f64, + points.iter().map(|p| p.1 as f64).sum::() / points.len() as f64]; + let animated_palette_counts: Vec<_> = (240..=255).map(|index| sprite.pixels.iter().filter(|pixel| **pixel == index).count()).collect(); + json!({"visible_pixels": points.len(), "bounds": bounds, "centroid": centroid, + "palette_indices_240_to_255_counts": animated_palette_counts}) +} + +fn main() { + let args: Vec<_> = env::args().collect(); + assert_eq!(args.len(), 4, "expected DATA PACK OUTPUT"); + let output = Path::new(&args[3]); fs::create_dir_all(output).unwrap(); + let catalog = Catalog::open(&args[1]).unwrap(); let base_palette = catalog.palette().unwrap(); + let manifest: Manifest = serde_json::from_slice(&fs::read(Path::new(&args[2]).join("hd.json")).unwrap()).unwrap(); + let mut hd = HdAssets::load(Path::new(&args[2]), &catalog); + assert!(hd.diagnostics.is_empty(), "{:?}", hd.diagnostics); + let mut frames = Vec::new(); let mut indexed = Vec::new(); + for sequence in manifest.sequences { for frame in sequence.frames { + let id = sequence.base_asset + frame.frame; + let palette_name = match id { 0x1002b => "POGPAL_DAT", 0x1002d => "CYGPAL_DAT", 0x1002f => "TAIPAL_DAT", _ => "PALETTE_DAT" }; + let palette = if palette_name == "PALETTE_DAT" { base_palette } else { + raptor_replica::assets::decode_palette(&catalog.named(palette_name).unwrap()).unwrap() + }; + let original = catalog.sprite(id).unwrap(); + ppm(&output.join(format!("{id:05x}-original.ppm")), &original, &palette); + let source_indices: Vec = original.pixels.iter().copied().filter(|&p|p>().into_iter().collect(); + let mut constraint_scales = Vec::new(); + let mut masks = Vec::new(); let mut logical = None; let mut indexed_scales = Vec::new(); + for scale in 1..=4 { + let sprite = if palette_name == "PALETTE_DAT" { + hd.sprite(id, scale, &palette) + } else { + #[cfg(static_card_api)] + { hd.sprite_static_palette(id, scale, &palette) } + #[cfg(not(static_card_api))] + { panic!("Static-card review requires the source-specific palette API in the selected library") } + }.unwrap().expect("candidate rejected"); + assert_eq!((sprite.width, sprite.height), (frame.logical_width as usize * scale, frame.logical_height as usize * scale)); + assert!(sprite.pixels.iter().any(|p| *p != TRANSPARENT)); + ppm(&output.join(format!("{id:05x}-hd-{scale}.ppm")), &sprite, &palette); + if frame.palette_constraint.is_some() { + constraint_scales.push(json!({"scale":scale, + "visible_pixels":sprite.pixels.iter().filter(|&&p|p>(), + "scales":constraint_scales}); + } + frames.push(report); + }} + let report = json!({"method": "actual Rust HdAssets::load and sprite at scales 1..4; Catalog palette; no artwork edits", + "validated_frames": hd.coverage(), "diagnostics": hd.diagnostics, "frames": frames, + "limitation": "Gallery validation does not prove live scene reachability, terrain seam correctness or artistic fidelity. Opaque sprites have trivial alpha-mask overlap; compare visible source geometry manually."}); + fs::write(output.join("runtime-qa.json"), serde_json::to_vec_pretty(&report).unwrap()).unwrap(); + fs::write(output.join("actual-indexed-source-and-scales.json"),serde_json::to_vec_pretty(&indexed).unwrap()).unwrap(); println!("validated {} candidate frames at all four scales", frames.len()); +} diff --git a/tools/hd_register_reviewed.py b/tools/hd_register_reviewed.py index 94418503..1b655d29 100644 --- a/tools/hd_register_reviewed.py +++ b/tools/hd_register_reviewed.py @@ -17,7 +17,7 @@ from pathlib import Path from hd_candidate_review import binding_digest, proposal_frames from hd_art_audit import checked_palette, validate_hd_frame from hd_art_source import decode -from hd_frame_policy import validate_frame_policy, retained_review_version_ok +from hd_frame_policy import validate_frame_policy, retained_review_version_ok, original_palette_indices CARRIED_TERRAIN_V2 = "standalone-terrain-carried-identity-v2" @@ -600,6 +600,72 @@ def validate_extended_retained_sources(project, rows): raise ValueError("extended Original details lack authentic catalog Source bytes") + +def validate_palette_constraint_approval(project, rows, review, qa): + """The new treatment cannot reuse an old raw-art or default-sampler approval. + + Keep all existing artistic/current-QA gates. Source domains are derived here + from checked catalog bytes; supplied indices or RGBs never define eligibility. + """ + constrained = {asset: frame for asset, frame in rows.items() if frame.get("palette_constraint") is not None} + if not constrained: + return + declaration = review.get("palette_constraint_review", {}) + if (not isinstance(declaration, dict) + or declaration.get("policy_version") != "original-painted-ordinary-v1" + or declaration.get("frame_bindings_sha256") != binding_digest(rows) + or declaration.get("unchanged_generated_raw") is not True + or declaration.get("independent_allscale_and_genuine_context_review") is not True): + raise ValueError("explicit palette treatment and exact independent all-scale/context approval required") + reports = {row["asset"]: row for row in qa["frames"]} + for asset, frame in constrained.items(): + authenticated = None + for data in (project / "data", project.parent / "data"): + index = data / "INDEX" + if not index.is_file(): + continue + matches = [p for line in index.read_text().splitlines() + if len(p := line.split()) == 3 and int(p[0], 16) == int(asset, 16)] + if len(matches) != 1: + continue + relative = Path(matches[0][2]) + if relative.is_absolute() or ".." in relative.parts: + raise ValueError("palette Source path escapes catalog") + path = (data / relative).resolve() + palette_path = (data / "misc/PALETTE_DAT").resolve() + if not path.is_relative_to(data.resolve()) or not palette_path.is_relative_to(data.resolve()): + raise ValueError("palette Source path escapes catalog") + payload = path.read_bytes() + if len(payload) != int(matches[0][1]) or hashlib.sha256(payload).hexdigest() != frame["source_sha256"]: + continue + validate_frame_policy(frame, payload) + indices = original_palette_indices(frame, payload) + palette = checked_palette(palette_path.read_bytes()) + authenticated = (indices, [{"index": i, "RGB": list(palette[i])} for i in indices]) + break + if authenticated is None: + raise ValueError("palette treatment lacks authentic opaque ordinary catalog Source") + indices, colors = authenticated + report = reports[asset] + evidence = report.get("palette_constraint", {}) + if (not isinstance(evidence, dict) or evidence.get("policy") != frame["palette_constraint"] + or evidence.get("applied") is not True or report.get("source_palette") != "PALETTE_DAT" + or not same_json(evidence.get("source_indices"), indices) + or not same_json(evidence.get("source_colors"), colors)): + raise ValueError("palette measurement is not the exact SourceClasses treatment/domain") + scales = evidence.get("scales", []) + if (not isinstance(scales, list) or any(not isinstance(x, dict) for x in scales) + or [x.get("scale") for x in scales] != [1, 2, 3, 4] + or any(type(x.get("scale")) is not int or type(x.get("visible_pixels")) is not int + or type(x.get("outside_original_palette")) is not int for x in scales)): + raise ValueError("palette measurement needs four exact typed scale records") + masks = {x["scale"]: x["mask"] for x in report["hd_masks"]} + if any(type(masks[x["scale"]].get("visible_pixels")) is not int + or x["outside_original_palette"] != 0 or x["visible_pixels"] <= 0 + or x["visible_pixels"] != masks[x["scale"]]["visible_pixels"] for x in scales): + raise ValueError("palette measurement violates its domain or visible sample binding") + + def approved_job(root, identity, project): directory = contained(root, Path("jobs") / identity) if directory.parent != (root / "jobs").resolve(): @@ -697,6 +763,7 @@ def approved_job(root, identity, project): elif retained_details and catalog_only: approved_catalog_only_retained_evidence(root, project, declaration, rows, qa) validate_extended_retained_sources(project, rows) + validate_palette_constraint_approval(project, rows, review, qa) return candidate, review, rows