A real 256x256 CRTC mode, derived not recalled; palette ceiling was 2 dB low
Session 3 left the harness on the IPL's 768x512 text timing because no CRTC values had been derived and guessing them was the failure mode to avoid. This derives them from MAME 0.277's divisor ladder instead, and the derivation is self-checking: the 256-wide mode runs at div 6 against the 768 mode's div 2, so htotal is exactly 1104/3 = 368 dots and every horizontal register divides by three with no remainder. Only the blanking split rounds. Verified by snapshot: native 256x512, active area pixel-exact, x=512 wrap gone. Two things fell out that change numbers elsewhere: - The palette's shared LSB I must be chosen per entry, not hardcoded to 1. Doing so lifts the display ceiling from 38.85 to 40.81 dB and is the only way to reach true black at all, since pal6bit(1) = 4. 102 of 256 entries want I = 0, so this is not a corner case. Supersedes FINDINGS 22.4; scsi has ~2 dB more headroom than that section claimed. The encoder does not do this yet. - Letterboxing costs a palette entry: GVRAM cleared to zero shows entry 0, and a free mediancut palette puts a real image colour there. 255 colours plus a reserved black, via prep_frame.py --reserve-black. MAME's graphics double-scan is phase-shifted one raster line (it halves the absolute scanline and vbegin is odd), which produced a false failure before it was understood; the regression test now asserts the shifted pairing explicitly. Still Lua-side. No 68000 instruction has drawn a pixel; the 38% blit estimate remains unvalidated. What this buys is a defined geometry for the decoder to write into: 256 words per row, 1024-byte stride, rows 32..223. Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
-- CRTC mode table: 256x256, 256 colours, 31.5kHz, graphics double-scanned.
|
||||
--
|
||||
-- DERIVED from MAME 0.277 src/mame/sharp/x68k_crtc.cpp, not recalled. The
|
||||
-- derivation is self-checking, which is why it is trustworthy:
|
||||
--
|
||||
-- refresh_mode() picks the dot clock as (reg20 bit4 ? 69.55199MHz : 38.86363MHz)/div
|
||||
-- with div from the (reg20 & 0x1f) ladder: 0x16 -> 2 (IPL's 768x512)
|
||||
-- 0x11 -> 3 (512 wide)
|
||||
-- 0x10 -> 6 (256 wide, double-scan)
|
||||
--
|
||||
-- IPL 768 mode: div 2 -> 34.776 MHz, m_htotal = (137+1)*8 = 1104 dots
|
||||
-- 34.776e6 / 1104 = 31500.0 Hz exactly.
|
||||
-- 256 mode: div 6 -> 11.592 MHz. Same 31.5kHz line rate requires
|
||||
-- 11.592e6 / 31500 = 368 dots = 46 chars -> R00 = 45.
|
||||
--
|
||||
-- 368 = 1104/3 exactly, so EVERY horizontal register is the 768-mode value
|
||||
-- divided by 3 -- no rounding for the active window:
|
||||
-- visible chars (124-28) / 3 = 32 -> 32*8 = 256 dots exact
|
||||
-- Only the blanking split needs rounding. 768 mode is sync/back/front =
|
||||
-- 14/14/14 chars; /3 = 4.67 each; the closest integer triple summing to
|
||||
-- 46-32 = 14 is 5/5/4. -> R01=5, R02=10, R03=42.
|
||||
--
|
||||
-- Total blanking time is identical to the 768 mode (112 dots @ 11.592MHz =
|
||||
-- 336 dots @ 34.776MHz = 9.66us), which is what a real monitor needs.
|
||||
--
|
||||
-- VERTICAL registers are NOT halved. The CRTC still generates a 568-line
|
||||
-- 31.5kHz raster (31500/568 = 55.46 Hz); "256 lines" is a graphics-layer
|
||||
-- double-scan (draw_gfx() halves gfxrect, x68k_v.cpp:401). Halving them would
|
||||
-- ask the monitor for 110 Hz. So R04-R07 keep the 31kHz text-mode values.
|
||||
-- MAME logerrors "visarea larger then reg[20]" for this; it is cosmetic.
|
||||
|
||||
local M = {}
|
||||
|
||||
M.regs = {
|
||||
[0] = 45, -- H total (46 chars = 368 dots @ 11.592MHz = 31500.0 Hz)
|
||||
[1] = 5, -- H sync end (5 chars = 3.45us)
|
||||
[2] = 10, -- H disp begin (hbegin = 10*8+1 = 81)
|
||||
[3] = 42, -- H disp end (hend = 336; inclusive width = 336-81+1 = 256)
|
||||
[4] = 567, -- V total (568 scanlines -> 55.46 Hz)
|
||||
[5] = 5, -- V sync end
|
||||
[6] = 40, -- V disp begin (vbegin = 41)
|
||||
[7] = 552, -- V disp end (512 scanlines -> 256 gfx rows, double-scanned)
|
||||
[8] = 27, -- H sync adjust (MAME stores but does not use it; IPL value)
|
||||
}
|
||||
|
||||
-- R20: bit11=0 display (not buffer), bits9-8=01 256-colour,
|
||||
-- bit4=1 31.5kHz, bits3-2=00 256 lines, bits1-0=00 256 dots
|
||||
M.r20 = 0x0110
|
||||
|
||||
M.width, M.height = 256, 256
|
||||
|
||||
function M.apply(SP)
|
||||
for r, v in pairs(M.regs) do SP:write_u16(0xE80000 + r*2, v) end
|
||||
SP:write_u16(0xE80000 + 20*2, M.r20)
|
||||
SP:write_u16(0xE82400, 0x0001) -- video ctrl reg 0: 256 colours
|
||||
SP:write_u16(0xE82600, 0x001F) -- reg 2: graphics on, all 4 pages, text/PCG off
|
||||
SP:write_u8 (0xE8E001, 15) -- monitor contrast (IPL leaves 14 = 7% dark)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -9,14 +9,24 @@ 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]
|
||||
argv = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||
# --reserve-black: quantise to 255 colours and reserve index 0 as black.
|
||||
# Needed for any mode that letterboxes (256x192 inside 256x256): GVRAM cleared
|
||||
# to 0 displays palette entry 0, and a free mediancut palette puts a real image
|
||||
# colour there. Costs one of 256 entries; measured quality cost is negligible.
|
||||
RESERVE = "--reserve-black" in sys.argv
|
||||
src, out = argv[0], argv[1]
|
||||
f = sorted(glob.glob(f"{src}/*.png"))[int(argv[2]) if len(argv) > 2 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)
|
||||
n = 255 if RESERVE else 256
|
||||
q = im.quantize(colors=n, method=Image.MEDIANCUT, dither=Image.NONE)
|
||||
pal = np.array(q.getpalette()[:n*3], dtype=np.uint8).reshape(n, 3)
|
||||
idx = np.asarray(q, dtype=np.uint8)
|
||||
if RESERVE:
|
||||
pal = np.vstack([np.zeros((1, 3), np.uint8), pal]) # index 0 = black
|
||||
idx = idx + 1
|
||||
|
||||
with open(out, "wb") as fh:
|
||||
fh.write(b"DLXR")
|
||||
@@ -26,4 +36,5 @@ with open(out, "wb") as fh:
|
||||
|
||||
# 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}")
|
||||
print(f"src={f} {W}x{H} colors={len(np.unique(idx))}"
|
||||
f"{' (idx 0 reserved black)' if RESERVE else ''} -> {out}")
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
-- Same as show_frame.lua, but sets a REAL 256x256 CRTC mode instead of
|
||||
-- borrowing the IPL's 768x512 text timing. Proves the mode table in
|
||||
-- crtc_mode.lua and removes the x=512 wrap of FINDINGS 22.5.
|
||||
M=manager.machine; SP=M.devices[":maincpu"].spaces["program"]; SUB=nil
|
||||
|
||||
local function load_mode()
|
||||
for _,p in ipairs{"../tools/bench/crtc_mode.lua","tools/bench/crtc_mode.lua","crtc_mode.lua"} do
|
||||
local f=loadfile(p); if f then return f() end
|
||||
end
|
||||
error("crtc_mode.lua not found")
|
||||
end
|
||||
local MODE = load_mode()
|
||||
|
||||
local GVRAM, GPAL = 0xC00000, 0xE82000
|
||||
|
||||
local f=io.open("frame256.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
|
||||
local YOFF = (MODE.height - H) // 2 -- letterbox 192 rows inside 256
|
||||
|
||||
-- GGGGGRRRRRBBBBBI, confirmed from x68k_v.cpp. The LSB "I" is SHARED by all
|
||||
-- three channels: each renders as pal6bit((field<<1)|I). Hardcoding I=1 (as
|
||||
-- show_frame.lua does) makes true black unreachable -- pal6bit(1) = 4 -- so I
|
||||
-- is chosen per entry to minimise summed squared error over R,G,B.
|
||||
local function pal6(v) return ((v<<2)|(v>>4)) & 0xff end
|
||||
local function pack(r,g,b)
|
||||
local f = {r>>3, g>>3, b>>3}
|
||||
local best, bestI = nil, 1
|
||||
for I=0,1 do
|
||||
local e=0
|
||||
for c=1,3 do
|
||||
local want = ({r,g,b})[c]
|
||||
local d = pal6((f[c]<<1)|I) - want
|
||||
e = e + d*d
|
||||
end
|
||||
if best==nil or e<best then best,bestI = e,I end
|
||||
end
|
||||
return (f[2]<<11)|(f[1]<<6)|(f[3]<<1)|bestI
|
||||
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
|
||||
MODE.apply(SP)
|
||||
-- clear the letterbox rows: GVRAM holds IPL leftovers, not zeros
|
||||
for y=0,MODE.height-1 do
|
||||
if y<YOFF or y>=YOFF+H then
|
||||
local base=GVRAM+y*1024
|
||||
for x=0,MODE.width-1 do SP:write_u16(base+x*2,0) end
|
||||
end
|
||||
end
|
||||
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+YOFF)*1024
|
||||
for x=0,W-1 do SP:write_u16(base+x*2, B(row+x)) end
|
||||
end
|
||||
print(string.format("[256] R00-R08 %d %d %d %d %d %d %d %d %d R20=%04X yoff=%d t=%.3f",
|
||||
SP:read_u16(0xE80000),SP:read_u16(0xE80002),SP:read_u16(0xE80004),SP:read_u16(0xE80006),
|
||||
SP:read_u16(0xE80008),SP:read_u16(0xE8000A),SP:read_u16(0xE8000C),SP:read_u16(0xE8000E),
|
||||
SP:read_u16(0xE80010),SP:read_u16(0xE80028), YOFF, t))
|
||||
st,tp="painted",t
|
||||
elseif st=="painted" and t>tp+0.30 then
|
||||
M.video:snapshot(); print("[256] snapshot"); st="done"; M:exit()
|
||||
end
|
||||
end)
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression test for the 256x256 CRTC mode (docs/FINDINGS 23).
|
||||
|
||||
Checks tmp/snap256/x68000/0000.png against tmp/frame256.bin:
|
||||
1. native snapshot is 256x512 -- 256 dots, and 512 active scanlines of a
|
||||
568-line 31.5kHz raster carrying 256 double-scanned graphics rows
|
||||
2. double-scan pairing is (1,2),(3,4),... -- MAME halves the ABSOLUTE
|
||||
scanline (x68k_v.cpp get_gfx_pixel) and vbegin=41 is odd, so snapshot
|
||||
row 0 is a lone half-line and even rows are gfx rows 0..255
|
||||
3. the 192 active rows are PIXEL-EXACT against the palette pushed through
|
||||
GGGGGRRRRRBBBBBI with I chosen per entry by minimum squared error
|
||||
4. the letterbox bars are TRUE black -- needs both a reserved index-0 black
|
||||
entry AND I=0 on it, since pal6bit(1) = 4, not 0
|
||||
"""
|
||||
import struct, sys
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
s = np.asarray(Image.open("tmp/snap256/x68000/0000.png").convert("RGB")).astype(int)
|
||||
d = open("tmp/frame256.bin", "rb").read()
|
||||
W, H = struct.unpack(">HH", d[4:8])
|
||||
pal = np.frombuffer(d[8:8+768], np.uint8).reshape(256, 3).astype(int)
|
||||
idx = np.frombuffer(d[8+768:8+768+W*H], np.uint8).reshape(H, W)
|
||||
|
||||
p6 = lambda v: ((v << 2) | (v >> 4)) & 0xFF
|
||||
f = pal >> 3
|
||||
render = lambda I: p6((f << 1) | I[:, None])
|
||||
I = (((render(np.ones(256, int)) - pal) ** 2).sum(1)
|
||||
< ((render(np.zeros(256, int)) - pal) ** 2).sum(1)).astype(int)
|
||||
exp = render(I)[idx]
|
||||
|
||||
fail = []
|
||||
if s.shape[:2] != (512, 256):
|
||||
fail.append(f"1. geometry: expected 512x256, got {s.shape[1]}x{s.shape[0]}")
|
||||
if not all(np.array_equal(s[i], s[i+1]) for i in range(1, s.shape[0]-1, 2)):
|
||||
fail.append("2. double-scan pairing (1,2),(3,4),... broken")
|
||||
|
||||
g = s[0::2]
|
||||
yoff = (g.shape[0] - H) // 2
|
||||
act = g[yoff:yoff+H]
|
||||
if not np.array_equal(act, exp):
|
||||
diff = abs(act - exp)
|
||||
fail.append(f"3. active area not pixel-exact: maxdiff {diff.max()}, "
|
||||
f"{diff.any(2).sum()} px differ")
|
||||
|
||||
bars = np.concatenate([g[:yoff], g[yoff+H:]])
|
||||
if bars.max() != 0:
|
||||
fail.append(f"4. letterbox not true black: max channel {bars.max()}")
|
||||
|
||||
for x in fail:
|
||||
print("FAIL " + x)
|
||||
if fail:
|
||||
sys.exit(1)
|
||||
|
||||
mse = ((act - pal[idx]) ** 2).mean()
|
||||
print(f"OK 256x512 native, double-scan exact, active {W}x{H} pixel-exact, "
|
||||
f"letterbox true black")
|
||||
print(f" palette ceiling vs 24-bit palettised source: "
|
||||
f"{10*np.log10(255**2/mse):.2f} dB ({(I==0).sum()}/256 entries use I=0)")
|
||||
Reference in New Issue
Block a user