Skip to content

fix: correct green YUV shadows with neutral chroma fallback - #128

Merged
AloysHF merged 1 commit into
masterfrom
fix/yuv-neutral-chroma
Sep 27, 2026
Merged

AloysHF merged 1 commit into
masterfrom
fix/yuv-neutral-chroma

Conversation

@AloysHF

@AloysHF AloysHF commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Problem and reproduction

Users reported a hazy green tint in game output. Starting Bloody Blade from EPOP/EBBLADE.smf, skipping the video, and confirming the title screen and menus reproduces incorrect green pixels in character shadows and some image edges in NA32SSL/ENGLISH/BBLADE/BBPLAY10.SSL.

The reproduced defect affects pixels with unresolved zero chroma. A uniform green tint across the entire screen was not reproduced, so this PR does not establish that every reported tint has the same cause.

The original decoder was run first and a failing regression was recorded. The real image's compressed blocks were then unpacked independently to inspect the original Y/U/V values, followed by a fix and before/after comparisons.

SHA256 of the tested BBPLAY10.SSL:
d8cb739d81559cf4031e9e44386b9cfe17aa69da82f3fb5847c624e447844745.

Root cause

Image #636 is a 46×74 character sprite. Independently unpacking its compressed data reveals alternating black/transparent shadow blocks encoded as Y=[0,16,16,0], U=V=0.

The decoder already borrows neighboring nonzero chroma samples vertically and then horizontally. However, if a component remains zero after both steps, that zero is passed directly to the RGB conversion. The existing unwrap_or(128) handles an absent buffer entry, but not an entry whose value is zero.

For an opaque shadow pixel with Y=16,U=V=0, the conversion uses C=Y-16=0, D=U-128=-128, E=V-128=-128 and produces RGB (0,154,0), or #009A00. Black shadow stippling therefore becomes green.

Fix

Preserve the existing vertical-then-horizontal chroma recovery. After both steps, substitute neutral chroma 128 independently for each U or V component that is still zero, then apply the existing RGB conversion.

Applying the fallback after recovery preserves usable neighboring colors. Applying it earlier would replace missing samples with nonzero values and prevent the existing recovery from borrowing those colors. When only one component is missing, only that component changes; nonzero components retain their original values. Y=0 remains transparent, while Y=16,U=V=0 becomes opaque black.

This changes the game-image YUV decoder shared by the standalone frontend and libretro core. ARGB images and MPEG video decoding are unchanged. The fix operates on chroma values rather than removing pixels based on their final RGB color, so legitimate green artwork remains visible.

Results

Check Before After
Independently identified shadow blocks in image #636 3 black pixels and 110 incorrect green pixels 113 black pixels and no incorrect green pixels
Full pixel comparison of image #636 127 incorrect green shadow pixels and 2 other affected pixels with unresolved zero chroma 127 pixels become black and 2 become gray; 129 RGB pixels change in total
Alpha in image #636 Alternating opaque/transparent shadow stippling Identical alpha values; transparent stippling preserved
Legitimate green with nonzero chroma and recoverable neighboring colors Original colors Preserved, with explicit unit-test expectations

The 113 pixels in the identified shadow blocks are a subset of the full image, which explains the different counts. Full-image results come from before/after PNG comparisons. The asset regression independently unpacks the shadow blocks and asserts their decoded colors and transparency.

Local captures cover the title screen, menus, first level, and character sprite. The standalone title captures at frame 200 differ by only one pixel. The first-level captures at frame 600 differ in 42 RGB pixels, with no alpha changes. Captures are stored locally under tmp/color-before and tmp/color-after and are not included in the PR.

Regression coverage and validation

Four new unit tests require no external assets and cover literal/repeat compression commands, black/transparent shadows, independently missing U/V components, legitimate green, recovery from all four neighboring directions, and odd dimensions.

The new ignored asset test, bloody_blade_shadow_and_gameplay, copies the launcher and SSL scenes into a temporary directory to isolate user saves. It runs 1,200 frames from EPOP/EBBLADE.smf, enters the first level, dismisses dialogue, and moves right. It then independently unpacks image #636 and verifies shadow colors and transparency. Game assets are not distributed with the repository.

Completed local checks:

  • cargo fmt --all -- --check and git diff --check: passed.
  • cargo test --workspace: 266 default tests passed; tests requiring game assets remain ignored by default.
  • cargo clippy --workspace --all-targets --all-features -- -D warnings: passed.
  • cargo test -p native32emu-core --test yuv_color -- --ignored --nocapture: passed with real Bloody Blade assets. Before the fix, the regression identified 110 incorrect green shadow pixels and failed.
  • cargo test -p native32emu-core --test smoke smoke_all_games -- --ignored --nocapture: 185 local SMF/SSL files ran for 150 frames each without crashes. Two ESTORM.smf launchers produced blank-frame warnings. This checks runtime stability, not color correctness in every game.
  • cargo build -p native32emu --release: passed; the updated standalone executable launched Bloody Blade and captured a screenshot successfully.
  • cargo build -p native32emu-libretro --release: passed. The Windows x64 DLL was installed into the local RetroArch core directory after backing up the previous DLL, and its SHA256 matched the build artifact. Manual visual verification in RetroArch is still awaiting results and is not counted as completed validation.

To run the asset regression in PowerShell:

# Assets default to tmp/native32_game inside the repository.
# Optionally set NATIVE32_GAME_DIR to the asset root containing EPOP and NA32SSL.
$env:NATIVE32_COLOR_CAPTURE_DIR = Join-Path (Get-Location) 'tmp/color-after'
cargo test -p native32emu-core --test yuv_color -- --ignored --nocapture

Interpretation and limitations

The original compressed blocks, the failing regression on the old decoder, and before/after pixel comparisons support this correction. Treating unresolved zero chroma as missing color remains an interpretation supported by these assets, rather than a rule confirmed by original hardware or an authoritative format specification. If another game uses zero U/V as valid extreme chroma, its output would be affected by this fallback. Neutral fallback preserves luminance but cannot reconstruct missing original color information.

This PR addresses the confirmed shadow/edge defect; it does not establish the cause of every report of a uniform green tint. Only the decoder fix and regression tests are included. The reproduction, explanation, results, and limitations are documented here without adding docs/YUV-Color-Fix.md.

@AloysHF AloysHF changed the title fix: restore neutral chroma for YUV shadows fix: correct green YUV shadows with neutral chroma fallback Sep 27, 2026
@AloysHF
AloysHF merged commit 880b1ef into master Sep 27, 2026
4 checks passed
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant