Get a real Dragon's Lair frame onto the emulated X68000

First pixels on an actual X68000 screen. Everything up to now was Python-side
or a headless -video none run, which cannot snapshot at all.

The blocker was not the video controller. The IPL leaves CRTC R20 = 0x0B16,
and bit 11 is "G-VRAM set to buffer", which makes MAME's draw_gfx() return
early. GVRAM writes still land and read back correctly while the layer is
invisible, so six attempts at $E82400/$E82500/$E82600 all rendered black with
every register holding the value I intended.

Two more facts, both confirmed against MAME 0.277 source rather than assumed:

- $E8E001 monitor contrast is left at 14 by the IPL, scaling all output to
  93.3%. The player must set it to 15. Contrast 0 blanks the screen, which is
  a free fade-to-black for scene transitions.
- The palette word is GGGGGRRRRRBBBBBI with a shared LSB, expanded as
  pal6bit((field<<1)|I). With contrast at 15 the render is pixel-exact, not
  merely close, which also confirms the 1024-byte GVRAM line stride.

That exactness gives a new quality ceiling: the 15-bit+I palette alone costs
38.88 dB against the 24-bit palettised source, the same order as the scsi
profile's own codec error. scsi is close to display-transparent on hardware,
which bounds how much further it is worth raising.

