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 }
{ to expand glyph bitmaps directly into a reusable 8bpp DIB pixel buffer. }
{ This eliminates per-pixel branching and 32-bit arithmetic from the inner }
{ loop, with one SetDIBitsToDevice call per dirty row. }
{ Constant mini-frame values are hoisted outside the column loop to reduce }
{ 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. }
@ -68,13 +69,13 @@ type
FRows: Integer;
FScrollbackSize: Integer;
FCursorVisible: Boolean;
FLastCursorRow: Integer;
FOnKeyData: TKeyDataEvent;
FPaintFont: HFont;
FStockFont: Boolean;
FBlinkCount: Integer;
FUpdateCount: Integer;
FPendingScroll: Integer;
FLastRenderTick: Longint;
FDirtyRow: array[0..255] of Boolean;
FAllDirty: Boolean;
FTextBlinkOn: Boolean;
@ -96,6 +97,7 @@ type
procedure DeleteLines(N: Integer);
procedure DestroyRowBuffers;
procedure DirtyAll;
procedure DirtyBlinkRows;
procedure DirtyRow(Row: Integer);
procedure DoScrollDown;
procedure DoScrollUp;
@ -178,9 +180,9 @@ const
$00FFFFFF { 15 White (bright) }
);
{ Timer fires at ~18 Hz (Win16 tick resolution is ~55 ms). }
{ Cursor and text blink toggle every 9 ticks (~500 ms). }
RenderTickMs = 55;
{ Blink timer interval. Win16 minimum is ~55 ms (18.2 Hz tick). }
BlinkTimerMs = 55;
{ Cursor and text blink toggle every 9 timer ticks (~500 ms). }
BlinkInterval = 9;
{ OUT_RASTER_PRECIS may not be defined in Delphi 1.0 WinTypes }
@ -444,6 +446,7 @@ begin
FScrollback := TList.Create;
FCursorRow := 0;
FCursorCol := 0;
FLastCursorRow := 0;
FSaveCurRow := 0;
FSaveCurCol := 0;
FAttrFG := 7;
@ -465,7 +468,6 @@ begin
FBlinkCount := 0;
FUpdateCount := 0;
FPendingScroll := 0;
FLastRenderTick := 0;
FAllDirty := True;
FTextBlinkOn := True;
FRowBufSize := 0;
@ -667,6 +669,44 @@ begin
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);
begin
if (Row >= 0) and (Row <= 255) then
@ -730,22 +770,12 @@ end;
procedure TKPAnsi.EndUpdate;
var
Now: Longint;
begin
Dec(FUpdateCount);
if FUpdateCount <= 0 then
begin
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;
end;
FlipToScreen;
end;
end;
@ -1233,6 +1263,16 @@ begin
end;
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 }
DC := GetDC(Handle);
for Row := 0 to FRows - 1 do
@ -1486,14 +1526,11 @@ begin
{ Reset cursor blink to visible on new data }
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
begin
if GetTickCount - FLastRenderTick >= RenderTickMs then
begin
FLastRenderTick := GetTickCount;
FlipToScreen;
end;
end;
FlipToScreen;
end;
@ -1784,7 +1821,7 @@ begin
{ Start render/blink timer }
if not FTimerActive then
begin
SetTimer(Handle, 1, RenderTickMs, nil);
SetTimer(Handle, 1, BlinkTimerMs, nil);
FTimerActive := True;
end;
@ -1877,6 +1914,20 @@ begin
FNibbleFG := 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
begin
{ Determine effective colors }
@ -1912,26 +1963,20 @@ begin
PixOfs := Word(CellH - 1) * Stride + Word(Col) * 8;
asm
{ Push all values to explicit mini-frame BEFORE any register }
{ 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 only per-cell values. Constants already on stack above. }
push PixOfs
push GlyphOfs
push bp
mov bp, sp
{ Mini-frame layout (all accessed via SS:[BP+n]): }
{ Mini-frame layout (same offsets as before): }
{ [bp] = saved original BP }
{ [bp+2] = GlyphOfs }
{ [bp+4] = PixOfs }
{ [bp+6] = GlyphSeg }
{ [bp+8] = PixSeg }
{ [bp+10] = CellH }
{ [bp+12] = Stride }
{ [bp+2] = GlyphOfs (pushed this cell) }
{ [bp+4] = PixOfs (pushed this cell) }
{ [bp+6] = GlyphSeg (pushed once before loop) }
{ [bp+8] = PixSeg (pushed once before loop) }
{ [bp+10] = CellH (pushed once before loop) }
{ [bp+12] = Stride (pushed once before loop) }
push ds
push bx
@ -1980,12 +2025,13 @@ begin
pop bx
pop ds
pop bp
add sp, 12 { remove 6 mini-frame words }
add sp, 4 { remove per-cell GlyphOfs + PixOfs only }
end;
end;
{ 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. }
{ Constants are still on the stack from above -- reused here. }
if FCursorVisible and FBlinkOn and (FScrollPos = 0) and
(Row = FCursorRow) and (FCursorCol >= 0) and (FCursorCol < FCols) then
begin
@ -2014,10 +2060,6 @@ begin
PixOfs := Word(CellH - 1) * Stride + Word(FCursorCol) * 8;
asm
push Stride
push CellH
push PixSeg
push GlyphSeg
push PixOfs
push GlyphOfs
@ -2069,9 +2111,14 @@ begin
pop bx
pop ds
pop bp
add sp, 12
add sp, 4
end;
end;
{ Remove constant mini-frame words pushed before the column loop }
asm
add sp, 8
end;
end;
@ -2219,14 +2266,11 @@ begin
FBlinkCount := 0;
FBlinkOn := not FBlinkOn;
FTextBlinkOn := not FTextBlinkOn;
DirtyAll;
DirtyBlinkRows;
end;
{ Render pending dirty rows and/or update blink state. This also }
{ catches any data that arrived just before a throttle boundary }
{ in EndUpdate (worst-case latency: one timer tick, ~55 ms). }
{ Render blink changes and any stale dirty rows }
FlipToScreen;
FLastRenderTick := GetTickCount;
end;