Put the palette on the channel, and find one start paints a whole frame

ROADMAP K1, the packed player's one open structural item.  A frame is a picture
AND a palette, and no run in this tree had pointed a DMA channel at the palette
registers.  dmagate.s runs 7-9, gated by dma_run.sh and check.sh:

  7. 512 B off the disc into $E82000, bus held -- byte-exact in 256 register
     words, read back OUT OF the registers by the 68000;
  8. the SAME transfer aimed at RAM -- byte-exact at $2C000, and 256 of 256
     palette words still read the poison the CPU wrote, which is what attributes
     run 7 to the channel's MAR rather than to the readback path;
  9. ONE array-chained start across two kinds of destination -- the palette and
     six picture rows at the 1,024 B line stride, 2,048 B byte-exact.

So a packed frame is one channel start: a 193-entry array, palette first, CPU
halted from the first byte to the last.  The array is scene-constant, because
the packed layout spends both 256-colour pages and there is no page to flip.
What is left on the CPU per frame in the video path is the channel start and the
READ(10) -- no per-frame PAINT, which is not the same claim as no per-frame CPU.

The destination is POISONED first (62.1).  Runs 4-6 wrote into RAM that was zero
and GVRAM that was stale against a record that is mostly pad; "it matches the
disc" was weaker than it read as.  The host counts whether the poison actually
discriminates instead of assuming it: 511 of 512, and the gate refuses under 500.

And it opened a hardware item (62.4, ROADMAP B4).  MAME maps the palette to
palette_device over memory_array, whose write16 is a plain COMBINE_DATA -- RAM
that honours mem_mask, with no handler that could refuse a byte write.  Unlike
GVRAM's 256-colour arm there is nothing here to be wrong about, so the run
bounds the model and not the board.  What a real palette register does with a
byte write is unmeasured.  A negative costs 0.28% of a frame and nothing else.

29_packed_player.py now also prints the two rows with the per-frame palette
charged -- 55.7% of a frame on the chain, 582 KB/s -- alongside the picture-only
figures the codec comparison is quoted against.

check.sh ALL GREEN before (tmp/check_s30_start.log) and after
(tmp/check_s30_end.log).

