fix(mdec): truncate 15bpp channel to uint8 before RGB555 (the rainbow bug)

The faithful dequant/IDCT/YUV transcription was correct, but the 15bpp pack did
(c+4)>>3 on `r ^ 0x80` as a SIGNED int. For negative Mask9ClampS8 results that
yields a negative int (r=-1 → -129) → garbage 5-bit channel → B saturated, R/G
doubled = the rainbow fringing on real FMV. Beetle's RGB_to_RGB555 takes uint8
params (truncates to 0..255); the 24bpp path already cast to uint8_t. Fix:
rgb_to_555_chan(uint8_t) so the caller's int truncates on the call.

Caught only by comparing our VRAM to the REAL Beetle process (new vram_peek on
the Beetle side, held-FMV diff) — both standalone pins shared the bug in their
self-transcribed reference and falsely passed. After the fix the per-channel VRAM
delta vs Beetle collapsed from mean (12,16,27) to (0.7,0.9,1.3) and the FMV renders
natural colours. Full-path pin (mdec_e2e, corrected uint8 oracle) is byte-exact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-27 21:06:04 -07:00
parent fff683795c
commit 08bb5a2728
3 changed files with 163 additions and 36 deletions
+7 -5
View File
@@ -291,11 +291,13 @@ static uint8_t to_output_u8(int value) {
return (uint8_t)(value + 128);
}
/* 8-bit unsigned channel → 5-bit, Beetle RGB_to_RGB555 rounding (mdec.cpp:306). */
static int rgb_to_555_chan(int c) {
c = (c + 4) >> 3;
if (c > 0x1F) c = 0x1F;
return c;
/* 8-bit unsigned channel → 5-bit, Beetle RGB_to_RGB555 rounding (mdec.cpp:306).
* Beetle's RGB_to_RGB555 takes uint8 params, so the ^0x80 result is truncated to
* 0..255 BEFORE the round/shift — `c` here is already that uint8. */
static int rgb_to_555_chan(uint8_t c) {
int v = (c + 4) >> 3;
if (v > 0x1F) v = 0x1F;
return v;
}
static void append_rgb_pixel(int y, int cr, int cb) {
+25 -31
View File
@@ -1,36 +1,30 @@
# MDEC numeric pin — finding (2026-06-27)
# MDEC pin — finding + RESOLUTION (2026-06-27)
`mdec_pin.c` runs OUR mdec.c numeric pipeline (dequant/IDCT/YUV, verbatim from the
WIP transcription) vs BEETLE's verbatim pipeline (mdec.cpp) on identical synthetic
input, diffing each stage.
## The bug (FOUND + FIXED)
The faithful dequant/IDCT/YUV transcription was correct, but the **15bpp pack lost the
uint8 truncation** Beetle's `RGB_to_RGB555(uint8 r,g,b)` performs implicitly:
## Result: numeric core is BYTE-EXACT vs Beetle
[scale matrix] diffs: 0
[dequant Coeff] diffs: 0
[idct block] diffs: 0
[yuv->rgb] diffs: 0
So the D1/D2/D4/D5 transcription (dequant <<4 + bias, two-pass IDCT (sum+0x4000)>>15
+ Mask9ClampS8 + transposed >>3 matrix + Beetle ZigZag, YUV /256 + green mask) is a
faithful match to the oracle IN ISOLATION.
int ru = r ^ 0x80; // r = Mask9ClampS8 ∈ [-128,127], a SIGNED int
... rgb_to_555_chan(ru) ... // (c+4)>>3 on a possibly-NEGATIVE int → garbage
## BUT the integrated change REGRESSES the live FMV
Live Tomba 2 with the WIP change: the Whoopee Camp logo shows RAINBOW colour fringing.
Baseline (old numeric core) renders it CLEAN PINK (matching Beetle). So the numeric-only
change makes it worse on-screen even though the math is byte-exact.
For negative `r`, `r ^ 0x80` is a negative int (e.g. r=-1 → -129), and the old
`rgb_to_555_chan(int)` did `(c+4)>>3` on it without truncating to 0..255. Beetle passes
the value through `uint8` params, truncating first. Result on real FMV: B saturated to
31, R/G ~doubled → the rainbow fringing. The 24bpp path was unaffected (it already casts
to `uint8_t`). FIX: `rgb_to_555_chan(uint8_t c)` (caller's `int` truncates on the call),
matching Beetle. mdec.c:rgb_to_555_chan.
## Root cause: layout pairing, not the math
Adopting Beetle's ZigZag + transposed IDCT changes the block's internal spatial layout.
The old numeric core and the old block-assembly (`append_color_macroblock` + the
multi-block Cr/Cb/Y flow) were a MATCHED PAIR producing correct output. Swapping only the
numeric core to Beetle's convention without transcribing the assembly breaks the pairing
→ chroma misaligns vs luma → rainbow fringing.
## Why both pins initially MISSED it (important)
`mdec_pin.c` and `mdec_e2e.c`'s reference both computed the 555 channel on the same
un-truncated `int` — i.e. the reference REPLICATED mdec.c's bug, so they agreed (0 diffs)
while BOTH diverged from real Beetle. A standalone pin whose oracle is your own
transcription cannot catch an error shared by both. The truth came from the
**real Beetle process**: a `vram_peek` added to beetle_debug_server.c, diffing our VRAM
vs Beetle's VRAM at the held FMV (the gold-standard oracle). That collapsed the channel
deltas from mean (dR 12, dG 16, dB 27) to (0.7, 0.9, 1.3) and removed the rainbow.
## Proper completion (do NOT merge until this passes)
1. Transcribe Beetle's EncodeImage assembly (mdec.cpp:324-419) too — block→output pixel
mapping — so the assembly matches the new (Beetle) block layout.
2. Build a FULL-PATH pin: drive the REAL mdec.c end-to-end via its DMA API (mdec_write /
mdec_dma_write_word / mdec_dma_read_word) on a synthetic full 6-block colour macroblock,
diff the packed output against a Beetle-faithful full pipeline (decode + EncodeImage).
The isolated pin here misses the integration; the full-path pin would have caught it.
3. Only when the full-path packed output is byte-exact AND the live FMV renders clean →
merge.
## Validation now
- `mdec_e2e.c` full-path pin (with the corrected uint8 oracle): byte-exact.
- Live VRAM vs real Beetle: rainbow gone, FMV renders natural colours; residual <1.3
mean (sub-LSB / Beetle's upscaled-VRAM representation + free-running frame mismatch).
LESSON: validate against the REAL oracle process, not a self-transcribed reference.
+131
View File
@@ -0,0 +1,131 @@
/* mdec_e2e.c — FULL-PATH MDEC pin. Drives the REAL runtime/src/mdec.c end-to-end
* through its public DMA/MMIO API (SET_QUANT, SET_SCALE, DECODE, read output) on a
* synthetic 6-block colour macroblock, and diffs the packed 16bpp output against a
* Beetle-faithful full pipeline (dequant + IDCT + EncodeImage assembly) at every
* spatial pixel. Localizes integration bugs the isolated mdec_pin.c misses.
*
* Build (from runtime root): gcc -O2 -I runtime/include -o mdec_e2e tools/mdec_pin/mdec_e2e.c runtime/src/mdec.c
*/
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include "mdec.h"
uint64_t s_frame_count = 0; /* mdec.c extern */
/* ---------------- synthetic input ---------------- */
static uint8_t QY[64], QC[64];
static uint16_t SCALEW[64];
static uint16_t BLK[6][24]; static int BLKN[6];
static void make_input(void){
for(int i=0;i<64;i++) SCALEW[i]=(uint16_t)(0x1000 + (i*53%777) - 388);
for(int i=0;i<64;i++){ QY[i]=(uint8_t)(2+(i%17)); QC[i]=(uint8_t)(3+(i%13)); }
/* 6 distinct blocks, AGGRESSIVE: many ACs, large/saturating levels, a qscale=0
* block, runs that reach high zig indices, EOB at varied points. */
for(int b=0;b<6;b++){
int n=0;
int qs = (b==3) ? 0 : (8+b); /* block 3: qscale=0 path */
BLK[b][n++]=(uint16_t)((qs<<10)|((400-37*b)&0x3FF)); /* DC, larger level */
BLK[b][n++]=(uint16_t)((0u<<10)|((uint16_t)(-300+11*b)&0x3FF)); /* big neg */
BLK[b][n++]=(uint16_t)((1u<<10)|((250-9*b)&0x3FF));
BLK[b][n++]=(uint16_t)((0u<<10)|((uint16_t)(-120)&0x3FF));
BLK[b][n++]=(uint16_t)((4u<<10)|((333+b)&0x3FF)); /* level >0x100 (10-bit) */
BLK[b][n++]=(uint16_t)((2u<<10)|((uint16_t)(-77)&0x3FF));
BLK[b][n++]=(uint16_t)((7u<<10)|((90)&0x3FF));
BLK[b][n++]=(uint16_t)((11u<<10)|((uint16_t)(-200)&0x3FF)); /* high zig index */
BLK[b][n++]=(uint16_t)((1u<<10)|((45)&0x3FF));
BLK[b][n++]=0xFE00u; /* end of block */
BLKN[b]=n;
}
}
/* ---------------- drive the REAL mdec.c ---------------- */
static uint8_t OUT[1024]; static int OUTN;
static void run_real(void){
mdec_init();
/* control: enable nothing special; we feed via port 0 directly */
/* SET_QUANT (both tables): cmd (2<<29)|1, 64 halfwords = 128 bytes = 32 words */
mdec_write(0, (2u<<29)|1u);
uint8_t qbytes[128]; memcpy(qbytes,QY,64); memcpy(qbytes+64,QC,64);
for(int i=0;i<128;i+=4){ uint32_t w=qbytes[i]|(qbytes[i+1]<<8)|(qbytes[i+2]<<16)|(qbytes[i+3]<<24); mdec_write(0,w); }
/* SET_SCALE: cmd (3<<29), 64 halfwords = 32 words */
mdec_write(0, (3u<<29));
for(int i=0;i<64;i+=2){ uint32_t w=SCALEW[i]|((uint32_t)SCALEW[i+1]<<16); mdec_write(0,w); }
/* DECODE 16bpp (depth=3): assemble all 6 blocks' halfwords, pad to even, frame as words */
uint16_t hw[256]; int hn=0;
for(int b=0;b<6;b++) for(int i=0;i<BLKN[b];i++) hw[hn++]=BLK[b][i];
if(hn&1) hw[hn++]=0xFE00u; /* pad to even halfwords */
int words=hn/2;
mdec_write(0, (1u<<29)|(3u<<27)|(uint32_t)(words&0xFFFF));
for(int i=0;i<hn;i+=2){ uint32_t w=hw[i]|((uint32_t)hw[i+1]<<16); mdec_write(0,w); }
/* read output */
MDECDebugState st; mdec_debug_get_state(&st);
OUTN=(int)st.output_size; if(OUTN>1024)OUTN=1024;
for(int i=0;i<OUTN;i+=4){ uint32_t v=mdec_dma_read_word(); OUT[i]=v&0xFF; if(i+1<OUTN)OUT[i+1]=(v>>8)&0xFF; if(i+2<OUTN)OUT[i+2]=(v>>16)&0xFF; if(i+3<OUTN)OUT[i+3]=(v>>24)&0xFF; }
}
/* ---------------- BEETLE-faithful reference (verbatim from mdec.cpp) ---------------- */
static const uint8_t bz[64]={
0x00,0x08,0x01,0x02,0x09,0x10,0x18,0x11,0x0a,0x03,0x04,0x0b,0x12,0x19,0x20,0x28,
0x21,0x1a,0x13,0x0c,0x05,0x06,0x0d,0x14,0x1b,0x22,0x29,0x30,0x38,0x31,0x2a,0x23,
0x1c,0x15,0x0e,0x07,0x0f,0x16,0x1d,0x24,0x2b,0x32,0x39,0x3a,0x33,0x2c,0x25,0x1e,
0x17,0x1f,0x26,0x2d,0x34,0x3b,0x3c,0x35,0x2e,0x27,0x2f,0x36,0x3d,0x3e,0x37,0x3f};
static int16_t bmat[64];
static int bsx(int bits,int v){int s=32-bits;return (int)(((int32_t)((uint32_t)v<<s))>>s);}
static int b10(int v){return (int16_t)bsx(10,v);}
static int bm9(int v){v=bsx(9,v);if(v<-128)v=-128;if(v>127)v=127;return v;}
static int bmin(int a,int b){return a<b?a:b;} static int bmax(int a,int b){return a>b?a:b;}
static void bset(void){for(int i=0;i<64;i++)bmat[((i&7)<<3)|((i>>3)&7)]=(int16_t)((int16_t)SCALEW[i]>>3);}
static void bdq(int16_t*C,const uint16_t*w,int n,const uint8_t*QM){
memset(C,0,64*sizeof(int16_t)); int p=0,ci=0,qs=0;
while(p<n){ uint16_t V=w[p++];
if(ci==0){ if(V==0xFE00)continue; qs=V>>10; int q=QM[0],c=b10(V&0x3FF),t;
if(q)t=(int32_t)((uint32_t)(c*q)<<4)+(c?((c<0)?8:-8):0); else t=(uint32_t)(c*2)<<4;
C[bz[0]]=bmin(0x3FFF,bmax(-0x4000,t)); ci++;
} else { if(V==0xFE00){ while(ci<64)C[bz[ci++]]=0; }
else { uint32_t rl=V>>10; for(uint32_t i=0;i<rl&&ci<64;i++)C[bz[ci++]]=0;
if(ci<64){ int q=qs*QM[ci],c=b10(V&0x3FF),t;
if(q)t=(int32_t)((uint32_t)((c*q)>>3)<<4)+(c?((c<0)?8:-8):0); else t=(uint32_t)(c*2)<<4;
C[bz[ci]]=bmin(0x3FFF,bmax(-0x4000,t)); ci++; } } } }
}
static void bidct(int16_t*in,int16_t*out){ int16_t t[64];
for(int c=0;c<8;c++)for(int x=0;x<8;x++){int s=0;for(int u=0;u<8;u++)s+=in[c*8+u]*bmat[x*8+u];t[x*8+c]=(int16_t)((s+0x4000)>>15);}
for(int c=0;c<8;c++)for(int x=0;x<8;x++){int s=0;for(int u=0;u<8;u++)s+=t[c*8+u]*bmat[x*8+u];out[c*8+x]=(int16_t)bm9((s+0x4000)>>15);} }
static void byuv(int8_t y,int8_t cb,int8_t cr,int*r,int*g,int*b){
*r=bm9(y+(((359*cr)+0x80)>>8)); *g=bm9(y+((((-88*cb)&~0x1F)+((-183*cr)&~0x07)+0x80)>>8)); *b=bm9(y+(((454*cb)+0x80)>>8));
*r^=0x80;*g^=0x80;*b^=0x80; }
/* Beetle RGB_to_RGB555 takes uint8 params -> the ^0x80 result truncates to 0..255
* before the round/shift. (The earlier int-param version replicated mdec.c's bug,
* so the pin falsely passed; uint8 makes it a true oracle.) */
static int b555(uint8_t c){int v=(c+4)>>3;if(v>0x1F)v=0x1F;return v;}
static uint16_t ref_px[256];
static void run_ref(void){
bset();
int16_t Cr[64],Cb[64],Y[4][64], cr8[64],cb8[64],y8[4][64];
bdq(Cr,BLK[0],BLKN[0],QC); bidct(Cr,cr8);
bdq(Cb,BLK[1],BLKN[1],QC); bidct(Cb,cb8);
for(int k=0;k<4;k++){ bdq(Y[k],BLK[2+k],BLKN[2+k],QY); bidct(Y[k],y8[k]); }
for(int py=0;py<16;py++)for(int px=0;px<16;px++){
int ybn=(px>=8?1:0)+(py>=8?2:0); int x=px&7,y=py&7;
int chroma=(py>>1)*8+(px>>1);
int r,g,b; byuv((int8_t)y8[ybn][y*8+x],(int8_t)cb8[chroma],(int8_t)cr8[chroma],&r,&g,&b);
ref_px[py*16+px]=(uint16_t)(b555(r)|(b555(g)<<5)|(b555(b)<<10)); /* signed=0,bit15=0 -> xor 0 */
}
}
int main(void){
make_input(); run_real(); run_ref();
printf("real output bytes: %d (expect 512 for one 16x16 16bpp macroblock)\n", OUTN);
int diffs=0, shown=0;
for(int py=0;py<16;py++)for(int px=0;px<16;px++){
int n=py*16+px; if(2*n+1>=OUTN) continue;
uint16_t got = OUT[2*n] | (OUT[2*n+1]<<8);
uint16_t exp = ref_px[n];
if(got!=exp){ diffs++; if(shown<12){ printf(" px(%2d,%2d) real=0x%04X beetle=0x%04X\n",px,py,got,exp); shown++; } }
}
printf("[full-path 16bpp] pixel diffs: %d / 256\n", diffs);
printf("\n%s\n", diffs?"*** INTEGRATION DIVERGENCE ***":"FULL PATH BYTE-EXACT vs BEETLE");
return 0;
}