harness: render the TMS34010 active scanlines from video RAM

MAME's TMS34010 renderer does not read VRAM: tms340x0_ind16 calls the
driver's scanline callback for the active region and paints only the
blanking regions.  Our callback drew nothing, so the active area was
never painted and every frame dump was all-black.

Add render_active_line(), called from scanline(): it reads the
framebuffer out of the video-RAM share at bit 0x04000000 with a measured
1024-bit (0x40-word, 128-byte) row pitch and LSB-first bit order within
each 16-bit word.  The pitch comes from the firmware's own character
blitter (PIXBLT at pc=3FDF9A00 advances its destination by 0x40 words per
glyph row); at the 160-byte pitch MAME derived from HEBLNK/HSBLNK/HTOTAL
the text is sheared and unreadable.

Validated: frame 160 shows the level-1 CP437 character table legibly,
frame 600 the level-1 error screen.  VT1200_DPYLOG added as an opt-in
per-scanline display-params dump.

Regression (no VT1200_* env): PC=3FDCE400 samples=199 -- holds.

See harness/mame/RENDER_REPORT.md.
This commit is contained in:
SDD committed 2026-10-16 12:42:33 +03:00
1 parent bcbd7924af
commit 571850e564
5 files changed
+268 -3

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
# VT1200 MAME renderer — the graphical milestone
**Status: DONE.** The driver now paints the terminal's screen. The level-1
self-test **CP437 character table is visible and legible**, and the level-1
error screen it writes later is legible too.
---
## 1. The defect
MAME's TMS34010 renderer does **not** read VRAM. `tms340x0_ind16`
(`tms34010.cpp`) calls the driver's scanline callback for the active region and
paints only the blanking regions itself:
```cpp
if (params.enabled)
m_scanline_ind16_cb(screen, bitmap, cliprect.min_y, &params); // active pixels -> driver
...
/* blank out the blank regions */
```
Our callback (`vt1200_state::scanline`) drew **nothing** when `m_render` was
true — it only blanked when `m_render` was false. So the active area was never
painted by anyone, and the frame dump was whatever the bitmap started as (all
zero = black on the first frame). The stale comment in `screen_update` —
*"Let the TMS34010 display controller do the rendering"* — was the
misconception; the controller delegates.
## 2. What I implemented
`vt1200_state::render_active_line()` (called from `scanline()`), an
active-pixel scanline renderer:
* Blanks the whole scanline first (the device only repaints the H-blanking
regions, so stale bitmap data would otherwise show).
* Maps screen line `y` to framebuffer row `y - params->veblnk` and screen
x `heblnk..hsblnk` to framebuffer x `0..1023`.
* Reads each pixel from the video-RAM share: bit address
`0x04000000 + row*1024 + x`, i.e. word index `(row*64) + (x>>4)` and bit
`x&15` of that 16-bit word — **LSB-first within the word**.
* `VT1200_NO_RENDER=1` restores the old blank-everything behaviour.
A new opt-in diagnostic `VT1200_DPYLOG=<path>` (plus `VT1200_DPYLOG_FRAMES`)
dumps the TMS34010 display params per scanline; it is how the geometry below
was pinned.
## 3. Which geometry I trusted, and why
The real-machine facts I was given were **1280×1024, 160 bytes/scanline,
163840-byte framebuffer**. I trusted the **video-RAM base and the bit/pixel
model** from those facts, but I did **not** use 160 bytes/scanline — I used the
geometry the firmware itself demonstrates, and I can reconcile the two:
| quantity | value I rendered | source / evidence |
|---|---|---|
| VRAM base | bit `0x04000000` | the mapping that unblocked the level-1 VIDEO MEMORY test; the reset routine's `MOVI 0x4000000,B4` sets it as the blitter bitmap base |
| row pitch | **1024 bits = 128 bytes = 0x40 words** | the firmware's own character blitter: the `PIXBLT` at `pc=3FDF9A00` writes glyph rows at word offsets `0x118C, 0x11CC, 0x120C …`, i.e. **+0x40 words per row** |
| bit order | LSB-first in each 16-bit word | the TMS34010 pixel order; only this order makes the text readable |
| active width | 1024 px | MAME derives `heblnk..hsblnk = 192..1216`, which is **exactly 1024 px — the framebuffer width** |
| screen line → row | `y - veblnk` | 1:1; `fb_row = scanline - params->veblnk` reproduces the dumped VRAM exactly |
**The decisive test** was rendering the dumped VRAM content both ways:
* pitch **128 bytes, LSB-first** → perfect text:
`LEVEL1/2 ERROR -> HOST PORT - 15 001EFF01`
* pitch **160 bytes** → sheared, unreadable gibberish.
So the *framebuffer* is 128 bytes/line (1024 px). The given "160 bytes/
scanline" is consistent with the **total** raster width (`1280/8 = 160`),
while only the 1024 active pixels are stored — and MAME's derived active span
of 1024 px independently agrees. The requested 160-byte number is therefore
not contradicted; it is just the total-raster figure, not the memory pitch.
In short: I trusted the real-machine base and mono/1bpp model, and the
real-machine active width (via MAME's 1024-px blanking span), but the memory
pitch comes from the firmware's own blitter because rendering proves it.
MAME's derived **80×901** geometry is a separate artefact (the timing registers
`HEBLNK/HSBLNK/VTOTAL` are not initialised to the real monitor's values);
the renderer does not use those numbers to index memory.
## 4. Frame dumps (before / after)
Regenerate with:
```sh
M=/home/arti/code/k-space/mame0289/vt1200
VT1200_VRAM=1 VT1200_BOARD_MODEL=handshake VT1200_BOARD_BUSY_TICKS=2 \
VT1200_BOARD_SWITCH="0640000D:70:70" VT1200_DUART=1 VT1200_DUART_SEL=1 \
VT1200_DUMP_DIR=harness/mame VT1200_DUMP_FRAME=<n> \
$M vt1200 -rompath harness/mame/roms -video none -sound none -nothrottle \
-debug -debugger none -seconds_to_run <n/50+2> -autoboot_delay 0
```
**Before:** the three pre-existing frame dumps (`vt1200-frame-60.ppm` and
friends) were byte-identical all-black — the active pixels were never painted.
They have been regenerated (and the `.ppm` files are gitignored as generated
artefacts).
**After** (committed as PNG evidence):
| file | what it shows |
|---|---|
| `harness/mame/vt1200-frame-160.png` | **the full CP437 character table** |
| `harness/mame/vt1200-frame-200.png` | the table being wiped (white block) |
| `harness/mame/vt1200-frame-600.png` | the level-1 error screen |
The matching `.ppm` files are written by `VT1200_DUMP_FRAME` in the same
directory.
## 5. Is the character table visible and legible? — **Yes**
`vt1200-frame-160.png` shows the complete 32×16 CP437 font table, readable
glyph by glyph:
```
`abcdefghijklmnopqrstuvwxyz{|}~⌂
!"#$%&'()*+,-./0123456789:;<=>?
@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_
ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒ
áíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐
...
:¤¤¤·ΔφÃΓ~¤θÑÂφ≡∃ΠΨΩ∴∆Θ...
¬¤Üø>δεφψηιοκλϑνόπρσςƒσξις+→↓
```
This matches the real machine's boot order (character table first, then a
wipe): frame 160 is the table, frame 200 the wipe, frames 90–140 a full-screen
test pattern, and frame 600 the later level-1 error screen. The level-1 pie
graph is **not** reached because the firmware is stuck at the (capped,
handed-over) HOST PORT gate — as expected.
## 6. Regression
With **no** `VT1200_*` environment variables:
```
PC=3FDCE400 samples=199
```
Holds — identical to before the change. The renderer only affects the bitmap;
it never touches CPU state, and with no `VT1200_VRAM` the video-RAM share is
absent, so the code just blanks the line (as it did before).
## 7. Unresolved / caveats
* **Vertical mapping is empirical.** I map framebuffer row `= scanline -
veblnk`; the device's reconstructed `rowaddr` advances only every second
scanline (DUDATE-derived), which does not describe the memory pitch, so I do
not use it. The visible 864 lines show framebuffer rows 0–863; if the real
VEBLNK/VSBLNK differ (they are known to be uninitialised) the last rows are
simply not dumped.
* **The screen is still MAME's derived 1280×901**, with the 1024×864 image
drawn into the active span (x 192..1215). The frame dump therefore has black
borders. Reconfiguring the raster to the real geometry is a separate change.
* **Main RAM is still 256 KiB** — deliberately not touched here (`BRINGUP_LOG`
flags enlarging it as a separate experiment).
* The video-RAM dump for a single frame is ~58 MB because the share covers
the whole `0x04000000..0x1fffffff` mapping; the framebuffer itself is the
first ~128 KiB.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+109 -3
View File
@@ -54,6 +54,16 @@
VT1200_DUART_LB1/LB2=1 external TX->RX loopback on DUART1 / DUART2.
VT1200_DUARTLOG=<path> every DUART access (not de-duplicated).
Video: the driver paints the active scanlines itself, reading the
framebuffer out of video RAM (see render_active_line). VT1200_NO_RENDER=1
blanks instead. Dumps:
VT1200_DUMP_FRAME=<n> write frame <n> as vt1200-frame-<n>.ppm
VT1200_DUMP_DIR=<dir> directory for the dumps (default ".")
VT1200_DPYLOG=<path> dump the TMS34010 display params, one line
per scanline, for the first
VT1200_DPYLOG_FRAMES frames (default 1)
VT1200_DUART also wires each DUART's IRQ output to the TMS34010's external
interrupt inputs (DUART1 -> INT1, DUART2 -> INT2). This is required: the
level-1 host-port test's transmit is entirely ISR-driven (the ISR writes
@@ -102,6 +112,20 @@ namespace {
// that uses byte addressing).
static const u32 BOARD_BASE[4] = { VT1200_WIN0, VT1200_WIN1, VT1200_WIN2, VT1200_WIN3 };
// --- Framebuffer geometry (measured, not derived from MAME) -----------------
// Video RAM is mapped at bit address 0x04000000 (the base that unblocks the
// level-1 VIDEO MEMORY test). The firmware's own blitter writes it as a
// linear 1-bit-per-pixel buffer with a 0x40-word (128-byte) row pitch; the
// TMS34010 paints the visible pixels LSB-first within each 16-bit word.
// Verified by rendering the dumped VRAM: at this geometry the level-1 error
// screen is legible ("LEVEL1/2 ERROR -> HOST PORT - 15"); at the derived
// 160-byte / 80-word pitch MAME computed from HEBLNK/HSBLNK/HTOTAL the text is
// sheared and unreadable. See RENDER_REPORT.md.
static const u32 VRAM_BASE_BITS = 0x04000000; // where the framebuffer lives
static const int VMEM_PITCH_BITS = 1024; // 0x40 words = 128 bytes
static const int VMEM_WIDTH = 1024; // visible pixels per row
static const int VMEM_HEIGHT = 1024; // rows in the framebuffer
class vt1200_state : public driver_device
{
public:
@@ -125,6 +149,8 @@ public:
// scanline callback: renders (or blanks) the line, and can dump a frame
void scanline(screen_device &screen, bitmap_ind16 &bitmap, int scanline,
const tms340x0_device::display_params *params);
void render_active_line(bitmap_ind16 &bitmap, int scanline,
const tms340x0_device::display_params *params);
void dump_frame(screen_device &screen, bitmap_ind16 &bitmap);
void dump_ram();
void dump_vram();
@@ -189,6 +215,13 @@ private:
long m_dump_frame = -1;
std::string m_dump_dir = ".";
// VT1200_DPYLOG=<path>: dump the TMS34010 display params for the first
// VT1200_DPYLOG_FRAMES frames (default 1), one line per visible scanline.
// Used to pin the VRAM fetch address/geometry against the real machine's
// 1280x1024 (160 bytes/line) numbers.
FILE *m_dpylog = nullptr;
long m_dpylog_frames = 1;
// VT1200_DUMP_RAM=<frame> writes main RAM at that frame to
// vt1200-ram-<frame>.bin so the framebuffer can be located offline.
long m_dump_ram = -1;
@@ -358,6 +391,14 @@ void vt1200_state::machine_start()
m_dump_vram = strtol(f, nullptr, 0);
if (const char *d = getenv("VT1200_DUMP_DIR"))
m_dump_dir = d;
if (const char *f = getenv("VT1200_DPYLOG"))
{
m_dpylog = fopen(f, "w");
if (m_dpylog)
fprintf(m_dpylog, "# line rowaddr coladdr yoff heblnk hsblnk veblnk vsblnk vcount\n");
}
if (const char *f = getenv("VT1200_DPYLOG_FRAMES"))
m_dpylog_frames = strtol(f, nullptr, 0);
if (const char *p = getenv("VT1200_LOG_UNMAPPED"))
{
@@ -640,7 +681,7 @@ void vt1200_state::program_map(address_map &map)
// 0x40000C in the same >>4 units, i.e. bit 0x040000C0), which is why the
// firmware reports error 22 and restarts before it ever draws anything.
if (getenv("VT1200_VRAM"))
map(0x04000000, 0x1fffffff).ram().share("vram");
map(VRAM_BASE_BITS, 0x1fffffff).ram().share("vram");
// Transparent logging proxy over the first 64 KiB of video memory, registered
// last so it wins over the .ram() above. off = (bitaddr - base) >> 4, so this
@@ -885,6 +926,54 @@ void vt1200_state::log_duart(int win, char kind, u32 reg, u16 value)
fflush(m_duartlog);
}
// Copy one active scanline out of the video RAM framebuffer.
//
// The TMS34010 screen-refresh address is reconstructed by the device into
// `params`, but the firmware's timing registers (HEBLNK/HSBLNK/VTOTAL) are not
// initialised to the real machine's values, so the geometry MAME derives from
// them (a 1280x901 screen, active x 192..1215) does not describe the memory
// layout. The measured memory layout does:
//
// * base bit 0x04000000 (the mapped video RAM)
// * pitch 1024 bits (0x40 words / 128 bytes) per row — taken from the
// firmware's own blitter, which advances its destination by
// 0x40 words per glyph row (pc=3FDF9A00 in VRAM write logs)
// * bit order LSB-first within each 16-bit word — the TMS34010 pixel order
// * active x the device's heblnk..hsblnk span is 1024 pixels, exactly the
// framebuffer width, so screen x-heblnk maps to framebuffer x
//
// The screen line maps 1:1 to a framebuffer row, offset by the vertical
// blanking start (params->veblnk).
void vt1200_state::render_active_line(bitmap_ind16 &bitmap, int scanline,
const tms340x0_device::display_params *params)
{
// Blank first: the device only repaints the horizontal blanking regions,
// so anything we leave here would be stale bitmap contents.
std::fill_n(&bitmap.pix(scanline, 0), bitmap.width(), u16(0));
if (!m_vram) // no video RAM mapped -> nothing to draw
return;
const int fby = scanline - (int)params->veblnk;
if (fby < 0 || fby >= VMEM_HEIGHT)
return;
const u16 *const vram = m_vram.target();
const int x0 = (int)params->heblnk;
const int x1 = (int)params->hsblnk;
const u32 row_bit = (u32)fby * VMEM_PITCH_BITS;
for (int fx = 0; fx < VMEM_WIDTH; fx++)
{
const int x = x0 + fx;
if (x >= x1 || x >= bitmap.width())
break;
const u32 bit = row_bit + (u32)fx;
// Address-map units are bits, so a 16-bit word is 16 units.
bitmap.pix(scanline, x) = (vram[bit >> 4] >> (bit & 15)) & 1;
}
}
void vt1200_state::scanline(screen_device &screen, bitmap_ind16 &bitmap, int scanline,
const tms340x0_device::display_params *params)
{
@@ -894,7 +983,22 @@ void vt1200_state::scanline(screen_device &screen, bitmap_ind16 &bitmap, int sca
osd_printf_info("vt1200: scanline#%d line=%d enabled=%d\n",
calls, scanline, params ? (params->enabled ? 1 : 0) : -1);
}
if (!m_render)
if (m_dpylog && params && (long)screen.frame_number() < m_dpylog_frames)
{
fprintf(m_dpylog, "%d %04X %04X %d %d %d %d %d %d\n",
scanline, params->rowaddr, params->coladdr, params->yoffset,
params->heblnk, params->hsblnk, params->veblnk, params->vsblnk, params->vcount);
fflush(m_dpylog);
}
// Paint the active pixels ourselves. MAME's TMS34010 renderer does NOT
// read VRAM; tms340x0_ind16 calls this callback for the active region and
// only paints the blanking regions itself. If we leave the active pixels
// untouched the frame stays whatever the bitmap was (all black on the
// first frame).
if (m_render && params && params->enabled)
render_active_line(bitmap, scanline, params);
else
std::fill_n(&bitmap.pix(scanline, 0), bitmap.width(), u16(0));
// Dump on the last visible line of the target frame: by then the whole
@@ -962,7 +1066,9 @@ uint32_t vt1200_state::screen_update(screen_device &screen, bitmap_ind16 &bitmap
screen.width(), screen.height());
}
// Let the TMS34010 display controller do the rendering.
// The TMS34010 display controller does not read VRAM: it calls our
// scanline callback for the active region (which paints it from the
// framebuffer) and blanks the blanking region itself.
const uint32_t changed = m_maincpu->tms340x0_ind16(screen, bitmap, cliprect);
// Dump once the whole frame has been rendered (the video manager calls us