Unblocks next step 2, the 68000 decoder skeleton, which now has a known-good
reference image to diff against.

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
prosolis
2026-08-23 13:12:27 -07:00
parent 22c67f1cb8
commit b322e84cd4
7 changed files with 198 additions and 3 deletions
+1
View File
@@ -12,3 +12,4 @@ __pycache__/
*.pyc *.pyc
*.dlx *.dlx
a.out a.out
tmp/
+80
View File
@@ -590,3 +590,83 @@ plausibly exceed the pipe where a 1.7s clip does not. Rate control gives a
original reason for choosing VQ over a lossless delta in the first place. original reason for choosing VQ over a lossless delta in the first place.
Still worth wiring in. No longer a blocker for shipping `scsi` at `lam=10`. Still worth wiring in. No longer a blocker for shipping `scsi` at `lam=10`.
## 22. The display path, measured — first real frame on the X68000
Everything before this section was Python-side or a headless `-video none` run.
This is the first time pixels reached an emulated X68000 screen, and it produced
four hardware facts and one blocker that no amount of reasoning would have found.
Reproduce:
```
python3 tools/bench/prep_frame.py <framedir> tmp/frame.bin 0
cd tmp && SDL_VIDEODRIVER=dummy mame x68000 -bios ipl10 -video soft -window \
-sound none -nothrottle -plugins -autoboot_script ../tools/bench/show_frame.lua \
-snapshot_directory ./snap -snapview native -seconds_to_run 6
```
### 22.1 The blocker: CRTC R20 bit 11 hides the graphics layer
The IPL leaves **CRTC R20 (`$E80028`) = `0x0B16`**. Bit 11 is *"G-VRAM set to
buffer"*, and MAME's `x68k_v.cpp` bails out of `draw_gfx()` on it outright:
```c
if (m_crtc->gfx_layer_buffer()) // if graphic layers are set to buffer, they aren't visible
return false;
// x68k_crtc.h: bool gfx_layer_buffer() const { return BIT(m_reg[20], 11); }
```
While that bit is set, GVRAM writes still land and read back correctly — which
is exactly what makes it so misleading. Six separate attempts at the video
controller (`$E82400/$E82500/$E82600`) rendered black with every register
reading back the intended value. **The video controller was never the problem.**
`R20` bits 9-8 select the colour setup, and this determines how `$C00000` is
decoded: `0x0300` = 65536c (16 bits/word), `0x0100` = 256c (low byte),
`0x0000` = 16c (4 bits). Set `R20 = 0x0116` for our mode.
### 22.2 Monitor contrast: the IPL leaves it at 14, not 15
`$E8E001` bits 3-0 are monitor contrast; MAME does
`m_screen->set_brightness(contrast * 0x11)`. The IPL leaves it at **14**, which
scales all output to 14/15 = 93.3%. Every rendered colour came out ~7% dark
until this was set to 15. **The player must write `$E8E001 = 15` at startup.**
Contrast `0` blanks the screen entirely (`x68k_v.cpp:661`) — that is the cheap
fade-to-black for scene transitions, no palette animation required.
### 22.3 Palette format CONFIRMED (was previously an assumption)
`PALETTE(config, m_gfxpalette).set_format(2, &x68k_state::GGGGGRRRRRBBBBBI, 256)`
```
bit 15..11 10..6 5..1 0
GGGGG RRRRR BBBBB I <- I is a shared LSB for all three channels
```
Expansion is `pal6bit((field << 1) | I)`, i.e. `(v << 2) | (v >> 4)`.
With contrast at 15, **all 256 entries render exactly as this predicts** — the
frame is pixel-identical, not merely close. GVRAM line stride is confirmed as
512 words = 1024 bytes, matching `HARDWARE.md`.
### 22.4 A new quality ceiling: the 15-bit palette costs 38.88 dB
Section 3 called the 256-colour palettised frame "the real quality ceiling".
That was measured in 24-bit RGB. The hardware palette only stores 5 bits per
channel plus a shared LSB, so there is a **second** quantisation below it:
| stage | PSNR |
|---|---|
| 24-bit palettised source -> X68000 15-bit+I display | **38.88 dB** |
| `scsi` profile codec error (00020, FINDINGS 15) | 39.4 dB |
The codec's error at `scsi` is **the same order as the display's own error**.
On real hardware `scsi` is therefore close to display-transparent, and pushing
`lam` below 10 buys quality the monitor cannot show. This bounds how much the
`scsi` profile is worth raising — it does not change the profiles themselves.
Caveat: measured on one frame (00020 f0001). It is a property of the palette,
not the content, so it should generalise, but it has not been checked across
scenes.
### 22.5 Why the first frame appears twice
GVRAM is a 512-pixel-wide page while the IPL's CRTC is still in its 768-wide
text timing, so the layer repeats at exactly x=512. This is correct hardware
behaviour, not a bug. The player sets its own CRTC mode and the wrap disappears.
No CRTC timing table has been written yet — the harness deliberately keeps the
IPL's timing so that no invented CRTC values are in play.
+41 -3
View File
@@ -156,6 +156,41 @@ functional models, not timing-accurate; a KB/s figure from MAME measures the
emulator's scheduler. `docs/BENCHMARK.md` covers the three-tier approach emulator's scheduler. `docs/BENCHMARK.md` covers the three-tier approach
(MAME validates the path, derivation bounds it, real hardware settles it). (MAME validates the path, derivation bounds it, real hardware settles it).
## Display path — WORKING, verified end to end (session 3)
The first real frame is on screen: `docs/images/x68k_first_frame_compare.png`.
Full write-up in **FINDINGS 22**. Harness: `tools/bench/show_frame.lua` +
`tools/bench/prep_frame.py`.
Three facts the player MUST honour, none of which were guessable:
| what | where | value |
|---|---|---|
| **Un-hide the graphics layer** | CRTC R20 `$E80028` | clear bit 11 ("G-VRAM set to buffer"); IPL leaves `0x0B16` |
| Colour setup (256c) | CRTC R20 bits 9-8 | `0x0100` -> `R20 = 0x0116` |
| **Monitor contrast** | `$E8E001` bits 3-0 | IPL leaves **14**; write **15** or everything renders 7% dark |
Bit 11 is the one that cost the most time: GVRAM writes land and read back
correctly while the layer is invisible, so the video controller looks guilty and
is not. Contrast `0` blanks the screen — free fade-to-black for transitions.
Palette format is now **confirmed from MAME source**, not assumed:
`GGGGGRRRRRBBBBBI` (G 15:11, R 10:6, B 5:1, shared LSB I), expanded as
`pal6bit((field<<1)|I)`. With contrast at 15 the render is **pixel-exact**.
New ceiling: the 15-bit+I palette alone costs **38.88 dB** against the 24-bit
palettised source — the same order as the `scsi` profile's own codec error
(39.4 dB). `scsi` is close to display-transparent on real hardware. See
FINDINGS 22.4 before considering raising quality further.
Snapshot recipe that works (`-video none` CANNOT snapshot):
```
SDL_VIDEODRIVER=dummy mame x68000 -bios ipl10 -video soft -window \
-sound none -nothrottle -plugins -autoboot_script <script>.lua \
-snapshot_directory ./snap -snapview native -seconds_to_run 6
```
`-snapview native` drops MAME's LED artwork and gives a clean 768x512 screen.
## Next steps, in priority order ## Next steps, in priority order
1. **Full-disc survey.** Only 4 clips of 1.2-1.7 s out of 224 streams have been 1. **Full-disc survey.** Only 4 clips of 1.2-1.7 s out of 224 streams have been
@@ -164,9 +199,12 @@ emulator's scheduler. `docs/BENCHMARK.md` covers the three-tier approach
vs content first (FINDINGS 13) or the averages are diluted by static menus. vs content first (FINDINGS 13) or the averages are diluted by static menus.
**Vectorise `_paint` before this run** — it is a Python per-block loop. **Vectorise `_paint` before this run** — it is a Python per-block loop.
2. **68000 decoder skeleton.** Parse `DLX1`, expand codebooks to word-per-pixel, 2. **68000 decoder skeleton.** Parse `DLX1`, expand codebooks to word-per-pixel,
blit SKIP/V1/V4/RAW. Measure real cycles with the existing MAME Lua harness blit SKIP/V1/V4/RAW. Measure real cycles with the existing MAME Lua harness.
the first time that harness gets used for its actual purpose. Validates the **Now unblocked** — the display path is verified (FINDINGS 22) and
38% full-frame blit estimate that the whole CPU budget rests on. `tools/bench/show_frame.lua` gives a known-good reference image to diff the
68000's output against. Validates the 38% full-frame blit estimate that the
whole CPU budget rests on. Still needs a real CRTC mode table for 256x256;
the harness deliberately borrows the IPL's timing and invents nothing.
3. **Wire rate control into `encode.py`.** No longer a blocker (FINDINGS 21), but 3. **Wire rate control into `encode.py`.** No longer a blocker (FINDINGS 21), but
it is what gives a deterministic ceiling over content not yet measured, which it is what gives a deterministic ceiling over content not yet measured, which
was the original reason for choosing VQ. Insurance, not a fix. Pairs with (1). was the original reason for choosing VQ. Insurance, not a fix. Pairs with (1).
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Frame -> flat (RGB888 palette + index plane) blob for the MAME Lua loader.
Packing into the X68000 palette word is done Lua-side on purpose: the exact
channel order is a hardware fact we intend to CONFIRM BY EYE, not assume, so it
has to be cheap to change without regenerating the blob.
"""
import sys, struct, glob
import numpy as np
from PIL import Image
src, out = sys.argv[1], sys.argv[2]
f = sorted(glob.glob(f"{src}/*.png"))[int(sys.argv[3]) if len(sys.argv) > 3 else 0]
im = Image.open(f).convert("RGB")
W, H = im.size
q = im.quantize(colors=256, method=Image.MEDIANCUT, dither=Image.NONE)
pal = np.array(q.getpalette()[:256*3], dtype=np.uint8).reshape(256, 3)
idx = np.asarray(q, dtype=np.uint8)
with open(out, "wb") as fh:
fh.write(b"DLXR")
fh.write(struct.pack(">HH", W, H))
fh.write(pal.tobytes())
fh.write(idx.tobytes())
# reference PNG of exactly what the X68000 should display
Image.fromarray(pal[idx]).save(out.replace(".bin", "_ref.png"))
print(f"src={f} {W}x{H} colors={len(np.unique(idx))} -> {out}")
+47
View File
@@ -0,0 +1,47 @@
-- Real frame, 256-colour, with the ACTUAL display gate from MAME's source:
-- CRTC R20 bit 11 = "G-VRAM set to buffer" -> graphics layer invisible.
-- CRTC R20 bits 9-8 = colour setup: 0x0100 = 256-colour, low byte per word.
-- The IPL leaves R20 = 0x0B16 (buffered + 65536c), which is why every earlier
-- attempt rendered black no matter what the video controller said.
M=manager.machine; SP=M.devices[":maincpu"].spaces["program"]; SUB=nil
local CRTC20 = 0xE80000 + 20*2
local GVRAM, GPAL = 0xC00000, 0xE82000
local f=io.open("frame.bin","rb"); local d=f:read("a"); f:close()
local function B(i) return string.byte(d,i) end
local W,H = B(5)*256+B(6), B(7)*256+B(8)
local PAL0, PIX0 = 9, 9+256*3
-- GGGGGRRRRRBBBBBI, confirmed from x68k_v.cpp
local function pack(r,g,b) return ((g>>3)<<11)|((r>>3)<<6)|((b>>3)<<1)|1 end
local function T() local t=M.time; return t.seconds+t.attoseconds/1e18 end
local st,tp="wait",nil
SUB = emu.add_machine_frame_notifier(function()
local t=T()
if st=="wait" then
if t<3.0 then return end
local old = SP:read_u16(CRTC20)
local new = (old & ~0x0B00) | 0x0100 -- clear buffer bit, select 256c
SP:write_u16(CRTC20, new)
SP:write_u16(0xE82400, 0x0001) -- video ctrl: 256 colours
SP:write_u16(0xE82600, 0x001F) -- graphics + pages on, text off
SP:write_u8(0xE8E001, 15) -- monitor contrast: IPL leaves it at 14
for c=0,255 do
local o=PAL0+c*3
SP:write_u16(GPAL+c*2, pack(B(o),B(o+1),B(o+2)))
end
for y=0,H-1 do
local row,base = PIX0+y*W, GVRAM+y*1024
for x=0,W-1 do SP:write_u16(base+x*2, B(row+x)) end
end
print(string.format("[SHOW3] R20 %04X->%04X E82400=%04X E82600=%04X gv=%04X t=%.3f",
old, SP:read_u16(CRTC20), SP:read_u16(0xE82400), SP:read_u16(0xE82600),
SP:read_u16(GVRAM), t))
st,tp="painted",t
elseif st=="painted" and t>tp+0.30 then
M.video:snapshot(); print("[SHOW3] snapshot"); st="done"; M:exit()
end
end)