Skip to content

Commit 01bf67c

Browse files
tannevaledclaude
andcommitted
R-doom1i: gpu_adapter.Flip integer up-scale DOOM canvas to fill framebuffer
Previously: 320×200 DOOM frame was blitted 1:1 in the framebuffer (typically 1280×800 host scanout) with a black border — visually tiny in the QEMU window, operator reported "l'image ne fit pas la taille de la fenêtre". Now: compute integer scale factor min(g.width/fw, g.height/fh) and replicate each source pixel into a scale×scale destination block. On a 1280×800 scanout: 320×200 DOOM → 4× scale → exactly fills 1280×800. On 640×400: 2× scale → exactly fills. Aspect ratio preserved; small letterbox only when ratios don't divide evenly. Edge case preserved: when source > framebuffer, fall back to clip-to-FB 1:1 (the original LargerThanFB_Clips test still passes). Test updated to verify scale=2 pixel replication into 2×2 dest blocks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 25f35fe commit 01bf67c

2 files changed

Lines changed: 75 additions & 31 deletions

File tree

backend/tamago/gpu_adapter.go

Lines changed: 63 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -88,36 +88,73 @@ func (g *GPUAdapter) Flip(frame *image.RGBA) error {
8888
if fw <= 0 || fh <= 0 || g.width <= 0 || g.height <= 0 {
8989
return nil
9090
}
91-
// Recompute centering if dimensions changed; first frame is the
92-
// canonical sizing.
93-
if fw <= g.width {
94-
g.xOff = (g.width - fw) / 2
95-
} else {
96-
g.xOff = 0
97-
fw = g.width
98-
}
99-
if fh <= g.height {
100-
g.yOff = (g.height - fh) / 2
91+
// R-doom1i (2026-06-14): integer nearest-neighbor up-scale the DOOM
92+
// canvas (typically 320×200) to fill as much of the framebuffer
93+
// (typically a host scanout size like 1280×800) as possible while
94+
// preserving aspect ratio. Previously we centered DOOM 1:1 in the
95+
// framebuffer with a large black border; the operator's reaction
96+
// "l'image ne fit pas la taille de la fenêtre QEMU" surfaced the
97+
// gap. With a 4× scale on a 1280×800 scanout, DOOM 320×200 fills
98+
// exactly the entire framebuffer (320×4=1280, 200×4=800).
99+
//
100+
// When the source frame is LARGER than the framebuffer (an edge
101+
// case the original test corpus exercises), we fall back to the
102+
// historical clip-and-center 1:1 behaviour: scale = 1, fw/fh
103+
// clamped to g.width/g.height. Otherwise scale is the maximum
104+
// integer factor that still fits both dimensions.
105+
scale := 1
106+
if fw <= g.width && fh <= g.height {
107+
scale = g.width / fw
108+
if sh := g.height / fh; sh < scale {
109+
scale = sh
110+
}
111+
if scale < 1 {
112+
scale = 1
113+
}
101114
} else {
102-
g.yOff = 0
103-
fh = g.height
115+
// Source larger than framebuffer — clip to FB dimensions, no scale.
116+
if fw > g.width {
117+
fw = g.width
118+
}
119+
if fh > g.height {
120+
fh = g.height
121+
}
104122
}
123+
dw := fw * scale
124+
dh := fh * scale
125+
g.xOff = (g.width - dw) / 2
126+
g.yOff = (g.height - dh) / 2
105127
srcStride := frame.Stride
106128
dstStride := g.width * 4
107-
for y := 0; y < fh; y++ {
108-
srcRow := frame.Pix[y*srcStride : y*srcStride+fw*4]
109-
dstRow := g.pix[(y+g.yOff)*dstStride+g.xOff*4 : (y+g.yOff)*dstStride+(g.xOff+fw)*4]
110-
for x := 0; x < fw; x++ {
111-
r := srcRow[x*4+0]
112-
gn := srcRow[x*4+1]
113-
bl := srcRow[x*4+2]
114-
a := srcRow[x*4+3]
115-
// virtio-gpu's VIRTIO_GPU_FORMAT_B8G8R8A8_UNORM lays bytes
116-
// out as { B, G, R, A } in little-endian memory order.
117-
dstRow[x*4+0] = bl
118-
dstRow[x*4+1] = gn
119-
dstRow[x*4+2] = r
120-
dstRow[x*4+3] = a
129+
for sy := 0; sy < fh; sy++ {
130+
srcRow := frame.Pix[sy*srcStride : sy*srcStride+fw*4]
131+
// Build one scaled destination row in a buffer, then copy `scale`
132+
// times into the framebuffer for the vertical replication.
133+
baseY := (g.yOff + sy*scale) * dstStride
134+
// First the inner pixel loop, writing scale-replicated horizontally.
135+
for sx := 0; sx < fw; sx++ {
136+
r := srcRow[sx*4+0]
137+
gn := srcRow[sx*4+1]
138+
bl := srcRow[sx*4+2]
139+
a := srcRow[sx*4+3]
140+
// virtio-gpu's VIRTIO_GPU_FORMAT_B8G8R8A8_UNORM = { B, G, R, A }.
141+
startCol := g.xOff + sx*scale
142+
for dx := 0; dx < scale; dx++ {
143+
off := baseY + (startCol+dx)*4
144+
g.pix[off+0] = bl
145+
g.pix[off+1] = gn
146+
g.pix[off+2] = r
147+
g.pix[off+3] = a
148+
}
149+
}
150+
// Vertical replication: copy the just-written row `scale-1` more
151+
// times beneath it. The first row was written by the inner loop.
152+
rowStart := baseY + g.xOff*4
153+
rowEnd := rowStart + dw*4
154+
src := g.pix[rowStart:rowEnd]
155+
for dy := 1; dy < scale; dy++ {
156+
dstStart := (g.yOff+sy*scale+dy)*dstStride + g.xOff*4
157+
copy(g.pix[dstStart:dstStart+dw*4], src)
121158
}
122159
}
123160
return g.fb.Flush()

backend/tamago/gpu_adapter_test.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,20 @@ func TestNewGPUAdapter_Flip_Centers(t *testing.T) {
3737
if fb.flushed != 1 {
3838
t.Fatalf("Flush count: got %d want 1", fb.flushed)
3939
}
40-
// Centered: xOff = (640-320)/2 = 160, yOff = (400-200)/2 = 100.
40+
// R-doom1i: scale = min(640/320, 400/200) = 2 → DOOM 320×200 scales
41+
// to 640×400 = fills the entire framebuffer (xOff=yOff=0, no border).
42+
// The single top-left source pixel is replicated into the 2×2 dest
43+
// pixel block (0,0)..(1,1).
4144
dstStride := W * 4
42-
off := (100)*dstStride + 160*4
43-
got := [4]byte{pix[off+0], pix[off+1], pix[off+2], pix[off+3]}
4445
want := [4]byte{0x33, 0x22, 0x11, 0x44} // BGRA
45-
if got != want {
46-
t.Fatalf("BGRA pixel: got %v want %v", got, want)
46+
for dy := 0; dy < 2; dy++ {
47+
for dx := 0; dx < 2; dx++ {
48+
off := dy*dstStride + dx*4
49+
got := [4]byte{pix[off+0], pix[off+1], pix[off+2], pix[off+3]}
50+
if got != want {
51+
t.Fatalf("BGRA pixel at (%d,%d): got %v want %v (scale=2 replication)", dx, dy, got, want)
52+
}
53+
}
4754
}
4855
}
4956

0 commit comments

Comments
 (0)