Claude-Session: https://claude.ai/code/session_01194oWYW8DQXK1SZ2DnChW6
This commit is contained in:
prosolis
2026-08-25 07:06:44 -07:00
parent 1be428c270
commit 07f36c2af9
9 changed files with 664 additions and 32 deletions
+32 -4
View File
@@ -330,10 +330,14 @@ print(f""" PSNR against the 24-bit source, 18_text_plane_16col.py over the same
one entry. The tree has already measured a reserved entry at
0.04 dB (60.3), so this is noise against {PSNR_FRAME_256-PSNR_SHIPPED:+.2f}.
NOT SETTLED, and it is the obvious next probe: whether a DMA CHANNEL can write
the palette registers at $E82000, which would make the palette a 193rd chain
entry and cost the CPU nothing at all. Untested; 61.2 only ever pointed a
channel at GVRAM.
SETTLED IN SESSION 30, AND THE ANSWER IS YES (FINDINGS 62): a channel writes
the palette registers at $E82000 byte-exact, and ONE array-chained start
crosses from those registers into GVRAM -- so the palette IS a 193rd chain
entry and the clocks row above is what the CPU pays only if it does the write
itself. dmagate.s runs 7-9. What that does NOT settle is the board: MAME maps
the palette to palette_device over memory_array, whose write16 is a plain
COMBINE_DATA, so there is no handler that could refuse a byte write and the
model cannot discriminate. ROADMAP B4.
AND THE PSNR FIGURES ARE PIL's MEDIANCUT, not this project's own palette
builder (vq.scene_palette / H.build). The DIRECTION is measured and the
@@ -344,6 +348,14 @@ print(f""" PSNR against the 24-bit source, 18_text_plane_16col.py over the same
w9 = 9.0
free_packed_dma = PACKED_BPF * w9 + CHAIN_CLK + AUD_CLK
free_packed_cpu = PACKED_BPF * w9 + blit[8] + AUD_CLK
# ... and the same two rows with the PER-FRAME PALETTE actually charged, which
# is what a player ships. The picture rows above are the comparison against the
# codec and are left alone so the published 55.2% / 81.6% do not drift; these
# are the shipping figures. Session 30 (FINDINGS 62) made the DMAC row's
# version legal: the palette is a 193rd chain ENTRY, so it costs 512 more
# delivered bytes and one more entry rather than 256 CPU word writes.
pal_dma = (PACKED_BPF + PAL_BYTES) * w9 + CHAIN_CLK + B.DMA_CHAIN_CLK + AUD_CLK
pal_cpu = (PACKED_BPF + PAL_BYTES) * w9 + blit[8] + pal_clk + AUD_CLK
codec_9 = codec_bpf * w9 + codec_decode + AUD_CLK
print("\n" + "=" * 78)
print(f"""THE ANSWER, AT THE ONE RUNG THIS MACHINE CAN BE SHOWN TO RUN (W=9)
@@ -352,6 +364,22 @@ print(f"""THE ANSWER, AT THE ONE RUNG THIS MACHINE CAN BE SHOWN TO RUN (W=9)
free / DMAC->GVRAM / PACKED {100*free_packed_dma/FRAME_CLK:6.1f}% -- FITS, with {100-100*free_packed_dma/FRAME_CLK:.0f}% to spare
free / CPU-painted / PACKED {100*free_packed_cpu/FRAME_CLK:6.1f}% -- FITS, with {100-100*free_packed_cpu/FRAME_CLK:.0f}% to spare
WITH THE PER-FRAME PALETTE CHARGED, which is what would ship:
DMAC-direct, palette on the CHAIN (62) {100*pal_dma/FRAME_CLK:6.1f}% of the frame, {(PACKED_BPF+PAL_BYTES)*FPS/1024:.0f} KB/s
CPU-painted, palette written by the CPU {100*pal_cpu/FRAME_CLK:6.1f}% of the frame, {(PACKED_BPF+PAL_BYTES)*FPS/1024:.0f} KB/s
The palette costs the same on the WIRE either way -- {PAL_BYTES} B a frame,
+{100*PAL_BYTES/PACKED_BPF:.1f}% -- and the wire is where this design is expensive. The gap
between the two rows is the PAINT, not the palette.
What session 30 bought is smaller than either and is worth stating exactly:
{pal_clk:,.0f} CPU clocks of palette writing replaced by one more chain entry at
{B.DMA_CHAIN_CLK} clocks, a net {100*(pal_clk-B.DMA_CHAIN_CLK)/FRAME_CLK:.2f}% of a frame -- plus the structural half,
which is that the video path then contains no per-frame PAINT at all. The
CPU still issues the READ(10) and starts the channel, and neither of those
is priced anywhere in this tree.
THE DECODER-FREE PACKED PLAYER FITS THE CLOCK BUDGET THAT THE CODEC MISSES.
That is not a small correction to 47.5, it is the reverse of the reason the
codec exists. 44.7 said it in advance and on a different cost model: "the
+12 -1
View File
@@ -550,6 +550,16 @@ echo "--- session 27: the DMAC drives the data phase, and holds the bus (FINDING
# again, so the run asserts the contrast and not just the held value;
# * the channel's own CSR/CER/MTC/MAR, which must say it moved every byte
# without error;
# * THE PALETTE REGISTERS AT $E82000 (session 30, ROADMAP K1): the same
# transfer aimed at the palette, byte-exact into 256 register words read
# back by the 68000; the SAME transfer aimed 20 KB away leaving the palette
# as the CPU poisoned it, which is what attributes the first run to the
# channel's MAR; and ONE array-chained start crossing from the registers
# into GVRAM, which is the shape of a whole frame -- a palette entry and
# 192 row entries, started once. What this does NOT settle is the board:
# MAME models the palette as a generic palette_device over memory_array,
# whose write16 is a plain COMBINE_DATA, so it cannot tell a register file
# that takes byte writes from one that does not (FINDINGS 62.4);
# * and a WINDOWED read through the channel REFUSED. 117 of 120 records start
# part way into a sector (58.3); a channel writes a contiguous run and cannot
# drop the bytes in front of one, so it would write the neighbouring records
@@ -565,7 +575,8 @@ if command -v chdman > /dev/null; then
bash tools/bench/dma_run.sh "$DLX" > tmp/dma_gate.log 2>&1 || {
echo "FAIL: the DMAC did not drive the SCSI data phase."
tail -16 tmp/dma_gate.log; exit 1; }
grep -aE "BYTES OK|MTC one instruction|trips round|REFUSED" tmp/dma_gate.log \
grep -aE "BYTES OK|MTC one instruction|trips round|REFUSED|PALETTE|ONE START" \
tmp/dma_gate.log \
| sed 's/^ *//;s/^/ /'
else
echo " SKIPPED: no chdman (ships with mame-tools) -- cannot build the volume"
+117 -15
View File
@@ -24,25 +24,40 @@ local SP = M.devices[":maincpu"].spaces["program"]
local function P(s) print("[DMA] "..s) end
local function T() local t=M.time; return t.seconds + t.attoseconds/1e18 end
local DGFLAG, DGREC, DGREC_SZ = 0x18600, 0x18610, 32
local DGFLAG, DGREC, DGREC_SZ = 0x18600, 0x18800, 32
local DGWIN, DGWERR, DGR20, DGR20N, DGR20C = 0x18700, 0x18704, 0x18708, 0x1870C, 0x18710
local DGR20P = 0x18714
local CHROW, CHN, CHBASE = 256, 8, 0xC10000
local R20OF -- filled in after the mailbox addresses are known
local GV = 0xC00000
local DGLBA, DGBLK = 1000, 4
local DST = {0x20000, 0x24000, 0x28000, 0xC08000, 0xC0C000, 0xC10000}
-- the palette runs (ROADMAP K1). PS7/PS8/PS9 are the SNAPSHOTS dmagate.s takes
-- by reading $E82000 back with the 68000 after each run; the registers
-- themselves hold only the last of the three by the time the host looks.
local PAL, PALN, PALB, POIS = 0xE82000, 256, 512, 0xA500
local PS7, PS8, PS9 = 0x1A000, 0x1A200, 0x1A400
local CHROW2, CH2BASE, CHN2ROWS = 256, 0xC14000, 6
local DST = {0x20000, 0x24000, 0x28000, 0xC08000, 0xC0C000, 0xC10000,
PAL, 0x2C000, CH2BASE}
local NAME = {"PIO (the path FINDINGS 58 measured)",
"DMA, BUS HELD (DCR $00 burst, OCR $81 max rate)",
"DMA, STEALING (DCR $80 cycle steal, OCR $80 limited)",
"DMA -> GVRAM (bus held, R20 bit 11 = BUFFER MODE) [47.6.2]",
"DMA -> GVRAM (the SAME, bit 11 CLEAR -- NEGATIVE CONTROL)",
"DMA -> GVRAM (ARRAY CHAINED, 8 rows at the 1024 B line stride)"}
local SHORT = {"pio", "held", "steal", "gvram", "masked", "chain"}
"DMA -> GVRAM (ARRAY CHAINED, 8 rows at the 1024 B line stride)",
"DMA -> PALETTE (bus held, 512 B into $E82000) [K1, 61.9]",
"DMA -> RAM (the SAME read aimed elsewhere -- NEGATIVE CONTROL:"
.." the palette must still read poison)",
"DMA -> PALETTE + SIX ROWS (ONE array-chained start across two"
.." kinds of destination)"}
local SHORT = {"pio", "held", "steal", "gvram", "masked", "chain",
"pal", "palctl", "palchain"}
local LENOF = {[6]=PALB, [7]=PALB} -- everything else is DGBLK*512
local ERRNAME = {[0]="OK", "SELECTION TIMEOUT -- no target answered",
"UNEXPECTED PHASE", "POLL TIMEOUT -- a phase never arrived",
"NON-ZERO SCSI STATUS",
"WINDOWED READ REFUSED -- a channel cannot drop bytes"}
R20OF = {[3]=DGR20, [4]=DGR20N, [5]=DGR20C}
R20OF = {[3]=DGR20, [4]=DGR20N, [5]=DGR20C, [8]=DGR20P}
local DISK = os.getenv("DLX_SCSI_IMG") or "dlxdisk.img"
local code do local f=io.open("dmagate.bin","rb"); code=f:read("a"); f:close() end
@@ -75,8 +90,8 @@ SUB = emu.add_machine_frame_notifier(function()
return
end
if not want then P("no "..DISK.." to check against"); P("done"); M:exit(); return end
local LEN = DGBLK*512
for i = 0, 5 do
for i = 0, 8 do
local LEN = LENOF[i] or DGBLK*512
local b = DGREC + i*DGREC_SZ
local rc = SP:read_u32(b)
local e = SP:read_u32(b+4)
@@ -101,21 +116,43 @@ SUB = emu.add_machine_frame_notifier(function()
local pg1, pg0, bad_hi, bad_lo = 0, 0, 0, 0
for k = 1, LEN do
local got
-- `a` is set for every destination that has to be read a WORD at a
-- time and split by hand -- GVRAM in buffer mode, and the palette
-- snapshots, whose words are what the 68000 read back out of
-- $E82000. Where it stays nil the destination is plain RAM.
local off, a
if i == 5 then
-- The chained run's destination is not linear: byte k of the
-- transfer is byte k%256 of row k//256, and the rows are a full
-- 1024 B line stride apart. If the channel had ignored the array
-- and run contiguously, every byte past the first row would be
-- in the wrong place and this comparison would say so.
local off = (k-1) % CHROW
local a = CHBASE + ((k-1) // CHROW) * 1024 + (off & ~1)
off = (k-1) % CHROW
a = CHBASE + ((k-1) // CHROW) * 1024 + (off & ~1)
elseif i == 6 then
off = (k-1) % 2
a = PS7 + ((k-1) & ~1)
elseif i == 8 then
-- ONE transfer across two kinds of destination: the first sector
-- is the palette, the rest is six picture rows at the line
-- stride. The split is the array's, and this walks it the same
-- way the channel was told to.
if k <= PALB then
off = (k-1) % 2
a = PS9 + ((k-1) & ~1)
else
local idx = k - PALB - 1
off = idx % 2
a = CH2BASE + (idx // CHROW2) * 1024 + ((idx % CHROW2) & ~1)
end
elseif i >= 3 and i ~= 7 then
off = (k-1) % 2
a = DST[i+1] + ((k-1) & ~1)
end
if a then
local w = SP:read_u16(a)
if (off % 2) == 0 then got = (w >> 8) & 0xff; pg1 = pg1 + 1
else got = w & 0xff; pg0 = pg0 + 1 end
elseif i >= 3 then
local w = SP:read_u16(DST[i+1] + ((k-1) & ~1))
if ((k-1) % 2) == 0 then got = (w >> 8) & 0xff; pg1 = pg1 + 1
else got = w & 0xff; pg0 = pg0 + 1 end
else
got = SP:read_u8(DST[i+1]+k-1)
end
@@ -125,14 +162,35 @@ SUB = emu.add_machine_frame_notifier(function()
else bad_lo = bad_lo + 1 end
end
end
if i >= 3 then
if R20OF[i] then
P(string.format(" R20 during the run = $%04X (bit 11 %s); %d bytes "
.."read back as page 1 (high half) and %d as page 0",
.."read back out of the HIGH half of a destination "
.."word and %d out of the LOW half",
SP:read_u32(R20OF[i]),
((SP:read_u32(R20OF[i]) & 0x0800) ~= 0)
and "SET" or "CLEAR",
pg1, pg0))
end
-- THE PALETTE RUNS' OWN VACUITY CHECK. Run 7's destination was
-- poisoned by the 68000 first, so "it matches the disc" cannot be
-- satisfied by a channel that did nothing -- but only if the poison
-- and the disc actually differ everywhere they are compared. That is
-- a property of THIS record and is counted rather than assumed.
if i == 6 then
local diff = 0
for j = 0, PALN-1 do
local w = (POIS | j) & 0xffff
if ((w >> 8) & 0xff) ~= string.byte(want, 2*j+1) then diff = diff + 1 end
if (w & 0xff) ~= string.byte(want, 2*j+2) then diff = diff + 1 end
end
P(string.format(" PALETTE POISON IS A DISCRIMINATOR: %d of %d "
.."positions differ from the disc's bytes -- a "
.."channel that wrote nothing could not have passed "
.."in those.", diff, PALB))
P(string.format(" %d bytes read back out of the HIGH half of a "
.."palette word (G and the top of R) and %d out of "
.."the LOW half", pg1, pg0))
end
if bad == 0 then
P(string.format(" BYTES OK: %d B from LBA %d match %s byte for byte "
.."[%s]", LEN, DGLBA, DISK, SHORT[i+1]))
@@ -152,6 +210,26 @@ SUB = emu.add_machine_frame_notifier(function()
P(" CONTROL DID NOT FAIL: the masked write path delivered every "
.."byte too, so the run above is not evidence about R20 bit 11.")
end
if i == 6 then
P(" A CHANNEL WRITES THE PALETTE REGISTERS: 512 B off the disc "
.."became 256 palette words, read back OUT OF $E82000 by the "
.."68000 itself, with the CPU halted for the transfer. Each "
.."disc byte landed in its own half of a register word, so a "
.."per-frame palette needs no CPU (61.9, ROADMAP K1).")
end
if i == 8 then
P(string.format(" ONE START PAINTED THE PALETTE AND %d ROWS: a "
.."single array-chained transfer crossed from device registers "
.."at $%06X into GVRAM at $%06X, %d B in %d entries, CPU halted "
.."throughout. A frame is that shape with %d row entries "
.."instead of %d.", CHN2ROWS, PAL, CH2BASE, LEN, CHN2ROWS+1,
192, CHN2ROWS))
end
elseif i == 6 or i == 8 then
P(string.format(" PALETTE WRONG [%s]: %d of %d differ, first at "
.."+%d -- %d at EVEN offsets (the HIGH half of a "
.."word), %d at ODD.",
SHORT[i+1], bad, LEN, first, bad_hi, bad_lo))
elseif i == 4 then
-- THE CLAIM IS NOT "half the bytes differ". In masked 256-colour
-- mode gvram_w takes `data & 0x00ff` and ignores mem_mask, so a byte
@@ -175,6 +253,30 @@ SUB = emu.add_machine_frame_notifier(function()
SHORT[i+1], bad, LEN, first))
end
end
if i == 7 then
-- THE ATTRIBUTION CONTROL'S SECOND CLAIM, and the one that makes run
-- 7 mean something: the same transfer aimed 20 KB away leaves the
-- palette exactly as the 68000 poisoned it. If this comes back with
-- the disc's bytes in it, something other than the channel's MAR
-- decides what reaches $E82000 and run 7 measured that instead.
local stale, first_s = 0, nil
for j = 0, PALN-1 do
if SP:read_u16(PS8 + 2*j) ~= ((POIS | j) & 0xffff) then
stale = stale + 1; first_s = first_s or j
end
end
if stale == 0 then
P(string.format(" PALETTE UNTOUCHED BY THE CONTROL: %d of %d words "
.."still read the poison the 68000 wrote, so the "
.."bytes in run 7 got there because the channel's "
.."MAR pointed at $%06X.", PALN, PALN, PAL))
else
P(string.format(" CONTROL DID NOT FAIL [palctl]: %d of %d palette "
.."words are no longer poison (first at entry %d) "
.."-- the palette changed without a channel aimed "
.."at it.", stale, PALN, first_s))
end
end
if i > 0 then
-- THE DISCRIMINATOR. MTC as the instruction after START saw it, and
-- the number of times the CPU went round its own wait loop.
+37
View File
@@ -108,6 +108,43 @@ grep -aq "BYTES OK: 2048 B from LBA 1000 .*\[chain\]" tmp/dma_run.log || \
grep -aq "THE CHANNEL WALKED THE ARRAY ITSELF" tmp/dma_run.log || \
fail "the chained run did not report walking its own array."
# ---- THE PALETTE (ROADMAP K1, FINDINGS 61.9). If the registers at $E82000 take
# a byte-wide DMA the way GVRAM does in buffer mode, a per-frame palette is a
# 193rd array-chain entry and ONE channel start paints a whole frame; if they do
# not, the CPU writes 256 words a frame and the architecture still stands. The
# run is poisoned first and controlled twice -- once by aiming the same transfer
# elsewhere, once by counting how many of the 512 positions the poison and the
# disc actually differ in.
grep -aq "BYTES OK: 512 B from LBA 1000 .*\[pal\]" tmp/dma_run.log || \
fail "the channel did not write the palette registers at \$E82000 -- so a
per-frame palette costs the CPU 256 word writes and cannot ride the
frame's array chain (61.9). That is a RESULT, not a broken run: check the
PALETTE WRONG line above for whether the bytes were dropped or misplaced."
DIFF=$(sed -n 's/.*PALETTE POISON IS A DISCRIMINATOR: \([0-9]*\) of 512.*/\1/p' \
tmp/dma_run.log)
[ -n "$DIFF" ] && [ "$DIFF" -ge 500 ] || \
fail "the poison and the disc's bytes agree in ${DIFF:-?} of 512 positions, so
the palette run could have passed without a channel writing anything --
this is run 4's could-not-fail trap in a new place. Change DGPOIS."
grep -aq "BYTES OK: 512 B from LBA 1000 .*\[palctl\]" tmp/dma_run.log || \
fail "the ATTRIBUTION control's read did not land in RAM, so its palette claim
is about a transfer that did not happen."
grep -aq "PALETTE UNTOUCHED BY THE CONTROL: 256 of 256 words" tmp/dma_run.log || \
fail "the palette changed during a transfer aimed 20 KB away from it. Then
what reached \$E82000 in the run above was not decided by the channel's
MAR, and that run measured something else."
if grep -aq "CONTROL DID NOT FAIL \[palctl\]" tmp/dma_run.log
then
fail "the control reported its own failure -- see the line above it."
fi
grep -aq "BYTES OK: 2048 B from LBA 1000 .*\[palchain\]" tmp/dma_run.log || \
fail "ONE array-chained start could not cross from the palette registers into
GVRAM. A frame is one palette entry and 192 row entries; if the two kinds
of destination cannot share a chain, the CPU is back in the video path
once a frame to start the second half of it."
grep -aq "ONE START PAINTED THE PALETTE AND 6 ROWS" tmp/dma_run.log || \
fail "the palette+rows run did not report the crossing it exists to show."
grep -aq "WINDOWED DMA READ REFUSED" tmp/dma_run.log || \
fail "a WINDOWED read through the channel was not refused. 117 of 120 records
start part way into a sector (58.3), and a channel cannot drop the bytes