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:
@@ -659,6 +659,11 @@ frame is pixel-identical, not merely close. GVRAM line stride is confirmed as
|
|||||||
512 words = 1024 bytes, matching `HARDWARE.md`.
|
512 words = 1024 bytes, matching `HARDWARE.md`.
|
||||||
|
|
||||||
### 22.4 A new quality ceiling: the 15-bit palette costs 38.88 dB
|
### 22.4 A new quality ceiling: the 15-bit palette costs 38.88 dB
|
||||||
|
> **Superseded by 23.3.** The 38.88 dB figure assumed the shared LSB `I` is
|
||||||
|
> always 1. Choosing `I` per palette entry by minimum error lifts the ceiling to
|
||||||
|
> **40.81 dB** on the same frame. The conclusion below ("`scsi` is close to
|
||||||
|
> display-transparent") is therefore weaker than stated — there is ~2 dB more
|
||||||
|
> headroom than this section claims.
|
||||||
Section 3 called the 256-colour palettised frame "the real quality ceiling".
|
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
|
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:
|
channel plus a shared LSB, so there is a **second** quantisation below it:
|
||||||
@@ -683,3 +688,107 @@ 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.
|
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
|
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.
|
IPL's timing so that no invented CRTC values are in play.
|
||||||
|
|
||||||
|
|
||||||
|
## 23. A real CRTC mode: 256x192 inside 256x256 (session 4)
|
||||||
|
|
||||||
|
Session 3's harness borrowed the IPL's 768x512 text timing and invented no CRTC
|
||||||
|
values, which is why the frame repeated at x=512 (22.5). This session derived a
|
||||||
|
real 256x256 mode table from MAME 0.277 source and verified it by snapshot.
|
||||||
|
Table: `tools/bench/crtc_mode.lua`. Regression test: `tools/bench/verify_frame256.py`.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Left: palettised source. Right: the emulated X68000's native 256x512 raster —
|
||||||
|
256 dots wide, 512 scanlines carrying 256 double-scanned graphics rows, with the
|
||||||
|
192-row picture letterboxed in true black.*
|
||||||
|
|
||||||
|
### 23.1 The table, and why it needed no guessing
|
||||||
|
`refresh_mode()` in `x68k_crtc.cpp` selects the dot clock as
|
||||||
|
`(reg20 bit4 ? 69.55199MHz : 38.86363MHz) / div`, with `div` from a ladder keyed
|
||||||
|
on `reg20 & 0x1f`. Three entries matter:
|
||||||
|
|
||||||
|
| `reg20 & 0x1f` | div | dot clock | mode |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `0x16` | 2 | 34.776 MHz | IPL's 768 wide, 31.5kHz |
|
||||||
|
| `0x11` | 3 | 23.184 MHz | 512 wide, 31.5kHz |
|
||||||
|
| `0x10` | 6 | 11.592 MHz | **256 wide, 31.5kHz, graphics double-scanned** |
|
||||||
|
|
||||||
|
The IPL's `R00 = 137` gives `m_htotal = (137+1)*8 = 1104` dots, and
|
||||||
|
`34.776e6 / 1104 = 31500.0 Hz` **exactly**. Holding the same line rate at div 6
|
||||||
|
needs `11.592e6 / 31500 = 368` dots `= 46` chars, so `R00 = 45`.
|
||||||
|
|
||||||
|
`368 = 1104/3` exactly, so every horizontal register is the 768-mode value
|
||||||
|
divided by three, and the active window divides without remainder:
|
||||||
|
`(124-28)/3 = 32` chars `= 256` dots. **No horizontal value was recalled or
|
||||||
|
estimated.** Only the blanking split rounds: the 768 mode is sync/back/front =
|
||||||
|
14/14/14 chars, `/3 = 4.67` each, and the closest integer triple summing to
|
||||||
|
`46-32 = 14` is 5/5/4.
|
||||||
|
|
||||||
|
| reg | value | meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| R00 | 45 | H total, 46 chars = 368 dots -> 31500.0 Hz |
|
||||||
|
| R01 | 5 | H sync end (3.45 us) |
|
||||||
|
| R02 | 10 | H display begin -> `hbegin = 81` |
|
||||||
|
| R03 | 42 | H display end -> `hend = 336`; inclusive width `336-81+1 = 256` |
|
||||||
|
| R04 | 567 | V total, 568 scanlines -> 55.46 Hz |
|
||||||
|
| R05 | 5 | V sync end |
|
||||||
|
| R06 | 40 | V display begin -> `vbegin = 41` |
|
||||||
|
| R07 | 552 | V display end -> 512 scanlines = 256 double-scanned rows |
|
||||||
|
| R08 | 27 | H sync adjust (MAME stores it and never reads it) |
|
||||||
|
| R20 | `0x0110` | display (not buffer), 256-colour, 31.5kHz, 256x256 |
|
||||||
|
|
||||||
|
**The vertical registers are NOT halved**, which is the one thing that looks
|
||||||
|
wrong and is not. The CRTC still generates a 568-line raster; "256 lines" is a
|
||||||
|
graphics-layer double-scan applied in `draw_gfx()` (`x68k_v.cpp:401`), not a
|
||||||
|
change to the raster. Halving R04 would ask the monitor for 110 Hz. MAME emits
|
||||||
|
a `visarea larger then reg[20]` logerror for this; it is cosmetic.
|
||||||
|
|
||||||
|
Total blanking time is identical to the 768 mode (112 dots at 11.592 MHz =
|
||||||
|
336 dots at 34.776 MHz = 9.66 us), which is the property a real monitor cares
|
||||||
|
about — so this table should be safe on hardware, though that is untested.
|
||||||
|
|
||||||
|
### 23.2 MAME's double-scan is phase-shifted by one raster line
|
||||||
|
`get_gfx_pixel()` indexes `m_gfxbitmap.pix(scanline / divisor, pixel)` using the
|
||||||
|
**absolute** scanline, and `vbegin = 41` is odd. So in the native 256x512
|
||||||
|
snapshot the identical row pairs are `(1,2), (3,4), ...` and row 0 is a lone
|
||||||
|
half-line. Even rows are graphics rows 0..255. This cost a false failure before
|
||||||
|
it was understood; the regression test now asserts the shifted pairing
|
||||||
|
explicitly so a change in MAME's behaviour is visible rather than confusing.
|
||||||
|
|
||||||
|
### 23.3 The shared LSB `I` must be chosen per palette entry — worth 1.96 dB
|
||||||
|
Session 3's `pack()` hardcoded `I = 1`. That is not free: `I` is shared by all
|
||||||
|
three channels and each renders as `pal6bit((field << 1) | I)`, so with `I = 1`
|
||||||
|
the darkest reachable value is `pal6bit(1) = 4`, and **true black does not
|
||||||
|
exist**. Choosing `I` per entry to minimise summed squared error over R,G,B:
|
||||||
|
|
||||||
|
| rule | ceiling vs 24-bit palettised (00020 f0001) | entries with I=0 |
|
||||||
|
|---|---|---|
|
||||||
|
| `I = 1` fixed (session 3) | 38.85 dB | 0 |
|
||||||
|
| `I` per entry, min squared error | **40.81 dB** | 102 / 256 |
|
||||||
|
|
||||||
|
Nearly **2 dB for free**, and 102 of 256 entries want `I = 0` — this is not a
|
||||||
|
corner case. It supersedes the ceiling in 22.4 and means `scsi` has about 2 dB
|
||||||
|
more headroom before it hits the display than that section claimed.
|
||||||
|
|
||||||
|
The encoder does not yet do this. `tools/encoder/` still emits 24-bit palettes
|
||||||
|
and the packing happens Lua-side; whatever eventually writes X68000 palette
|
||||||
|
words must use the per-entry rule.
|
||||||
|
|
||||||
|
### 23.4 Letterboxing requires a reserved black palette entry
|
||||||
|
GVRAM cleared to zero displays **palette entry 0**, and a free mediancut palette
|
||||||
|
puts a real image colour there — on 00020 f0001 it was `(206,192,176)`, used by
|
||||||
|
210 image pixels, so it cannot simply be repurposed. A 256x192 picture in a
|
||||||
|
256x256 mode has 64 blank rows, so the palette must be built with **255 colours
|
||||||
|
plus a reserved black at index 0** (`prep_frame.py --reserve-black`). Combined
|
||||||
|
with 23.3, entry 0 also needs `I = 0` or the bars sit at RGB (4,4,4).
|
||||||
|
|
||||||
|
Cost: one of 256 entries. Measured quality effect: none visible — the ceiling
|
||||||
|
figure in 23.3 is already measured on the 255-colour palette.
|
||||||
|
|
||||||
|
### 23.5 What is still not proven
|
||||||
|
GVRAM was again filled from Lua. **No 68000 instruction has drawn a pixel yet**,
|
||||||
|
and the 38% full-frame blit estimate underpinning the CPU budget remains
|
||||||
|
unvalidated. What this section adds is that the *target mode* is now real, so
|
||||||
|
68000 code has a defined geometry to write into: 256 words per visible row, a
|
||||||
|
1024-byte line stride, and rows 32..223 of a 256-row page.
|
||||||
|
|||||||
+54
-19
@@ -1,4 +1,4 @@
|
|||||||
# Status & next-session handoff — end of session 3 (2026-08-23)
|
# Status & next-session handoff — session 4 in progress (2026-08-23)
|
||||||
|
|
||||||
## Decisions locked
|
## Decisions locked
|
||||||
|
|
||||||
@@ -72,6 +72,27 @@ rate-distortion curve, not two codecs.
|
|||||||
the working-setup section below. They cost ~1.5 h of wall clock and a wedged
|
the working-setup section below. They cost ~1.5 h of wall clock and a wedged
|
||||||
CPU core, and one of them was hit again this session.
|
CPU core, and one of them was hit again this session.
|
||||||
|
|
||||||
|
## What session 4 settled (in progress)
|
||||||
|
|
||||||
|
1. **A real 256x256 CRTC mode exists and is verified.** `crtc_mode.lua`, derived
|
||||||
|
from `x68k_crtc.cpp`'s divisor ladder rather than recalled — the derivation is
|
||||||
|
self-checking (368 = 1104/3 exactly, so the horizontal registers divide by
|
||||||
|
three with no remainder). Snapshot is native 256x512, active area pixel-exact,
|
||||||
|
letterbox true black. FINDINGS 23. The x=512 wrap of FINDINGS 22.5 is gone.
|
||||||
|
2. **The palette ceiling was wrong by 2 dB, in our favour.** The shared LSB `I`
|
||||||
|
must be chosen **per palette entry**, not hardcoded to 1. Doing so lifts the
|
||||||
|
display ceiling from 38.85 to **40.81 dB** and is the only way to get true
|
||||||
|
black at all (`pal6bit(1) = 4`). 102 of 256 entries want `I = 0`. This
|
||||||
|
supersedes FINDINGS 22.4 and gives `scsi` ~2 dB more headroom than believed.
|
||||||
|
**The encoder does not do this yet** — see the encoder-gaps list.
|
||||||
|
3. **Letterboxing costs one palette entry.** 255 colours + a reserved black at
|
||||||
|
index 0, with `I = 0` on it. `prep_frame.py --reserve-black`. FINDINGS 23.4.
|
||||||
|
4. **MAME's graphics double-scan is phase-shifted one raster line** — pairs are
|
||||||
|
(1,2),(3,4),..., not (0,1), because `get_gfx_pixel` halves the *absolute*
|
||||||
|
scanline and `vbegin = 41` is odd. Cost a false failure. FINDINGS 23.2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## What session 2 settled
|
## What session 2 settled
|
||||||
|
|
||||||
1. **The critical-path question is answered.** "Does VQ soften Bluth's linework
|
1. **The critical-path question is answered.** "Does VQ soften Bluth's linework
|
||||||
@@ -113,6 +134,10 @@ multi-byte fields are **big-endian** so the 68000 reads them with a plain `move`
|
|||||||
builds a lam-ladder per frame; it needs hooking up and validating.
|
builds a lam-ladder per frame; it needs hooking up and validating.
|
||||||
- **Payload is deliberately NOT entropy-coded** — deflate decode does not fit in
|
- **Payload is deliberately NOT entropy-coded** — deflate decode does not fit in
|
||||||
the 68000's frame budget (FINDINGS 17.2). Do not "optimise" this later.
|
the 68000's frame budget (FINDINGS 17.2). Do not "optimise" this later.
|
||||||
|
- **Palette packing is not implemented in the encoder.** It still emits 24-bit
|
||||||
|
palettes; the X68000 word packing happens Lua-side. Whatever writes real
|
||||||
|
palette words must pick `I` per entry by minimum squared error (FINDINGS 23.3,
|
||||||
|
worth 1.96 dB) and reserve index 0 as black with `I = 0` (FINDINGS 23.4).
|
||||||
- Codebooks are per-scene and rebuilt from scratch; no inter-scene reuse.
|
- Codebooks are per-scene and rebuilt from scratch; no inter-scene reuse.
|
||||||
- `_paint` is a Python per-block loop — fine for prototyping, slow for a full
|
- `_paint` is a Python per-block loop — fine for prototyping, slow for a full
|
||||||
disc encode. Vectorise before the 224-stream run.
|
disc encode. Vectorise before the 224-stream run.
|
||||||
@@ -177,7 +202,7 @@ 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 — VERIFIED (session 3). CPU path — still unproven.
|
## Display path — VERIFIED (session 3), in a real mode (session 4). CPU path — still unproven.
|
||||||
|
|
||||||
The first real frame is on screen: `docs/images/x68k_first_frame_compare.png`.
|
The first real frame is on screen: `docs/images/x68k_first_frame_compare.png`.
|
||||||
|
|
||||||
@@ -228,19 +253,19 @@ SDL_VIDEODRIVER=dummy mame x68000 -bios ipl10 -video soft -window \
|
|||||||
**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.
|
||||||
**Now unblocked** — the display path is verified (FINDINGS 22) and
|
**Fully unblocked** — the display path is verified (FINDINGS 22) AND the
|
||||||
`tools/bench/show_frame.lua` gives a known-good reference image to diff the
|
target CRTC mode is now real (FINDINGS 23), so 68000 code has a defined
|
||||||
68000's output against. Validates the 38% full-frame blit estimate that the
|
geometry to write into: 256 words per row, 1024-byte line stride, picture in
|
||||||
whole CPU budget rests on. Still needs a real CRTC mode table for 256x256;
|
rows 32..223 of a 256-row page. `tools/bench/show_frame256.lua` gives a
|
||||||
the harness deliberately borrows the IPL's timing and invents nothing.
|
known-good reference image to diff the 68000's output against. This is what
|
||||||
2a. **CRTC mode table for 256x192-in-256x256.** Prerequisite for (2) and the
|
validates the 38% full-frame blit estimate the whole CPU budget rests on.
|
||||||
smallest well-defined unit of work available right now. Needs real R00-R08
|
**This is now the top priority** — it is the only remaining unknown that can
|
||||||
timing values. **Do not write these from memory** — session 3 lost time to
|
still invalidate the design.
|
||||||
exactly that failure mode on the video registers. Derive them from the CRTC
|
2a. ~~CRTC mode table for 256x192-in-256x256.~~ **DONE, session 4.** Derived from
|
||||||
dividers in `x68k_crtc.cpp` (`m_reg[20] & 0x1f` selects the dot-clock
|
the CRTC divisor ladder (not recalled), verified by snapshot, pixel-exact.
|
||||||
divisor; the IPL's `0x16` gives /2 off the 69MHz clock), or lift a known-good
|
`tools/bench/crtc_mode.lua`; write-up in FINDINGS 23; regression test
|
||||||
set from a real X68000 title and verify by snapshot. The harness makes this
|
`tools/bench/verify_frame256.py`. Untested on real hardware, but the blanking
|
||||||
cheap to iterate: change values, snapshot, look.
|
timing is identical to the IPL's 768 mode, which is what a monitor cares about.
|
||||||
|
|
||||||
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
|
||||||
@@ -268,14 +293,24 @@ SDL_VIDEODRIVER=dummy mame x68000 -bios ipl10 -video soft -window \
|
|||||||
## Not yet started
|
## Not yet started
|
||||||
- **Any 68000 player code.** `src/player/` is still empty. The display path is
|
- **Any 68000 player code.** `src/player/` is still empty. The display path is
|
||||||
proven, but proven *from Lua* — no 68000 instruction has yet drawn a pixel.
|
proven, but proven *from Lua* — no 68000 instruction has yet drawn a pixel.
|
||||||
- **A real CRTC mode table.** The harness deliberately borrows the IPL's 768x512
|
|
||||||
text timing and invents no CRTC values, which is why the frame repeats at
|
|
||||||
x=512 (FINDINGS 22.5). A 256x256 mode needs real R00-R08 values, and those
|
|
||||||
must be derived or measured, NOT recalled from memory — see the note below.
|
|
||||||
- ADPCM audio extraction/encoding
|
- ADPCM audio extraction/encoding
|
||||||
- Disk image packaging
|
- Disk image packaging
|
||||||
- Game logic (scene branching, input windows, death clips)
|
- Game logic (scene branching, input windows, death clips)
|
||||||
|
|
||||||
|
## Reproducing the 256x256 mode result (session 4)
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 tools/encoder/extract.py 00020 tmp/fr_00020 12 crop
|
||||||
|
python3 tools/bench/prep_frame.py tmp/fr_00020 tmp/frame256.bin 0 --reserve-black
|
||||||
|
mkdir -p tmp/snap256 && cd tmp && SDL_VIDEODRIVER=dummy timeout -k 5 90 mame x68000 \
|
||||||
|
-bios ipl10 -video soft -window -sound none -nothrottle -plugins \
|
||||||
|
-autoboot_script ../tools/bench/show_frame256.lua \
|
||||||
|
-snapshot_directory ./snap256 -snapview native -seconds_to_run 6
|
||||||
|
cd .. && python3 tools/bench/verify_frame256.py
|
||||||
|
```
|
||||||
|
Exits non-zero on any drift. Expected: `256x512 native, double-scan exact,
|
||||||
|
active 256x192 pixel-exact, letterbox true black`, ceiling 40.81 dB.
|
||||||
|
|
||||||
## Reproducing the display result
|
## Reproducing the display result
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
@@ -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
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
src, out = sys.argv[1], sys.argv[2]
|
argv = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||||
f = sorted(glob.glob(f"{src}/*.png"))[int(sys.argv[3]) if len(sys.argv) > 3 else 0]
|
# --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")
|
im = Image.open(f).convert("RGB")
|
||||||
W, H = im.size
|
W, H = im.size
|
||||||
|
|
||||||
q = im.quantize(colors=256, method=Image.MEDIANCUT, dither=Image.NONE)
|
n = 255 if RESERVE else 256
|
||||||
pal = np.array(q.getpalette()[:256*3], dtype=np.uint8).reshape(256, 3)
|
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)
|
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:
|
with open(out, "wb") as fh:
|
||||||
fh.write(b"DLXR")
|
fh.write(b"DLXR")
|
||||||
@@ -26,4 +36,5 @@ with open(out, "wb") as fh:
|
|||||||
|
|
||||||
# reference PNG of exactly what the X68000 should display
|
# reference PNG of exactly what the X68000 should display
|
||||||
Image.fromarray(pal[idx]).save(out.replace(".bin", "_ref.png"))
|
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