Remove render throttle and add smart blink dirty tracking

Data now hits the screen immediately when it arrives — no artificial
delay. The render throttle (RenderTickMs, FLastRenderTick) is removed
entirely. ParseData and EndUpdate call FlipToScreen unconditionally;
callers control batching via BeginUpdate/EndUpdate.

Blink toggle no longer calls DirtyAll. New DirtyBlinkRows method only
marks the cursor row and rows containing blink cells, reducing blink
overhead from ~63ms (25 rows) to ~3ms (1-3 rows) on a 486. Cursor
ghost handling in FlipToScreen dirties the old cursor row when the
cursor moves between rows.

Constant mini-frame values (Stride, CellH, PixSeg, GlyphSeg) are
pushed once before the column loop instead of per-cell, saving 320
push instructions per row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Duensing 2026-02-28 18:55:28 -06:00
parent c5d31ca930
commit b6f08a3150

View file

@ -9,8 +9,9 @@ unit KPAnsi;
{ } { }
{ Rendering uses a font atlas with a nibble lookup table and inline ASM } { Rendering uses a font atlas with a nibble lookup table and inline ASM }
{ to expand glyph bitmaps directly into a reusable 8bpp DIB pixel buffer. } { to expand glyph bitmaps directly into a reusable 8bpp DIB pixel buffer. }
{ This eliminates per-pixel branching and 32-bit arithmetic from the inner } { Constant mini-frame values are hoisted outside the column loop to reduce }
{ loop, with one SetDIBitsToDevice call per dirty row. } { per-cell overhead. Smart blink tracking dirties only cursor and blink }
{ rows instead of the entire screen, eliminating wasteful full repaints. }
{ } { }
{ Installs to the "KP" palette tab alongside TKPComm. } { Installs to the "KP" palette tab alongside TKPComm. }
@ -68,13 +69,13 @@ type
FRows: Integer; FRows: Integer;
FScrollbackSize: Integer; FScrollbackSize: Integer;
FCursorVisible: Boolean; FCursorVisible: Boolean;
FLastCursorRow: Integer;
FOnKeyData: TKeyDataEvent; FOnKeyData: TKeyDataEvent;
FPaintFont: HFont; FPaintFont: HFont;
FStockFont: Boolean; FStockFont: Boolean;
FBlinkCount: Integer; FBlinkCount: Integer;
FUpdateCount: Integer; FUpdateCount: Integer;
FPendingScroll: Integer; FPendingScroll: Integer;
FLastRenderTick: Longint;
FDirtyRow: array[0..255] of Boolean; FDirtyRow: array[0..255] of Boolean;
FAllDirty: Boolean; FAllDirty: Boolean;
FTextBlinkOn: Boolean; FTextBlinkOn: Boolean;
@ -96,6 +97,7 @@ type
procedure DeleteLines(N: Integer); procedure DeleteLines(N: Integer);
procedure DestroyRowBuffers; procedure DestroyRowBuffers;
procedure DirtyAll; procedure DirtyAll;
procedure DirtyBlinkRows;
procedure DirtyRow(Row: Integer); procedure DirtyRow(Row: Integer);
procedure DoScrollDown; procedure DoScrollDown;
procedure DoScrollUp; procedure DoScrollUp;
@ -178,9 +180,9 @@ const
$00FFFFFF { 15 White (bright) } $00FFFFFF { 15 White (bright) }
); );
{ Timer fires at ~18 Hz (Win16 tick resolution is ~55 ms). } { Blink timer interval. Win16 minimum is ~55 ms (18.2 Hz tick). }
{ Cursor and text blink toggle every 9 ticks (~500 ms). } BlinkTimerMs = 55;
RenderTickMs = 55; { Cursor and text blink toggle every 9 timer ticks (~500 ms). }
BlinkInterval = 9; BlinkInterval = 9;
{ OUT_RASTER_PRECIS may not be defined in Delphi 1.0 WinTypes } { OUT_RASTER_PRECIS may not be defined in Delphi 1.0 WinTypes }
@ -444,6 +446,7 @@ begin
FScrollback := TList.Create; FScrollback := TList.Create;
FCursorRow := 0; FCursorRow := 0;
FCursorCol := 0; FCursorCol := 0;
FLastCursorRow := 0;
FSaveCurRow := 0; FSaveCurRow := 0;
FSaveCurCol := 0; FSaveCurCol := 0;
FAttrFG := 7; FAttrFG := 7;
@ -465,7 +468,6 @@ begin
FBlinkCount := 0; FBlinkCount := 0;
FUpdateCount := 0; FUpdateCount := 0;
FPendingScroll := 0; FPendingScroll := 0;
FLastRenderTick := 0;
FAllDirty := True; FAllDirty := True;
FTextBlinkOn := True; FTextBlinkOn := True;
FRowBufSize := 0; FRowBufSize := 0;
@ -667,6 +669,44 @@ begin
end; end;
procedure TKPAnsi.DirtyBlinkRows;
{ Targeted dirty marking for blink toggle. Instead of DirtyAll (which }
{ forces a full 25-row re-render and 25 SetDIBitsToDevice calls), only }
{ dirty the cursor row (cursor blink) and rows containing blink cells }
{ (text blink). Typical BBS content has 0-3 blink rows, so this reduces }
{ blink overhead from ~63ms to ~3ms on a 486. }
var
I: Integer;
J: Integer;
Line: PTermLine;
begin
{ In scrollback view, FlipToScreen sets FAllDirty anyway }
if FAllDirty or (FScrollPos <> 0) then
Exit;
{ Dirty cursor row for cursor blink }
if FCursorVisible and (FCursorRow >= 0) and (FCursorRow < FRows) then
FDirtyRow[FCursorRow] := True;
{ Dirty rows containing blink cells for text blink }
for I := 0 to FRows - 1 do
begin
if not FDirtyRow[I] and (I < FScreen.Count) then
begin
Line := FScreen[I];
for J := 0 to FCols - 1 do
begin
if Line^.Cells[J].Blink then
begin
FDirtyRow[I] := True;
Break;
end;
end;
end;
end;
end;
procedure TKPAnsi.DirtyRow(Row: Integer); procedure TKPAnsi.DirtyRow(Row: Integer);
begin begin
if (Row >= 0) and (Row <= 255) then if (Row >= 0) and (Row <= 255) then
@ -730,24 +770,14 @@ end;
procedure TKPAnsi.EndUpdate; procedure TKPAnsi.EndUpdate;
var
Now: Longint;
begin begin
Dec(FUpdateCount); Dec(FUpdateCount);
if FUpdateCount <= 0 then if FUpdateCount <= 0 then
begin begin
FUpdateCount := 0; FUpdateCount := 0;
{ Render immediately if enough time has elapsed. This avoids }
{ depending on WM_TIMER, which is starved by continuous }
{ WM_COMMNOTIFY messages during high-speed data reception. }
Now := GetTickCount;
if Now - FLastRenderTick >= RenderTickMs then
begin
FLastRenderTick := Now;
FlipToScreen; FlipToScreen;
end; end;
end; end;
end;
procedure TKPAnsi.EraseDisplay(Mode: Integer); procedure TKPAnsi.EraseDisplay(Mode: Integer);
@ -1233,6 +1263,16 @@ begin
end; end;
FPendingScroll := 0; FPendingScroll := 0;
{ Dirty old cursor row to erase ghost when cursor moved between rows }
if FCursorRow <> FLastCursorRow then
begin
if (FLastCursorRow >= 0) and (FLastCursorRow <= 255) then
FDirtyRow[FLastCursorRow] := True;
if (FCursorRow >= 0) and (FCursorRow <= 255) then
FDirtyRow[FCursorRow] := True;
FLastCursorRow := FCursorRow;
end;
{ Interleaved render + blast: single buffer is reused per row } { Interleaved render + blast: single buffer is reused per row }
DC := GetDC(Handle); DC := GetDC(Handle);
for Row := 0 to FRows - 1 do for Row := 0 to FRows - 1 do
@ -1486,15 +1526,12 @@ begin
{ Reset cursor blink to visible on new data } { Reset cursor blink to visible on new data }
FBlinkOn := True; FBlinkOn := True;
{ Render immediately -- no throttle. Data hits the screen as soon }
{ as it arrives. BeginUpdate/EndUpdate suppresses intermediate }
{ renders when the caller is batching multiple Write calls. }
if FUpdateCount = 0 then if FUpdateCount = 0 then
begin
if GetTickCount - FLastRenderTick >= RenderTickMs then
begin
FLastRenderTick := GetTickCount;
FlipToScreen; FlipToScreen;
end; end;
end;
end;
procedure TKPAnsi.ParseSGR; procedure TKPAnsi.ParseSGR;
@ -1784,7 +1821,7 @@ begin
{ Start render/blink timer } { Start render/blink timer }
if not FTimerActive then if not FTimerActive then
begin begin
SetTimer(Handle, 1, RenderTickMs, nil); SetTimer(Handle, 1, BlinkTimerMs, nil);
FTimerActive := True; FTimerActive := True;
end; end;
@ -1877,6 +1914,20 @@ begin
FNibbleFG := 255; FNibbleFG := 255;
FNibbleBG := 255; FNibbleBG := 255;
{ Push constant mini-frame values ONCE before the column loop. }
{ These 4 values (Stride, CellH, PixSeg, GlyphSeg) don't change }
{ across cells. Only per-cell values (GlyphOfs, PixOfs) are pushed }
{ inside the loop. This saves 320 push instructions per row (4 pushes }
{ x 80 cells). SP is 8 bytes below Delphi's expectation until the }
{ matching ADD SP,8 at the end, but local variable access uses BP, }
{ not SP, so this is safe. }
asm
push Stride
push CellH
push PixSeg
push GlyphSeg
end;
for Col := 0 to FCols - 1 do for Col := 0 to FCols - 1 do
begin begin
{ Determine effective colors } { Determine effective colors }
@ -1912,26 +1963,20 @@ begin
PixOfs := Word(CellH - 1) * Stride + Word(Col) * 8; PixOfs := Word(CellH - 1) * Stride + Word(Col) * 8;
asm asm
{ Push all values to explicit mini-frame BEFORE any register } { Push only per-cell values. Constants already on stack above. }
{ clobber. BASM reads register variables from SI/DI correctly }
{ here since nothing has been overwritten yet. }
push Stride
push CellH
push PixSeg
push GlyphSeg
push PixOfs push PixOfs
push GlyphOfs push GlyphOfs
push bp push bp
mov bp, sp mov bp, sp
{ Mini-frame layout (all accessed via SS:[BP+n]): } { Mini-frame layout (same offsets as before): }
{ [bp] = saved original BP } { [bp] = saved original BP }
{ [bp+2] = GlyphOfs } { [bp+2] = GlyphOfs (pushed this cell) }
{ [bp+4] = PixOfs } { [bp+4] = PixOfs (pushed this cell) }
{ [bp+6] = GlyphSeg } { [bp+6] = GlyphSeg (pushed once before loop) }
{ [bp+8] = PixSeg } { [bp+8] = PixSeg (pushed once before loop) }
{ [bp+10] = CellH } { [bp+10] = CellH (pushed once before loop) }
{ [bp+12] = Stride } { [bp+12] = Stride (pushed once before loop) }
push ds push ds
push bx push bx
@ -1980,12 +2025,13 @@ begin
pop bx pop bx
pop ds pop ds
pop bp pop bp
add sp, 12 { remove 6 mini-frame words } add sp, 4 { remove per-cell GlyphOfs + PixOfs only }
end; end;
end; end;
{ Cursor overlay: if cursor is on this row and visible, re-render the } { Cursor overlay: if cursor is on this row and visible, re-render the }
{ cursor cell with swapped FG/BG using the same ASM inner loop. } { cursor cell with swapped FG/BG using the same ASM inner loop. }
{ Constants are still on the stack from above -- reused here. }
if FCursorVisible and FBlinkOn and (FScrollPos = 0) and if FCursorVisible and FBlinkOn and (FScrollPos = 0) and
(Row = FCursorRow) and (FCursorCol >= 0) and (FCursorCol < FCols) then (Row = FCursorRow) and (FCursorCol >= 0) and (FCursorCol < FCols) then
begin begin
@ -2014,10 +2060,6 @@ begin
PixOfs := Word(CellH - 1) * Stride + Word(FCursorCol) * 8; PixOfs := Word(CellH - 1) * Stride + Word(FCursorCol) * 8;
asm asm
push Stride
push CellH
push PixSeg
push GlyphSeg
push PixOfs push PixOfs
push GlyphOfs push GlyphOfs
@ -2069,9 +2111,14 @@ begin
pop bx pop bx
pop ds pop ds
pop bp pop bp
add sp, 12 add sp, 4
end; end;
end; end;
{ Remove constant mini-frame words pushed before the column loop }
asm
add sp, 8
end;
end; end;
@ -2219,14 +2266,11 @@ begin
FBlinkCount := 0; FBlinkCount := 0;
FBlinkOn := not FBlinkOn; FBlinkOn := not FBlinkOn;
FTextBlinkOn := not FTextBlinkOn; FTextBlinkOn := not FTextBlinkOn;
DirtyAll; DirtyBlinkRows;
end; end;
{ Render pending dirty rows and/or update blink state. This also } { Render blink changes and any stale dirty rows }
{ catches any data that arrived just before a throttle boundary }
{ in EndUpdate (worst-case latency: one timer tick, ~55 ms). }
FlipToScreen; FlipToScreen;
FLastRenderTick := GetTickCount;
end; end;