mdview2: горизонтальный скролл по типу блока + фиксы fence/HR/SDCC

Горизонтальный скролл (Фаза 5, финал):
- Скроллим по ТИПУ, а не по длине: новый флаг IF_HSCROLL ставится только
  на код и таблицы; HR и границы fence (IF_NOWRAP без IF_HSCROLL) не
  двигаются. Блок едет целиком, включая строки короче 80.
- Обход бага кодогенерации SDCC z80: `if (n!=g) g=n;` пишет (n-g) вместо n
  (SUB сравнения затирает A, store переиспользует испорченный A). Лечится
  записью viewport_x ДО сравнения. Минимальный репродьюсер и оба описания
  для трекера — в docs/bugs/sdcc-z80-cmp-store-a/ (воспроизводится на чистом
  sdcc 4.5, в т.ч. с --no-peep → это кодогенератор, не peephole).

Рендеринг:
- Отступленный fence (```c внутри списка) теперь распознаётся: is_fence_raw
  пропускает ведущие пробелы/табы; то же в рендере прячет строку-границу.
- Строки-разделители (HR, ровно 80) больше не участвуют в скролле.

Чистка: удалён мёртвый код (is_fence_delim, get_init_style[_raw], is_cont,
seg_flags). Makefile (mdview/mdview2): iconv UTF-8→CP866 завершается ненулевым
кодом при отбрасывании символов — игнорируем (|| true).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 10:11:27 +03:00
parent eab5a2d6ac
commit 138e783e72
11 changed files with 1214 additions and 495 deletions
+127
View File
@@ -0,0 +1,127 @@
# [z80] `global = local` after `if (local != global)` stores `local - global` (A clobbered by comparison)
## Summary
On the z80 backend, the sequence
```c
if (n != g) { g = n; }
```
where `n` is in register `A` and `g` is a global, miscompiles. The compiler
evaluates the `!=` comparison with `SUB A,(HL)`, which destroys `A`, and then
emits the assignment `g = n` by storing `A` — which now holds `n - g`, not `n`.
As a result the global ends up holding `(unsigned char)(n - old_g)` instead of `n`.
It should either use `CP (HL)` (which leaves `A` intact) for the comparison, or
reload `n` before the store.
## Version
SDCC 4.5.0 #15242 (Mac OS X x86_64). Default options; also reproduces with
`--opt-code-speed` and with `--no-peep` (so this is a code-generator bug, not a
peephole-optimizer bug).
## Minimal reproducer
```c
unsigned char vx;
void update(unsigned char n)
{
if (n != vx) {
vx = n;
}
}
```
Build:
```
sdcc -mz80 -S repro.c
```
## Generated assembly (wrong)
```asm
_update::
;repro.c: if (n != vx) {
ld hl, #_vx
sub a, (hl) ; A (= n) is destroyed: A = n - vx
ret Z
;repro.c: vx = n;
ld (_vx+0), a ; stores (n - vx) instead of n
;repro.c: }
ret
```
With `--no-peep` the same defect is present (only the branch shape differs):
```asm
_update::
ld iy, #_vx
sub a, 0 (iy) ; A (= n) destroyed
jp NZ, 00112$
jp 00103$
00112$:
ld (_vx+0), a ; stores (n - vx)
00103$:
ret
```
## Why it happens
`n` arrives in `A` (sdcccall). The code generator picks `SUB A,(HL)` to evaluate
the relational `n != vx`. `SUB` overwrites `A` with the difference. The generator
then treats the still-live value `n` as if it were still in `A` and emits a bare
store `LD (_vx),A` for the assignment, without reloading `n` first. Because the
defect survives `--no-peep`, it is in code generation (register/lifetime tracking
across the comparison), not in the peephole optimizer.
The correct lowering for the comparison is `CP (HL)`, which sets the flags exactly
like `SUB` but preserves `A`, so the subsequent store would be correct with no
extra instructions.
## Variants that also reproduce
- `if (n == vx) return; vx = n;` (early-return form)
- `n` coming from a function call result instead of a parameter
- both `-mz80` default and `--opt-code-speed`
## Workaround
Store into the global *before* the comparison, so the destructive `SUB` is no
longer on the store path; compare a saved copy instead:
```c
void update(unsigned char n)
{
unsigned char old = vx;
vx = n; /* store first, A still holds n */
if (n != old) {
/* side effect */
}
}
```
generates the correct:
```asm
_update::
ld (_vx+0), a
ret
```
## Files in this directory
- `repro.c` — minimal reproducer
- `repro.asm` — generated output, default options (defect visible)
- `repro.nopeep.asm` — generated output with `--no-peep` (defect still present)
- `workaround.c` / `workaround.asm` — store-before-compare workaround (correct)
## Tracker search
A search of the SDCC bug tracker did not turn up an exact duplicate. The closest
version-matching report, #3834 "[Z80][SDCC 4.5] Compiler bug", is a *different*
defect (`genPointerSet`, swapped push/pop order), not this comparison-clobbers-A
case.
+127
View File
@@ -0,0 +1,127 @@
# [z80] `global = local` после `if (local != global)` пишет `local - global` (A затирается сравнением)
## Кратко
На бэкенде z80 последовательность
```c
if (n != g) { g = n; }
```
где `n` лежит в регистре `A`, а `g` — глобальная переменная, компилируется
неверно. Сравнение `!=` вычисляется через `SUB A,(HL)`, который **разрушает `A`**,
после чего присваивание `g = n` выполняется записью `A`а там уже `n - g`, а не
`n`. В итоге в глобальную переменную попадает `(unsigned char)(n - старое_g)`
вместо `n`.
Корректно было бы использовать для сравнения `CP (HL)` (он не меняет `A`) либо
перезагрузить `n` перед записью.
## Версия
SDCC 4.5.0 #15242 (Mac OS X x86_64). Опции по умолчанию; также воспроизводится с
`--opt-code-speed` и с `--no-peep` (то есть это баг кодогенератора, а не
peephole-оптимизатора).
## Минимальный пример
```c
unsigned char vx;
void update(unsigned char n)
{
if (n != vx) {
vx = n;
}
}
```
Сборка:
```
sdcc -mz80 -S repro.c
```
## Сгенерированный ассемблер (неверный)
```asm
_update::
;repro.c: if (n != vx) {
ld hl, #_vx
sub a, (hl) ; A (= n) разрушается: A = n - vx
ret Z
;repro.c: vx = n;
ld (_vx+0), a ; пишет (n - vx) вместо n
;repro.c: }
ret
```
С `--no-peep` дефект тот же (отличается лишь форма ветвления):
```asm
_update::
ld iy, #_vx
sub a, 0 (iy) ; A (= n) разрушен
jp NZ, 00112$
jp 00103$
00112$:
ld (_vx+0), a ; пишет (n - vx)
00103$:
ret
```
## Почему так происходит
`n` приходит в `A` (sdcccall). Кодогенератор выбирает `SUB A,(HL)` для вычисления
отношения `n != vx`. `SUB` перезаписывает `A` разностью. Далее генератор считает,
что ещё «живое» значение `n` по-прежнему в `A`, и для присваивания выдаёт голую
запись `LD (_vx),A`, не перезагрузив `n`. Поскольку дефект сохраняется при
`--no-peep`, он находится в кодогенерации (учёт регистров/времён жизни через
сравнение), а не в peephole-оптимизаторе.
Правильное преобразование сравнения — `CP (HL)`: он выставляет флаги ровно как
`SUB`, но сохраняет `A`, поэтому последующая запись была бы корректной без единой
лишней инструкции.
## Варианты, которые тоже воспроизводят
- `if (n == vx) return; vx = n;` (форма с ранним выходом)
- `n` как результат вызова функции, а не как параметр
- и `-mz80` по умолчанию, и `--opt-code-speed`
## Обходной путь (workaround)
Записывать в глобальную переменную **до** сравнения, чтобы разрушающий `SUB` не
оказался на пути записи; сравнивать сохранённую копию:
```c
void update(unsigned char n)
{
unsigned char old = vx;
vx = n; /* запись первой, A ещё держит n */
if (n != old) {
/* побочный эффект */
}
}
```
даёт корректное:
```asm
_update::
ld (_vx+0), a
ret
```
## Файлы в этом каталоге
- `repro.c` — минимальный пример
- `repro.asm` — вывод с опциями по умолчанию (дефект виден)
- `repro.nopeep.asm` — вывод с `--no-peep` (дефект сохраняется)
- `workaround.c` / `workaround.asm` — обход «запись до сравнения» (корректно)
## Поиск по трекеру
Поиск по баг-трекеру SDCC точного дубликата не нашёл. Ближайший по версии отчёт
#3834 «[Z80][SDCC 4.5] Compiler bug» — это **другой** дефект (`genPointerSet`,
переставленный порядок push/pop), а не данный случай «сравнение затирает A».
+61
View File
@@ -0,0 +1,61 @@
;--------------------------------------------------------
; File Created by SDCC : free open source ISO C Compiler
; Version 4.5.0 #15242 (Mac OS X x86_64)
;--------------------------------------------------------
.module repro
.optsdcc -mz80 sdcccall(1)
;--------------------------------------------------------
; Public variables in this module
;--------------------------------------------------------
.globl _update
.globl _vx
;--------------------------------------------------------
; special function registers
;--------------------------------------------------------
;--------------------------------------------------------
; ram data
;--------------------------------------------------------
.area _DATA
_vx::
.ds 1
;--------------------------------------------------------
; ram data
;--------------------------------------------------------
.area _INITIALIZED
;--------------------------------------------------------
; absolute external ram data
;--------------------------------------------------------
.area _DABS (ABS)
;--------------------------------------------------------
; global & static initialisations
;--------------------------------------------------------
.area _HOME
.area _GSINIT
.area _GSFINAL
.area _GSINIT
;--------------------------------------------------------
; Home
;--------------------------------------------------------
.area _HOME
.area _HOME
;--------------------------------------------------------
; code
;--------------------------------------------------------
.area _CODE
;repro.c:20: void update(unsigned char n)
; ---------------------------------
; Function update
; ---------------------------------
_update::
;repro.c:22: if (n != vx) {
ld hl, #_vx
sub a, (hl)
ret Z
;repro.c:23: vx = n;
ld (_vx+0), a
;repro.c:25: }
ret
.area _CODE
.area _INITIALIZER
.area _CABS (ABS)
+25
View File
@@ -0,0 +1,25 @@
/*
* Minimal reproducer — SDCC z80 codegen bug.
*
* "global = local" right after "if (local != global)" stores a WRONG value:
* the SUB used to evaluate the comparison clobbers register A (which still
* holds `n`), and the store reuses that clobbered A.
*
* Build (stock SDCC, no extra flags, no custom toolchain):
* sdcc -mz80 -S repro.c
*
* Tested with: SDCC 4.5.0 #15242 (Mac OS X x86_64), default options.
* Also reproduces with --opt-code-speed and with --no-peep
* (so it is a code-generator bug, not a peephole-optimizer bug).
*
* Expected semantics: after update(n), vx == n whenever n != vx.
* Actual result: vx == (unsigned char)(n - old_vx).
*/
unsigned char vx;
void update(unsigned char n)
{
if (n != vx) {
vx = n;
}
}
@@ -0,0 +1,64 @@
;--------------------------------------------------------
; File Created by SDCC : free open source ISO C Compiler
; Version 4.5.0 #15242 (Mac OS X x86_64)
;--------------------------------------------------------
.module repro
.optsdcc -mz80 sdcccall(1)
;--------------------------------------------------------
; Public variables in this module
;--------------------------------------------------------
.globl _update
.globl _vx
;--------------------------------------------------------
; special function registers
;--------------------------------------------------------
;--------------------------------------------------------
; ram data
;--------------------------------------------------------
.area _DATA
_vx::
.ds 1
;--------------------------------------------------------
; ram data
;--------------------------------------------------------
.area _INITIALIZED
;--------------------------------------------------------
; absolute external ram data
;--------------------------------------------------------
.area _DABS (ABS)
;--------------------------------------------------------
; global & static initialisations
;--------------------------------------------------------
.area _HOME
.area _GSINIT
.area _GSFINAL
.area _GSINIT
;--------------------------------------------------------
; Home
;--------------------------------------------------------
.area _HOME
.area _HOME
;--------------------------------------------------------
; code
;--------------------------------------------------------
.area _CODE
;repro.c:20: void update(unsigned char n)
; ---------------------------------
; Function update
; ---------------------------------
_update::
;repro.c:22: if (n != vx) {
ld iy, #_vx
sub a, 0 (iy)
jp NZ, 00112$
jp 00103$
00112$:
;repro.c:23: vx = n;
ld (_vx+0), a
00103$:
;repro.c:25: }
ret
.area _CODE
.area _INITIALIZER
.area _CABS (ABS)
@@ -0,0 +1,57 @@
;--------------------------------------------------------
; File Created by SDCC : free open source ISO C Compiler
; Version 4.5.0 #15242 (Mac OS X x86_64)
;--------------------------------------------------------
.module workaround
.optsdcc -mz80 sdcccall(1)
;--------------------------------------------------------
; Public variables in this module
;--------------------------------------------------------
.globl _update
.globl _vx
;--------------------------------------------------------
; special function registers
;--------------------------------------------------------
;--------------------------------------------------------
; ram data
;--------------------------------------------------------
.area _DATA
_vx::
.ds 1
;--------------------------------------------------------
; ram data
;--------------------------------------------------------
.area _INITIALIZED
;--------------------------------------------------------
; absolute external ram data
;--------------------------------------------------------
.area _DABS (ABS)
;--------------------------------------------------------
; global & static initialisations
;--------------------------------------------------------
.area _HOME
.area _GSINIT
.area _GSFINAL
.area _GSINIT
;--------------------------------------------------------
; Home
;--------------------------------------------------------
.area _HOME
.area _HOME
;--------------------------------------------------------
; code
;--------------------------------------------------------
.area _CODE
;workaround.c:4: void update(unsigned char n)
; ---------------------------------
; Function update
; ---------------------------------
_update::
ld (_vx+0), a
;workaround.c:8: if (n != old) {
;workaround.c:11: }
ret
.area _CODE
.area _INITIALIZER
.area _CABS (ABS)
@@ -0,0 +1,11 @@
/* Workaround: store BEFORE the comparison so the SUB that evaluates the
* comparison no longer sits on the store path. Generates correct code. */
unsigned char vx;
void update(unsigned char n)
{
unsigned char old = vx;
vx = n; /* store first, with A still holding n */
if (n != old) {
/* redraw / side effect */
}
}
+6 -1
View File
@@ -17,6 +17,11 @@ include $(PROJ_ROOT)/app.mk
# его в CP866, отбрасывая символы без аналога в целевой кодировке. # его в CP866, отбрасывая символы без аналога в целевой кодировке.
# Результат кладётся в .disk_tmp/README.MD, чтобы make_disk.py # Результат кладётся в .disk_tmp/README.MD, чтобы make_disk.py
# использовал правильное имя файла на диске. # использовал правильное имя файла на диске.
#
# iconv -c возвращает ненулевой код, если хоть один символ отброшен
# (даже с -c) — это ОЖИДАЕМО при потере символов без аналога в CP866,
# не ошибка конвертации; вывод при этом всё равно корректно записан.
# Поэтому код возврата iconv игнорируется (|| true).
# ------------------------------------------------------------------ # ------------------------------------------------------------------
DISK_TMP := .disk_tmp DISK_TMP := .disk_tmp
README_DISK := $(DISK_TMP)/README.MD README_DISK := $(DISK_TMP)/README.MD
@@ -25,7 +30,7 @@ $(DISK_TMP):
mkdir -p $@ mkdir -p $@
$(README_DISK): README.MD | $(DISK_TMP) $(README_DISK): README.MD | $(DISK_TMP)
iconv -c -f UTF-8 -t CP866 README.MD > $@ iconv -c -f UTF-8 -t CP866 README.MD > $@ || true
floppy: $(EXAMPLE).exe $(README_DISK) floppy: $(EXAMPLE).exe $(README_DISK)
python3 $(MAKE_DISK) $(FLOPPY_IMG) $(EXAMPLE).exe $(README_DISK) python3 $(MAKE_DISK) $(FLOPPY_IMG) $(EXAMPLE).exe $(README_DISK)
+6 -1
View File
@@ -20,6 +20,11 @@ include $(PROJ_ROOT)/app.mk
# его в CP866, отбрасывая символы без аналога в целевой кодировке. # его в CP866, отбрасывая символы без аналога в целевой кодировке.
# Результат кладётся в .disk_tmp/README.MD, чтобы make_disk.py # Результат кладётся в .disk_tmp/README.MD, чтобы make_disk.py
# использовал правильное имя файла на диске. # использовал правильное имя файла на диске.
#
# iconv -c возвращает ненулевой код, если хоть один символ отброшен
# (даже с -c) — это ОЖИДАЕМО при потере символов без аналога в CP866,
# не ошибка конвертации; вывод при этом всё равно корректно записан.
# Поэтому код возврата iconv игнорируется (|| true).
# ------------------------------------------------------------------ # ------------------------------------------------------------------
DISK_TMP := .disk_tmp DISK_TMP := .disk_tmp
README_DISK := $(DISK_TMP)/README.MD README_DISK := $(DISK_TMP)/README.MD
@@ -28,7 +33,7 @@ $(DISK_TMP):
mkdir -p $@ mkdir -p $@
$(README_DISK): README.MD | $(DISK_TMP) $(README_DISK): README.MD | $(DISK_TMP)
iconv -c -f UTF-8 -t CP866 README.MD > $@ iconv -c -f UTF-8 -t CP866 README.MD > $@ || true
floppy: $(EXAMPLE).exe $(README_DISK) floppy: $(EXAMPLE).exe $(README_DISK)
python3 $(MAKE_DISK) $(FLOPPY_IMG) $(EXAMPLE).exe $(README_DISK) python3 $(MAKE_DISK) $(FLOPPY_IMG) $(EXAMPLE).exe $(README_DISK)
+665
View File
@@ -131,3 +131,668 @@ int main(void) {
--- ---
*MDView v0.2 · (c) 2026 Петров А.Г.* *MDView v0.2 · (c) 2026 Петров А.Г.*
---
# Sprinter C Compiler — v1.0
C toolchain for **Sprinter** — the Z80-based home computer by Peters Plus, running
ESTEX DSS. Host: macOS / Linux. Target: `.EXE` files in SprintEXE format.
Built on top of **SDCC 4.5** (vendored in `third_party/sdcc/`). This repository adds
everything Sprinter-specific: crt0, linker integration, libc wrappers over ESTEX,
banked-call trampolines, graphics & accelerator API, mouse driver wrappers, and the
`mkexe` utility for producing SprintEXE images.
## What you get
* **`bin/sprinter-cc`** — one-line driver: `sprinter-cc -o foo.exe foo.c`
* **Memory modes**: `tiny`, `small`, `big`, `huge`, `manual` — see below.
* **stdio + conio**: printf, puts, putchar, getchar, fopen/fread/..., cprintf, cputs, putch, textcolor/textbackground/textattr, gotoxy, kbhit/getch.
* **Graphics**: 320×256×256 and 640×256×16 modes, accelerator-backed primitives (hline / vline / rect / fill_rect / line via Bresenham, plus clear), bitmap-font text in both modes via BIOS character generator.
* **File I/O**: POSIX (`open`/`read`/`write`/`close`/`lseek`/`unlink`/`creat`), FILE\* streams (`fopen`/`fgets`/`fwrite`/...), directory listing (`ffirst`/`fnext`), `chdir`/`getcwd`/`mkdir`/`rmdir`, `stat`/`fstat`.
* **Memory**: 32 KB heap (W2-resident), banking-aware page allocator (`mem_alloc_pages`/`bank_read`/`bank_write`), explicit memory modes for sub-16 KB programs.
* **Mouse**: full Sprinter driver wrapper (14 functions including custom cursor bitmaps).
* **Environment**: `getenv`/`putenv`/`sysenv` over ESTEX `$46`.
* **Time**: `getdatetime`/`setdatetime` + POSIX `time`/`localtime`/`mktime`/`asctime`/`ctime`.
* **Misc**: `errno`/`strerror`/`perror`, `atexit`, `setjmp`/`longjmp`, `sleep`, full argv parsing in crt0.
## Quick start
```sh
git clone <this repo> sprinter-c
cd sprinter-c
make sdcc # one-time: fetch SDCC 4.5 binary (~25 MB)
make all # build mkexe + libsprinter.lib + 27 examples
make floppy # pack everything into mame/v306/IMG/mc.img
cd mame/v306 && ./run_mame.sh # boot Sprinter in MAME
```
Compile a single program:
```sh
cat > hello.c <<EOF
#include <stdio.h>
int main(void) { puts("Hello, Sprinter!"); return 0; }
EOF
bin/sprinter-cc -o hello.exe hello.c
```
That's it — `hello.exe` is now a valid SprintEXE you can `RUN HELLO` from the ESTEX shell.
## Memory modes
Sprinter's address space is four 16 KB windows (W0 / W1 / W2 / W3). DSS allocates
pages by program size — small programs get only one page. Pick a memory mode based
on what your program needs:
| Mode | Code lives in | Banking | Use when | Note |
|---|---|---|---|---|
| `tiny` (default) | W2 (0x8100+) | no | code+data < 14 KB | |
| `small` | W1-W2 (0x4100+) | no | code+data < 30 KB | |
| `big` | W2 + W1 banking | yes (W1) | tiny + extra code modules | |
| `huge` | W1-W2 + W3 banking | yes (W3) | small + extra code modules | |
| `manual` | user-specified | optional | special layouts | Not implemented |
```sh
sprinter-cc --memory small -o big.exe bigprog.c
sprinter-cc --memory huge -o app.exe main.c --bank 1=engine.c --bank 2=ai.c
```
Banked functions are declared with `__banked`:
```c
void engine_tick(int dt) __banked; // lives in BANK1, automatically swapped
```
## Examples (27 total)
| Example | What it demonstrates |
|---|---|
| `hello` | Hello world with stdio + conio Turbo-C-style colors |
| `argv` | argv parsing in crt0 |
| `cat` | File I/O — read & print TEST.TXT |
| `seek` | 32-bit lseek over a 100 KB file |
| `ls` | Directory listing via ffirst/fnext |
| `filetest` | FILE\* streams (fopen/fread/...) |
| `stattest` | `stat`/`fstat` on files and directories |
| `errno` | errno / strerror / perror |
| `mem_test` | Page allocator + bank\_read/bank\_write |
| `malloc` | Heap stress test (200+ allocations) |
| `banked` | Banked code in W3 (huge mode) |
| `bankedbg` | Banked code in W1 (big mode) |
| `banklocl` | Bank-local static data and BSS |
| `mouse` | Mouse driver in text mode |
| `gfx_mous` | Mouse with custom bitmap cursor in graphics mode |
| `gfx_demo` | 320×256×256 lines / rects / accelerator |
| `gfx_d16` | 640×256×16 same primitives |
| `gfx_text` | Bitmap-font text on graphics screen |
| `timedir` | Date/time + directory listing |
| `ptime` | POSIX time API |
| `openenv` | open() flags + environment vars |
| `conio` | conio API smoke test |
| `attrprob` | Probe Sprinter text attribute byte layout |
| `strtest` | string.h test (from SDCC's z80.lib) |
| `stdlib` | stdlib.h test (qsort / rand / strtol / etc.) |
| `assrtest` | assert() |
| `rt_test` | Runtime helpers (sleep, setjmp, atexit) |
## Headers
Standard:
* `<stdio.h>` — puts / printf / FILE\* + Sprinter-specific dec/hex helpers
* `<stdlib.h>` — atoi / atof / malloc / qsort / ... (from SDCC z80.lib)
* `<string.h>` / `<ctype.h>` / `<math.h>` — from SDCC z80.lib
* `<unistd.h>` — read / write / close / lseek / unlink
* `<fcntl.h>` — open / creat + O\_RDONLY / O\_CREAT / ...
* `<errno.h>` — errno + error names + strerror
* `<sys/stat.h>` — stat / fstat
* `<setjmp.h>` / `<assert.h>` — from SDCC
Sprinter-specific:
* `<conio.h>` — putch / cputs / cprintf, textcolor / textbackground / textattr, kbhit / getch, clrscr, gotoxy, wherex/y
* `<gfx.h>` — gfx_init/done, palette, putpixel, hline/vline/rect/fill_rect/line, text — for both 320 and 640 modes (gfx_\*16 variants)
* `<mouse.h>` — full 14-function driver wrapper + mouse_cursor_t with bitmap support
* `<dir.h>` — chdir / getcwd / mkdir / rmdir / ffirst / fnext / ffblk
* `<time.h>` — getdatetime / setdatetime + POSIX time / localtime / etc.
* `<sprinter.h>` — raw ports, ESTEX/BIOS function numbers, env API
* `<sprinter_exit.h>` — exit / \_exit / atexit
* `<sprinter_mem.h>` — mem\_alloc\_pages / mem\_free\_block / bank\_read / bank\_write
* `<sprinter_compat.h>` — Solid-C compatibility layer (aliases + BOOL/WORD/uint types)
## Toolchain commands
```sh
make all # build mkexe + lib + every example
make floppy # repack mame/v306/IMG/mc.img with all .exe files
make check # 17 mkexe unit-tests
make clean # remove all build artefacts
make sdcc # one-time: fetch SDCC 4.5 binary
```
### sprinter-cc options
```
sprinter-cc -o foo.exe foo.c [more.c ...] [options]
--memory MODE tiny | small | big | huge | manual (default: tiny)
--memory-manual SPEC explicit placement (CODE=W1|W2,DATA=W1|W2|SAME,BANKED=W1|W3)
--stack-size N bytes reserved for the stack (default ~1278)
--crt0=TYPE default | minimal | banked | small
--bank N=FILE.c compile FILE.c into bank N (repeatable, max 15)
--debug enable runtime diagnostics (defines DEBUG_RT)
-I PATH extra include path
-L 0xADDR / -E / -S override load / entry / stack addresses
-Wl FLAG pass FLAG to sdldz80
--mkexe FLAG pass FLAG to mkexe (e.g. --mkexe -p --mkexe 0 for bank padding)
-v verbose
```
## Status
What works in v1.0:
* Compile / link / pack to SprintEXE — verified on all 27 examples
* Four memory modes (tiny / small / big / huge)
* Graphics (both modes) with accelerator
* Mouse (text + graphics cursor)
* File I/O, directories, environment, time
* All headers listed above
Deferred to v2.0 (see `docs/TODO.md`):
* **Turbo-C-style BGI graphics API** — `initgraph` / `setcolor` / `circle` /
`getimage` / `putimage` / etc. on top of our `gfx_*` primitives
* Remaining Solid-C compatibility gaps (Phase 2/3) — see `docs/solid_c_compatibility.md`
* Manual memory mode
* Rewrite FILE\* stream API (current implementation is very primitive and doesn't use buffers)
Deferred to v3.0:
* **IM2 interrupt handlers** — research complete (`docs/im2_isr_design.md`),
implementation scheduled for v3
* **Audio API** (AY-3-8910 + COVOX) — requires IM2
* **ISA-8 slot drivers** — requires IM2 (???)
## Documentation
* `docs/TODO.md` — roadmap and open work items
* `docs/solid_c_compatibility.md` — gap analysis vs Solid-C 2004
* `docs/im2_isr_design.md` — interrupt handler design (v2)
* `docs/converted/` — source documentation (ESTEX, BIOS, architecture)
converted to plain text for `grep`
* `docs/reference/`, `docs/samples/`, `docs/memory management/` — original
Russian docs and code samples from Peters Plus
## Repository layout
```
bin/sprinter-cc one-line compiler driver (bash)
toolchain/mkexe/ host-side tool: .ihx -> .exe SprintEXE
toolchain/check_banks.py post-link bank size enforcer
runtime/ crt0 variants (default, minimal, small, banked)
bank trampolines, heap, heap_top
libc/include/ headers
libc/io|stdio|mem|gfx/ C and asm sources for libsprinter.lib
lib/ Makefile that archives libsprinter.lib via sdar
examples/ 27 example programs
mame/v306/ MAME binary + Sprinter ROM/HDD images + floppy script
third_party/sdcc/ vendored SDCC 4.5 (fetched via `make sdcc`)
third_party/solid-c/ reference: original Sprinter native C (for compat target)
docs/ documentation
```
## License
This repository contains:
* Original code in `bin/`, `toolchain/`, `runtime/`, `libc/`, `lib/`, `examples/`
MIT-licensed.
* `third_party/sdcc/` — SDCC 4.5 under GPLv2 with linking exception
(see `third_party/sdcc/COPYING.txt`)
* `third_party/solid-c/` — original Sprinter Solid C, used only as a reference
## Credits
* **Sprinter / Peters Plus** — Иван Мак, Дмитрий Паринов and the original team
* **SDCC** — for the underlying Z80 compiler
* **MAME** — for the Sprinter Sp2000 emulation
---
For questions / patches: see CONTRIBUTING.md (TBD) or open an issue.
---
# TODO / Roadmap
Открытые задачи в порядке убывания приоритета. По мере появления реальных программ — приоритеты будут смещаться.
## Этап 5 — malloc / free + banking-aware page allocator ✅ ГОТОВО
- [x] SDCC's `malloc`/`free` + наш `runtime/heap.s` (полностью заменяет library heap.rel, 14000-байтный heap в окне 2)
- [x] `libc/mem/mem_alloc.c` — page allocator: `mem_alloc_pages`/`mem_free_block`/`mem_get_page`/`mem_info` через ESTEX `$3C/$3D/$3E` + BIOS `$C4`
- [x] `libc/mem/bank_io.c` — HOME-резидентные `bank_read`/`bank_write`/`bank_load_byte`/`bank_store_byte` со свопом W3 внутри
- [x] `examples/malloc_test/` — проверка SDCC's malloc (~210 64-байтных allocations через всю heap)
- [x] `examples/mem_test/` — проверка page allocator: 3 страницы, разные паттерны через bank_write, верификация через bank_read
## Этап 6 — argv parsing + sprinter-cc wrapper ✅ ГОТОВО
- [x] crt0 парсит ESTEX command-line из IX-prefix (inline asm в `runtime/crt0.s`)
- [x] Strip leading CP/M-style space (DSS quirk)
- [x] Передача `argc`/`argv` в main() через HL/DE (SDCC __sdcccall(1) ABI)
- [x] argv[0] = basename .EXE через ESTEX APPINFO ($47 subfn 2)
- [x] `runtime/crt0_minimal.s` — opt-out для очень маленьких программ
- [x] `runtime/crt0_banked.s` — теперь тоже парсит argv (parse_argv + get_progname скопированы из crt0.s; будет factored в argv.s когда возьмёмся за libsprinter.lib)
- [x] Bash-обёртка `bin/sprinter-cc`: `sprinter-cc -o foo.exe foo.c` одной строкой
- [x] Поддержка опций: `--memory`, `--memory-manual`, `--stack-size`, `--crt0=`, `--bank N=FILE.c`, `--debug`, `-I`, `-L`/`-E`/`-S`, `-Wl`, `--mkexe`
## Этап 8 — графика (320×256×256 + 640×256×16 + accel + bitmap font) ✅ ГОТОВО
- [x] **8a** Graphics core: `gfx_init`/`gfx_done`/`gfx_clear`/`gfx_putpixel`/`gfx_pal_load`/`gfx_pal_set` (libc/gfx/gfx_core.c). Палитра через BIOS PIC_SET_PAL ($A4). Verified 320×256×256.
- [x] **8b** Линии/прямоугольники/fill через accelerator (libc/gfx/gfx_lines.c): `gfx_hline`/`gfx_vline` через accel Fill (LD C,C / LD E,E + SMC block-size), `gfx_rect`/`gfx_fill_rect` с heuristic выбором ориентации (h/v bursts count), `gfx_line` с Bresenham для диагоналей. `gfx_clear` тоже переписан на column-major accel (~4× быстрее).
- [x] **8c** 640×256×16 mode (libc/gfx/gfx_16.c): `gfx_*16` API, HIGH nibble = LEFT pixel (документация misleading), per-row RMW для vline (один байт = 2 горизонтальных пикселя).
- [x] **8d** Bitmap font + gfx_text (libc/gfx/gfx_text.c): шрифт через BIOS WIN_GET_ZG ($B8), interleaved layout `font[row*256+char]`, `gfx_text`/`gfx_putchar` для 320 mode, `gfx_text16`/`gfx_putchar16` для 640 mode с pair-table lookup.
См. memory/sprinter_graphics.md, sprinter_accelerator.md, sprinter_graphics_16.md, sprinter_font_format.md.
Открытые мелочи (не блокируют):
- [ ] Шрифт-quad для 640: per-cell палитра (mode 0x82 разрешает 1 из 4 палитр per 16×8 cell) — через прямой доступ к area-описания экрана 0x0300..0x039F
## Auto-banking (см. `memory/banking_roadmap.md` для деталей)
Phase 1 — file-level bin-packing — реализовывать когда проект перерастёт ~30 KB кода.
- [ ] `toolchain/auto_bank.py`:
- Парсит размеры из `.rel`-файлов (или из .map после dry-run link'а)
- First-fit-decreasing bin-packing
- Уважает `#pragma codeseg BANKn` как manual override
- Перелинковывает с новыми `-Wl-b_BANKn=...` параметрами
- Печатает план распределения
Phase 2-5: incremental rebalance, declarative `banks.toml`, function-level, call-graph-aware. Только если/когда понадобится.
## Bank-local static data (mutable data в том же банке что и код) — ✅ ГОТОВО
- [x] Пример `examples/bank_local_data/` — функция в BANK1 со своим writable BSS array + const table + malloc-тест
- [x] `mkexe -p 0` для нулевого padding банков (BSS-storage обнуляется при загрузке)
- [x] Канонический рецепт: `--codeseg BANK1 --constseg BANK1 --dataseg BANK1` для bank1.c + `-Wl-b_BANK1=0x1C000` для линковки. **`--dataseg BANK1` РАБОТАЕТ** — раньше казалось обратное из-за trampoline bug который маскировал результат.
- [x] **Критичный фикс trampoline'a в runtime/bank.s** — старый `pop af; out (n), a` клобберил A → все banked-функции возвращающие uint8_t тихо возвращали мусор. Новый `pop bc; out (c), b` сохраняет A.
- [x] **malloc из banked-функции работает прозрачно** — heap живёт в W2 (HOME), W2 никогда не свапается trampoline'ом, pointer валиден из любого контекста. См. memory/bank_local_data_pattern.md.
- [x] Документация в memory: `memory/bank_local_data_pattern.md` (полный рецепт + malloc + nuances), `memory/sdcc_banking.md` (trampoline fix)
- [ ] Опционально — расширить `check_banks.py` чтобы показывать разбивку size = code + const + bss per bank (cosmetic)
Зачем: для модулей с большим private state (level loader, audio engine, scene data). Экономит W2 heap для динамики, а статика остаётся в бэке.
## Подсказки из solid-c (нативный Sprinter C — `third_party/solid-c/`)
После анализа solid-c'овской libc (см. `memory/solid_c_findings.md`) выявлены готовые паттерны для следующих недостающих функций. Приоритет от **высокого** к низкому:
### High-priority gaps (легко портировать, большая польза)
- [x] **`errno` + `strerror`/`perror`** — табличка 32 ошибок (libc/io/errno.c)
- [x] **Расширенный `open()`** для O_CREAT/O_TRUNC/O_APPEND/O_EXCL state machine
- [x] **`atexit`** — 8-callback LIFO + `exit()` + `_exit()` (libc/io/atexit.c)
- [x] **`setjmp`/`longjmp`** — 6-байт jmp_buf={sp,ix,pc} (libc/io/setjmp.c)
- [x] **`sleep(seconds)`** — 50Hz halt-loop (libc/io/sleep.c)
- [x] **ESTEX ENV API** ($46, getenv/putenv) — libc/io/env.c. Учли doc-bug: реально A=0 это NOT FOUND
### Medium-priority (нужно для shell-like утилит)
- [ ] **Mouse driver**`rst $30h`, 17 функций. **Сначала тест что работает в MAME**.
- [x] **`ffirst`/`fnext` + ffblk_t struct** для directory listing — реализовано, demo: ls.exe
- [x] **`getdatetime`/`setdatetime`** через ESTEX $21/$22 — libc/io/time.c, demo: time_dir_test
- [x] **`chdir`/`getcwd`/`mkdir`/`rmdir`** — wrappers для ESTEX $1B-$1E — libc/io/fsdir.c
- [x] **conio: `kbhit`/`getch`/`getche`/`cputs`/`clrscr`/`gotoxy`** — реализовано
- [x] **conio extras**: `wherex`/`wherey` ($53), `wrchar`/`rdchar` ($58/$57), `textmode_get/set` ($50/$51), `clrscr_attr` ($56) + COLOR macros
### Low-priority — ✅ FILE* stack ГОТОВО
- [x] **Минимальный unbuffered FILE\*** — `libc/stdio/file.c` + `libc/include/stdio.h`. fopen/fclose/fputs/fgets/fread/fwrite/fseek/ftell/rewind/feof/ferror/clearerr/fflush + stdin/stdout/stderr как pseudo-streams. См. `memory/file_star_design.md` и `examples/filetest`.
- [ ] fprintf / fscanf — нужна printf-через-callback machinery. Пока пользователь может `sprintf(buf, ...) + fputs(buf, fp)`.
- [ ] Опциональный buffered mode (setvbuf, line/block buffering) — если когда-то понадобится.
### POSIX time API — ✅ ГОТОВО
- [x] `libc/io/posix_time.c` — time/localtime/gmtime/mktime/asctime/ctime поверх getdatetime. SDCC's time.rel избегаем (нельзя override _RtcRead). См. `examples/ptime`.
### sys/stat — ✅ ГОТОВО
- [x] `libc/io/stat.c` — POSIX stat/fstat. Гибрид open+fstat для файлов, ffirst+iter для папок (включая "."/".."). См. `examples/stattest` и `memory/estex_ffirst_dotdot.md`.
### assert — ✅ ГОТОВО (используем SDCC's __assert через fallback include path)
## libc/stdlib — ✅ не нужно делать (см. memory/sdcc_stdlib_works.md)
Проверено через `examples/stdlib_test/`: SDCC z80.lib содержит работающие реализации:
- `atoi/atol/atof, strtol/strtoul, rand/srand, qsort/bsearch, abs/labs, div/ldiv`
- Полный `<string.h>` (memchr/cmp/set/cpy, strcat/cmp/cpy/len/chr/spn/etc.)
- `<ctype.h>` (toupper/tolower)
- `<math.h>` (sinf/cosf/sqrtf/etc.)
Линкер автоматически тянет из z80.lib когда нужно. **НЕ переписывать**.
Наши Sprinter-specific обязательные модули остаются: atexit, env, errno, setjmp, putchar/puts/getchar, conio, fsdir, time, mouse, open/read/lseek/close.
## Build-system: libsprinter.lib + sprinter-cc — ✅ ГОТОВО
- [x] `lib/Makefile` — собирает каждый libc/*.c в `.rel`, архивирует через sdar в `lib/sprinter.lib`
- [x] Включает runtime/bank.s и runtime/heap.s (auto-pulled при __banked/malloc)
- [x] `bin/sprinter-cc` — bash-wrapper: `sprinter-cc -o foo.exe foo.c` одной строкой
- [x] Поддержка опций `--crt0=default|minimal|banked`, `--bank N=FILE.c`, `-I`, `-L`/`-E`/`-S`, `-Wl`, `--mkexe`
- [x] `examples/hello_sccc/` — демо: `hello.c` собирается за один shell-вызов, размер совпадает с ручным Makefile (925 байт)
- [x] Split `putchar.c``putchar.c` + `puts.c` для per-function granularity (puts override SDCC's z80.lib version)
- [x] Включён в `make all` (зависимость `lib` перед `examples`)
Возможные улучшения (опционально):
- [ ] Мигрировать остальные examples на sprinter-cc вместо ручных Makefile (косметика)
- [ ] Дальнейшая декомпозиция libc/*.c per-function (но текущая granularity уже даёт нужный размер — линкер пакетует .rel целиком, и для большинства файлов это одна функция)
## Этап 9 — memory modes для sprinter-cc
DSS выделяет страницы памяти по размеру приложения: < 16 KB → одна страница, в остальные окна подключается «страница #FF» (read=0xFF, write игнорится). Из-за этого CODE-в-W1 + DATA-в-W2 для маленькой программы молча ломается. См. [memory/sprinter_memory_modes.md](../../.claude/projects/-Volumes-SAM8-Projects-DIY-Z80-Sprinter-C-Compiler/memory/sprinter_memory_modes.md).
- [x] **`tiny`**: всё (CODE+DATA+стек) в W2. Default. Verified hello/argv/conio/malloc/file/etc.
- [x] **`--memory MODE` флаг в sprinter-cc**: parser + per-mode дефолты CODE_LOC/DATA_LOC, override через явные `--code-loc`/`--data-loc`. tiny работает; small/big/huge компилируются с warning'ом (runtime не готов). Реализовано 2026-05-30.
- [x] **`--memory-manual SPEC`**: парсит `CODE=W1|W2,DATA=W1|W2|SAME,BANKED=W1|W3`. Реализовано 2026-05-30.
- [x] **`small` runtime**: `runtime/crt0_small.s` использует ESTEX `$3D GETMEM` + `$3A SETWIN2` чтобы выделить и замапить W2-страницу ДО gsinit. **НЕ** BIOS `$C4` — стек на этом этапе в W1 (boot_stack в HOME), а BIOS требует стек в W2. После маппинга SP переключается на 0xBFFE, дальше стандартный flow. Реализовано 2026-05-30, verified hello.exe.
- [x] **`small` auto-detect для >16 KB программ**: `crt0_small.s` читает порт `0xC2` (текущая страница в W2 — не `0xA2`! это W1). Если 0xFF — выделяет page; иначе DSS уже сделала это (программа сама вылезла в W2). Один crt0 покрывает 0..30 KB. mkexe также разрешает HOME span W1+W2 (0x4000..0xBFFF). Verified hello: small (5 KB файл, SETWIN2 path) + 32 KB файл (auto-skip). Реализовано 2026-05-30.
- [x] **`big` runtime** (tiny + banked code в W1): параметризовали `crt0_banked.s` + `bank.s` через `.ifdef BANK_W1` — другой banking port (0xA2 vs 0xE2), другой load-addr (0x4000 vs 0xC000). sprinter-cc prepend'ит `BANK_W1 = 1` при `--memory big`, передаёт `mkexe -B 0x4000`. Пример `examples/banked_big/`. Реализовано 2026-05-30.
- [x] **`huge` runtime** (small + banked code в W3): merge W2-detect логики из `crt0_small.s` в `crt0_banked.s`. Существующий пример `examples/banked/` теперь использует MEMORY=huge. Реализовано 2026-05-30.
- [x] **`--debug` флаг**: prepend `DEBUG_RT = 1` в crt0 + `-DDEBUG_RT` в sdcc. Открывает symbol `_w2_self_allocated` (uint8_t) — runtime diagnostic кто аллоцировал W2. Реализовано 2026-05-30.
### Дизайн-решения по libc и crt0
**Одна `sprinter.lib`** работает для всех memory mode — `.rel`-члены relocatable, SDLD делает dead-code elimination per-member (без графики не подтягивает `gfx_core.rel` и т.д.). Verified hello vs malloc_test через map-файлы.
**`gfx.lib` отдельно — НЕ нужен**: dead-code elimination уже работает.
**`libc_banked` (libc в bank вместо HOME)** — идея на потом, когда HOME (16 KB) забит user-кодом + libc в `huge` mode. Реализуется через `--codeseg BANK0` при компиляции libc; trade-off: trampoline ~30 циклов на каждый libc-вызов. Триггер: реальная программа упрётся в HOME budget.
**HW-зависимые модули — `sprinter_home.lib` отдельно.** Часть libc физически не может быть забанкована в W3, потому что она РАБОТАЕТ с W3:
- `gfx_*` — пишет в видеопамять `0xC000+` после swap W3 на video page
- `bank_io` (mem_alloc_pages/bank_read/bank_write) — swap'ит W3 через `OUT (0xE2)`
- Будущие ISR — прерывание может прийти когда W3 на чём угодно
В huge mode эти модули ДОЛЖНЫ остаться в HOME (W1). Когда будем делать `libc_banked`, **одновременно** выделяем `sprinter_home.lib` (HOME-only) из `sprinter.lib` (bankable). Финальная схема:
```
sprinter_home.lib HOME-only: gfx, bank_io, ISR shims
sprinter.lib bankable: printf, malloc, string, conio, stdio, env, ...
sprinter_banked.lib тот же sprinter.lib но --codeseg BANK0 (для huge)
```
Триггер: реализация `--memory huge` runtime.
**crt0 — по одному на mode:**
- `crt0.s` — текущий, для **tiny/big**: SP=0xBFFE, парсит argv (W2-ресурс уже выделен DSS).
- `crt0_minimal.s` — текущий, для tiny без argv.
- `crt0_small.s`**новый, step 3**: для **small/huge**, аллоцирует W2 через `mem_alloc_pages` ДО gsinit, маппит в порт `0xA2`, потом стандартный flow.
- `crt0_banked.s` — текущий, для **big**: trampoline-таблица для W3 банков, CODE в W2.
- `crt0_banked_small.s`**новый**: huge = small (W2-alloc) + banked (W3 trampolines).
sprinter-cc подбирает crt0 по `--memory` mode (сейчас `--crt0=` это override).
- [x] **Настраиваемый размер стека**: флаг `sprinter-cc --stack-size BYTES`. Wrapper генерирует `heap_top.s` с `___sdcc_heap_end = stack_top + 1 - stack_size`, отдельный .rel линкуется per-program. Default ≈1278 байт (heap_top=0xBB00) из `runtime/heap_top.s`. Реализовано 2026-05-30.
Интерфейс: `sprinter-cc --memory [tiny|small|big|huge|manual] [--memory-manual SPEC] [--stack-size N] foo.c`. `--memory-manual` имеет смысл только с `--memory manual`.
## Known issues / quirks
- **ESTEX $46 ENV API**: ✅ работает. Док-ция в `DiskSyscalls.txt v1.6` ошибочно описывает return-status — A=0 это NOT FOUND, не FOUND. Зафиксировано в `memory/sprinter_platform.md`.
## ОБЯЗАТЕЛЬНЫЕ ЗАДАЧИ ДЛЯ V2 (после релиза v1)
### Turbo-C-style graphics API (BGI-like) — **MUST для v2**
Расширить наш `gfx_*` API до уровня **Turbo-C `<graphics.h>`** (BGI) для MS-DOS.
Программисты привыкшие к Turbo-C должны переносить графический код 1-в-1.
**Что должно быть** (на основе Borland BGI):
Setup/teardown:
- `initgraph()` / `closegraph()`у нас сейчас `gfx_init`/`gfx_done`, добавить alias
- `getmaxx()` / `getmaxy()` — макрос на GFX_WIDTH-1 / GFX_HEIGHT-1
- `cleardevice()` — alias to gfx_clear
- `getgraphmode()` / `setgraphmode()`у нас get_videomode/set_videomode
Color/palette:
- `setcolor(c)`, `getcolor()` — current draw color
- `setbkcolor(c)`, `getbkcolor()` — background color
- `setpalette(idx, c)` — палитра entry
- `getpalette(&info)` — read all palette
Primitives (мы уже имеем эквиваленты — добавить BGI-имена как aliases):
- `putpixel(x, y, c)` — есть как gfx_putpixel
- `getpixel(x, y)` — нужно реализовать (RMW обратное — IN)
- `moveto(x, y)`, `lineto(x, y)`, `linerel(dx, dy)` — current point + line drawing
- `line(x1, y1, x2, y2)` — есть как gfx_line
- `rectangle(x1, y1, x2, y2)` — есть как gfx_rect (но другой API: x1,y1,x2,y2 vs x,y,w,h!)
- `bar(x1, y1, x2, y2)` — есть как gfx_fill_rect
- `bar3d(x1, y1, x2, y2, depth, topflag)` — новое: rect + 3d edges
- `circle(x, y, r)`, `arc(...)`, `ellipse(...)`, `pieslice(...)` — новые primitives
- `fillpoly()`, `drawpoly()` — полигоны
- `floodfill(x, y, border_color)` — заливка
Text on graphics screen:
- `outtext(s)` / `outtextxy(x, y, s)` — есть как gfx_text (alias)
- `settextstyle(font, dir, size)` — multiple bitmap fonts
- `gettextsettings(&info)`
- `textwidth(s)` / `textheight(s)` — measure
Image manipulation:
- `imagesize(x1, y1, x2, y2)` — bytes needed for getimage
- `getimage(x1, y1, x2, y2, buf)` — save rect to buffer
- `putimage(x, y, buf, op)` — paste back with COPY_PUT/XOR_PUT/AND_PUT/OR_PUT/NOT_PUT
Clipping/viewport:
- `setviewport(x1, y1, x2, y2, clip)` — drawing clip rect
- `getviewsettings(&info)`
- `clearviewport()`
- `setactivepage(p)` / `setvisualpage(p)` — двойная буферизация (Sprinter имеет 2 screen)
Line style:
- `setlinestyle(style, pattern, thickness)` — SOLID_LINE / DOTTED_LINE / etc.
- `getlinesettings(&info)`
**Acceptance:** перенос типичной Turbo-C BGI программы (рисующей с использованием
moveto/lineto/circle/bar/setcolor) должен работать без существенных правок.
**Notes:**
- BGI fonts (TRIPLEX/SANS_SERIF/GOTHIC) — у нас один BIOS font, остальные нужно
добавить (как bitmap data в lib)
- imagesize/getimage/putimage — самые востребованные для game/animation
- Active/visual page (двойная буферизация) — Sprinter поддерживает 2 graphics pages,
нужен API switching
См. также `examples/` Turbo C 2.x BGIDEMO как reference что нужно.
### IM2 Interrupt Handlers — **MUST для v2**
User-задаваемые ISR через Z80 IM 2 mode. Нужны для:
- Timer ticks (50 Hz frame counter, плавная анимация)
- Music playback (AY, COVOX)
- Real-time games (input + game logic + render в interrupt-driven)
- Async keyboard / mouse handling
**Status:** ОТЛОЖЕНО до v2. Полный research + design в `docs/im2_isr_design.md`.
**Решение по архитектуре:** реализовать как отдельный memory mode `--memory im2`
(вместо того чтобы лезть во все существующие crt0). Detail'и в design-doc.
**Резюме research'а** (полный текст в `docs/im2_isr_design.md`):
- Vector 0xFF — frame + keyboard + CBL. Disambiguation по портам 0x19 / 0xFE
- Mouse hardware-IRQ не приходит (на текущей плате)
- Vector table / ISR / stack ОБЯЗАНЫ быть в W2 (0x8000..0xBFFF)
- DSS имеет свой IM 2 handler — нужно chain'иться (иначе клавиатура / SYSTIME ломаются)
### Прочие крупные пункты для v2
- [ ] **FILE API rewrite — buffered streams** — текущая реализация в
`libc/stdio/file.c` это provisional unbuffered shim (каждый fputc/fgetc
= один read/write syscall). Нужна полноценная buffered семантика
как в Solid-C:
```c
typedef struct {
uint flags; // +0..1 file status flags
int level; // +2..3 empty/fill level of buffer
char *curp; // +4..5 current active pointer
int fd; // +6..7 underlying low-level fd
char *buffer; // +8..9 data transfer buffer
char hold; // +10 ungetc byte if no buffer
short token; // +11..12 reserved
char dummy; // +13 reserved
} FILE;
```
stdin/stdout/stderr — fd-маркеры `0 / -1 / -2`. Отрицательные для
stdout/stderr выбраны намеренно: ESTEX OPEN может вернуть positive
small fd (1, 2, …) для обычного файла → если бы stdout=1, реальный
fd=1 сталкивался бы с идентификатором. fd=0 для stdin безопасно
(ESTEX 0 не возвращает). Сами fd не передаются в syscall'ы —
диспетчеризация по флагам `_F_CONIN/_F_CONOUT`.
Принтер-потоки (stdaux/stdprn) НЕ реализуем — Sprinter принтерной
API не имеет.
Альтернатива — взять реализацию из third_party/solid-c (sources в
`SRC/CLIB/`); там есть готовый buffered FILE + fopen/fread/fwrite/
fseek/setvbuf и т.д. Адаптировать к нашим open/read/write/lseek.
При rewrite заодно решить deferred issues stdio-review:
- `fwrite` short-write должен ставить `_F_ERROR`
- `fgets(buf, 1, fp)` — стандарт говорит "empty string", мы вернули NULL
- `mode_to_flags` — break-out на '+' (cosmetic)
- [ ] **Audio API** — AY-3-8910 + COVOX через прерывания (требует IM2)
- [ ] **ISA-8 slot support** — ZX-Bus карты (sound, network, etc.) — требует IM2 + чтения portов
## Прочие задачи (v1 backlog, не блокирующие)
- [x] **#9: text I/O split (Turbo-C style)** — stdio (puts/printf/putchar) теперь fast no-attr через PCHARS/PUTCHAR. conio (cputs/cprintf/putch) применяет attr через textcolor/textbackground/textattr. KEEP_EXIST_ATTR → conio fallback на fast path. Verified в hello.exe. См. `memory/text_output_api_split.md`. Реализовано 2026-05-31.
- [x] **Mouse API полный** (резидентный driver, RST 30h) — все 14 функций обёрнуты (init/show/hide/refresh/read/goto/bounds/text_cursor/load_cursor/get_cursor/get/set_sensitivity/video_mode_changed). См. `memory/mouse_api.md`. Verified в MAME 2026-05-31. Sensitivity = divider (меньше = быстрее).
- [ ] Interrupt handlers — IM 2 vector table в HOME для user ISR'ов
- [ ] Поддержка `restore SP on EXIT` (паттерн из z88dk +pps) — проверить нужно ли
- [ ] CI: автоматически запускать MAME с `-aviwrite` для screenshot-сравнения, чтобы тесты примеров проходили без человека
## Идеи на потом
- Поддержка `<setjmp.h>` (есть в SDCC stdlib — нужно протестировать что наш crt0 совместим)
- `<time.h>` через ESTEX SYSTIME (`$21`) и CMOS BIOS-функции
- ZX Spectrum-совместимый режим как отдельный target (для портирования спектрумовских программ)
- Поддержка ZX-Bus карт (sound, network, etc.) — нужны драйверы
- Profile-guided optimization tools (hot/cold detection) для крупных программ
## Linker duplicate-symbol warnings (благоприятные, отфильтрованы)
Когда мы сознательно overrides'им SDCC z80.lib функции собственной версией в `sprinter.lib`, `sdldz80` пишет `?ASlink-Warning-Definition of public symbol '...' found more than once`. Линкер берёт первое найденное определение (наше), поэтому поведение корректное — warning только noise.
Текущие overrides:
- `_puts` — наша версия через PCHARS+\r\n vs SDCC posix puts
- `___sdcc_heap` — наш heap в W2 vs SDCC's стандартный
- `_asctime`, `_localtime` (и возможно другие из time) — наш `posix_time.c` через ESTEX SYSTIME vs SDCC's `time.rel` который зависит от `_RtcRead`
**Текущее решение:** `bin/sprinter-cc` отфильтровывает warning-блок (warning + 2 follow-up `Library:` строки) из вывода `sdcc`. Через `-v` (verbose) всё показывается. Реализовано через awk-pipe.
**Возможные улучшения:**
- Перейти на explicit `--nostdlib` + ручной список нужных модулей из z80.lib (string, math, stdlib без override'нутых) — убрать ИСТОЧНИК warning'ов, не маскировать
- Или: переименовать наши `_puts` → `_puts_sprinter` + alias через linker flag (не уверен что SDCC поддерживает)
- Или: оставить как сейчас (рабочее и benign) — приоритет низкий
## TODO: проверить на реальном железе
- [ ] **Port_Y banking trick** (`docs/part2/SprinterGraphics programming.txt`):
доку утверждает что после `OUT (0x89), Y` адреса 0xC000+0x400*N в окне W3
маппятся на строки Y..Y+15 (одно программирование → 16 строк).
Empirical 2026-06-01 в MAME 0.283 этот trick **не работает** — пиксели
по адресам выше 0xC000+row_width уходят в невидимую область. Канонический
`docs/samples/plasma2.asm` тоже не использует banking, переустанавливает
Port_Y per row.
План:
1. Получить доступ к реальному Sprinter
2. Запустить тест dual-write (`_gfx_putpixel_raw` + второй write в `0xD000+x`)
3. Если на железе видны двойные линии → бага MAME, открыть issue с
минимальным репро
4. Если на железе тоже одна линия → документ неверный, удалить упоминание
из доки и просто оставить текущую реализацию (Port_Y per pixel)
5. Если banking работает на железе → внедрить кэширование Port_Y в
`_gfx_putpixel_raw` (sentinel out-of-range, см. memory/gfx_port_y_banking.md)
Связанный выигрыш для Bresenham (60-pixel диагональ) — около 8× меньше
OUT (0x89) операций, для `gfx_fill_rect 320x256` — 16× меньше. Не блокирует
release v1.
## GFX: расширения по `docs/part2/accelerator_doc.txt`
После прочтения детального accelerator doc выявлены незакрытые направления.
Сейчас в коде используется только горизонтальный/вертикальный Fill mode.
### Quick wins для текущих primitives
- [ ] **Заменить SMC на `LD A, (var)` для block-size**. Документ явно
разрешает `LD A, (HL)`, `LD A, (BC)`, `LD A, (DE)` (но не `LD A, r`).
Это уберёт SMC complexity в `gfx_lines.c:hfill_chunk/vfill_chunk` и
`gfx_16.c:g16_hfill_chunk`. Запрещено только register-to-register.
- [ ] **Кэширование block-size**. Документ показывает что accel запоминает
block size между bursts (см. `Horizontal_Line_Fill`: устанавливают
size + `LD B,B` отключение, потом включают Fill mode и используют
сохранённый size). Для `gfx_fill_rect` с 100 одинаковыми
строками — установить size 1 раз, а не 100.
### Bank-prefix modes (port 0xE2 bits)
Документ показывает три варианта банка видеостраницы помимо стандартного 0x50:
| Bank byte | Effect |
|---|---|
| 0x50 | Normal write — пишется в shadow + видимый |
| 0x54 | "no copy in main shadow RAM" |
| 0x58 | **"FF is transparent"** — байт 0xFF при write оставляет background |
| 0x5C | both |
Bank 0x58 объясняет почему mouse cursor рисуется с 0xFF-прозрачностью.
Это путь к **sprite-blending через accel block copy**:
- [ ] **`gfx_set_bank_transparent(on)`** или флаг в `gfx_set_bank` для
выбора 0x50/0x58 при отрисовке sprite'ов
- [ ] Использовать в новом `gfx_blit()` чтобы по факту получать
transparent sprites через accel-копию
### Block copy mode (sprite blit'ы)
`LD L,L` (horizontal) и `LD A,A` (vertical) — режим копирования блока через
256-байтную accel memory. Это базис для blit'ов.
- [ ] **`gfx_blit(src_data, x, y, w, h)`** — копирование sprite'а
(произвольный размер, через accel)
- [ ] **`gfx_blit_transparent(src, x, y, w, h)`** — с использованием bank 0x58
См. `Draw_Restangle_Data` в accelerator_doc.txt как референс.
### AND / OR / XOR operations через accel
Документ показывает что accel поддерживает логические операции с блоками
данных. Применения:
- XOR — инверсия области (выделение selection в UI)
- OR / AND — masking, alpha-style blending
- См. пример в accelerator_doc.txt: "256 bytes block coding via XOR"
- [ ] **`gfx_xor_rect`** / **`gfx_or_rect`** / **`gfx_and_rect`** —
примитивы логических операций над прямоугольником
- [ ] **`gfx_invert_rect(x, y, w, h)`** — alias на xor с 0xFF
### Bitmap fonts разных размеров
Сейчас `gfx_text` / `gfx_putchar` хардкоженно работают с 8×8 шрифтом
(BIOS WIN_GET_ZG возвращает 256×8 байт). Для будущих UI / титульников
нужны:
- [ ] **`gfx_set_font_size(w, h)`** — переключить ширину/высоту glyph'а
- [ ] **`gfx_set_font_data(ptr, w, h, advance)`** — заменить указатель
на пользовательский шрифт + размеры
- [ ] Поддержка **proportional** (advance != w) шрифтов — добавить
array advance[256] на ширину каждого glyph'а
- [ ] **Big-font режимы**: 8×16, 16×16, 16×8 (для титульников)
- [ ] Возможно отдельный API `gfx_text_ex(x, y, str, font_id)` где
font_id выбирает один из загруженных шрифтов
- [ ] **Anti-alias 2-bit шрифты** (бит фон / бит граница / 2-бит alpha?)
— far future, для smooth UI
## Финальный этап оптимизаций (не сейчас)
- **`gfx_line` через accel для пологих диагоналей** — Bresenham для линии с |dy| << |dx| (или наоборот) выдаёт длинные runs одинакового Y (или X): пиксель, пиксель, пиксель, шаг Y, пиксель, пиксель... Каждый такой run — это готовый аргумент для `gfx_hline` (или `vline`).
План исследования: посчитать длину runs как функцию от наклона; решить минимальный run length, при котором выгоднее accel hline чем N×putpixel (overhead accel ~20µs, putpixel ~5µs — accel выгоднее при run ≥ 4-5 px); для крутых диагоналей (dx ≈ dy) оставить Bresenham, для пологих — run-length-based fill.
Сейчас `gfx_line` orthogonal cases уже через accel — оптимизировать только косые.
- **`gfx_fill_rect` с одним W3-swap на всю операцию** — сейчас каждый внутренний `gfx_hline`/`gfx_vline` делает свой DI/save-W3/restore-W3/EI. Можно сделать internal `_fill_rect_inner` который держит W3 замапленным и DI весь цикл; ~20µs × количество строк/столбцов экономии. Применимо ко всем композитным примитивам.
+64 -492
View File
@@ -143,6 +143,9 @@ typedef struct idx_rec_s {
#define IF_NOWRAP 0x02u /* строка не переносится (кодовый блок / HR / таблица) */ #define IF_NOWRAP 0x02u /* строка не переносится (кодовый блок / HR / таблица) */
#define IF_BLANK 0x04u /* визуально пустая строка */ #define IF_BLANK 0x04u /* визуально пустая строка */
#define IF_CODE 0x08u /* тело fenced code-блока (verbatim-режим) */ #define IF_CODE 0x08u /* тело fenced code-блока (verbatim-режим) */
#define IF_HSCROLL 0x10u /* блок горизонтально скроллируется ЦЕЛИКОМ (код/таблица),
* включая строки короче 80 — двигаются как единый блок.
* НЕ ставится на HR и границы fence (им нечего прятать). */
/* ================================================================== /* ==================================================================
* Фаза 1 (mdview2): директория и пул рендер-кэша * Фаза 1 (mdview2): директория и пул рендер-кэша
@@ -502,15 +505,6 @@ static void idx_put(uint16_t idx, const idx_rec_t *r)
bank_write(index_phys[page], off, r, INDEX_REC_SIZE); bank_write(index_phys[page], off, r, INDEX_REC_SIZE);
} }
/* Чтение полей индекса для рендера и навигации (построчный доступ). */
static uint8_t seg_flags(uint16_t idx)
{
idx_rec_t r;
if (idx >= n_lines) return 0;
idx_get(idx, &r);
return r.flags;
}
static uint32_t seg_off(uint16_t idx) static uint32_t seg_off(uint16_t idx)
{ {
idx_rec_t r; idx_rec_t r;
@@ -519,16 +513,12 @@ static uint32_t seg_off(uint16_t idx)
return r.off; return r.off;
} }
static uint8_t is_cont(uint16_t idx) { return (uint8_t)((seg_flags(idx) & IF_CONT) != 0); }
static uint8_t is_nowrap(uint16_t idx) { return (uint8_t)((seg_flags(idx) & IF_NOWRAP) != 0); }
static uint8_t is_blank(uint16_t idx) { return (uint8_t)((seg_flags(idx) & IF_BLANK) != 0); }
static uint8_t is_code_body(uint16_t idx){ return (uint8_t)((seg_flags(idx) & IF_CODE) != 0); }
/* Установка флагов всегда идёт в последний emit_seg (n_lines-1). /* Установка флагов всегда идёт в последний emit_seg (n_lines-1).
* Используется near-копия cur_rec, чтобы не делать обратное чтение из банка. */ * Используется near-копия cur_rec, чтобы не делать обратное чтение из банка. */
static void set_nowrap_cur(void) { cur_rec.flags |= IF_NOWRAP; idx_put((uint16_t)(n_lines - 1), &cur_rec); } static void set_nowrap_cur(void) { cur_rec.flags |= IF_NOWRAP; idx_put((uint16_t)(n_lines - 1), &cur_rec); }
static void set_blank_cur(void) { cur_rec.flags |= IF_BLANK; idx_put((uint16_t)(n_lines - 1), &cur_rec); } static void set_blank_cur(void) { cur_rec.flags |= IF_BLANK; idx_put((uint16_t)(n_lines - 1), &cur_rec); }
static void set_code_cur(void) { cur_rec.flags |= IF_CODE; idx_put((uint16_t)(n_lines - 1), &cur_rec); } static void set_code_cur(void) { cur_rec.flags |= IF_CODE; idx_put((uint16_t)(n_lines - 1), &cur_rec); }
static void set_hscroll_cur(void){ cur_rec.flags |= IF_HSCROLL; idx_put((uint16_t)(n_lines - 1), &cur_rec); }
/* Загружает 16 текстовых атрибутов в палитры ink/paper/blink. */ /* Загружает 16 текстовых атрибутов в палитры ink/paper/blink. */
static void set_pallete(void) { static void set_pallete(void) {
@@ -653,20 +643,6 @@ static void unload_file(void)
* Индексация строк * Индексация строк
* ================================================================== */ * ================================================================== */
/* Возвращает 1, если сегмент idx — это старт логической строки,
* и эта строка начинается с ``` (граница блока кода).
* Сегменты-продолжения никогда не считаются такими границами. */
static uint8_t is_fence_delim(uint16_t idx)
{
if (idx >= n_lines) return 0;
if (is_cont(idx)) return 0;
uint32_t off = seg_off(idx);
if (off + 2 >= file_size) return 0;
return (uint8_t)(fb(off) == '`' &&
fb(off + 1) == '`' &&
fb(off + 2) == '`');
}
#define INIT_STYLE_PLAIN 0x0 #define INIT_STYLE_PLAIN 0x0
#define INIT_STYLE_BOLD 0x1 #define INIT_STYLE_BOLD 0x1
#define INIT_STYLE_ITALIC 0x2 #define INIT_STYLE_ITALIC 0x2
@@ -680,25 +656,6 @@ static const uint8_t styles_map[] = {
ATTR_TEXT, ATTR_TEXT_BOLD, ATTR_TEXT_ITALIC, ATTR_TEXT_UNDERSORE, ATTR_TEXT_CODE, ATTR_TEXT_STRIKE ATTR_TEXT, ATTR_TEXT_BOLD, ATTR_TEXT_ITALIC, ATTR_TEXT_UNDERSORE, ATTR_TEXT_CODE, ATTR_TEXT_STRIKE
}; };
/* Возвращает стартовый атрибут сегмента из индексной записи. */
static uint8_t get_init_style(uint16_t idx)
{
idx_rec_t r;
if (idx >= n_lines) return styles_map[INIT_STYLE_PLAIN];
idx_get(idx, &r);
return styles_map[r.style & 7u];
}
/* Возвращает «сырой» INIT_STYLE_* из индексной записи. */
static uint8_t get_init_style_raw(uint16_t idx)
{
idx_rec_t r;
if (idx >= n_lines) return INIT_STYLE_PLAIN;
idx_get(idx, &r);
return (uint8_t)(r.style & 7u);
}
/* Тип строки, который возвращает classify_line(). */ /* Тип строки, который возвращает classify_line(). */
#define LK_PLAIN 0 #define LK_PLAIN 0
#define LK_H1 1 #define LK_H1 1
@@ -859,9 +816,13 @@ static uint8_t is_line_blank(uint32_t p)
return 1; return 1;
} }
/* Быстрая проверка начала блока кода по маркеру ``` . */ /* Быстрая проверка границы fenced-блока по маркеру ``` .
* Ведущие пробелы/табы пропускаются — fence может быть с отступом
* (например, ``` ```c ``` внутри элемента списка с отступом 2+ пробела).
* Язык после бэктиков (```c, ```sh) игнорируется: достаточно трёх ` подряд. */
static uint8_t is_fence_raw(uint32_t p) static uint8_t is_fence_raw(uint32_t p)
{ {
while (p < file_size && (fb(p) == ' ' || fb(p) == '\t')) p++;
if (p + 2 >= file_size) return 0; if (p + 2 >= file_size) return 0;
return (fb(p) == '`' && fb(p + 1) == '`' && fb(p + 2) == '`'); return (fb(p) == '`' && fb(p + 1) == '`' && fb(p + 2) == '`');
} }
@@ -1267,6 +1228,7 @@ static void index_lines(void)
emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0); emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0);
set_nowrap_cur(); set_nowrap_cur();
set_code_cur(); set_code_cur();
set_hscroll_cur(); /* код скроллится целым блоком */
while (p < file_size && fb(p) != '\n') p++; while (p < file_size && fb(p) != '\n') p++;
if (p < file_size) p++; if (p < file_size) p++;
continue; continue;
@@ -1285,6 +1247,7 @@ static void index_lines(void)
if (is_table_raw(p)) { if (is_table_raw(p)) {
emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0); emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0);
set_nowrap_cur(); set_nowrap_cur();
set_hscroll_cur(); /* таблица скроллится целым блоком */
while (p < file_size && fb(p) != '\n') p++; while (p < file_size && fb(p) != '\n') p++;
if (p < file_size) p++; if (p < file_size) p++;
continue; continue;
@@ -1474,411 +1437,6 @@ static uint8_t handle_inline_marker(char ch, uint32_t *io_p, uint32_t seg_end,
return 0; return 0;
} }
/* Сбрасывает накопленный "прогон" из *col символов одного атрибута
* одним bios_writeattr() вместо посимвольных wrchar(). *need_place
* лениво ставит BIOS-курсор перед первым выводом в текущей строке —
* далее place продвигается сам (verified), повторный bios_set_place
* не нужен пока *col растёт монотонно. */
static void flush_run(uint8_t row, uint8_t *col, const char *buf,
uint8_t len, uint8_t attr, uint8_t *need_place)
{
if (len == 0) return;
/* *col уже указывает на конец прогона (вызывающий цикл инкрементит
* col на каждый символ ещё во время накопления в буфер — это нужно
* для верных проверок col < SCREEN_W до сброса). Поэтому начало
* прогона на экране = *col - len, а не *col; и *col второй раз
* наращивать НЕ нужно — он уже корректен. */
if (*need_place) {
bios_set_place(row, (uint8_t)(*col - len));
*need_place = 0;
}
bios_writeattr(buf, len, attr);
}
static void render_line(uint16_t line_idx, uint8_t row)
{
/* Один idx_get() на line_idx вместо ~13 отдельных is_blank/is_cont/
* is_nowrap/is_code_body/seg_off/get_init_style* вызовов. Каждый из
* них шёл через seg_flags()/seg_off() -> idx_get() -> bank_read(),
* а bank_read() ДВАЖДЫ переключает банк окна 3 (на страницу индекса
* и обратно) плюс memcpy — это оказалось главным узким местом
* рендера (≈700+ переключений банка на кадр), а не сам вывод на
* экран. Все нужные поля (off/flags/style) лежат в одной 8-байтовой
* записи idx_rec_t — читаем её один раз и используем дальше. */
uint8_t have_rec = (line_idx < n_lines);
idx_rec_t rec;
if (have_rec) idx_get(line_idx, &rec);
uint8_t col = 0;
uint8_t base_attr = ATTR_TEXT;
uint8_t is_code = (uint8_t)(have_rec && (rec.flags & IF_CODE));
uint8_t cur_attr = is_code ? ATTR_TEXT_CODE
: styles_map[(have_rec ? rec.style : INIT_STYLE_PLAIN) & 7u];
uint8_t emph = is_code ? EM_CODE
: (uint8_t)((have_rec ? rec.style : INIT_STYLE_PLAIN) & 7u);
uint8_t parse_inline = 1; /* 1: включён парсер выделений, 0: выводим код как есть */
if (have_rec && (rec.flags & IF_BLANK)) {
bios_set_place(row, col);
bios_fillcharattr(' ', ATTR_TEXT, (uint8_t)(SCREEN_W - col));
return;
}
if (have_rec) {
uint32_t p = rec.off;
uint32_t content_off = p;
uint8_t cont = (uint8_t)((rec.flags & IF_CONT) != 0);
/* Правая граница сегмента: начало следующего сегмента или конец файла. */
uint32_t seg_end;
if ((uint16_t)(line_idx + 1) < n_lines) {
idx_rec_t rec_next;
idx_get((uint16_t)(line_idx + 1), &rec_next);
seg_end = rec_next.off;
} else {
seg_end = file_size;
}
if (seg_end > file_size) seg_end = file_size;
uint8_t kind;
uint8_t quote_stream = 0; /* 1: текущий сегмент относится к потоку цитаты */
/* Для сегмента-продолжения повторяем префикс/отступ
* от первого не-CONT сегмента логической строки. */
if (cont) {
uint16_t first = line_idx;
idx_rec_t rec_first = rec; /* rec.flags здесь уже содержит IF_CONT */
/* Для любого сегмента-продолжения откатываемся к первому
* НЕ-cont сегменту этой логической строки — один idx_get()
* на шаг вместо двух (is_cont + повторный seg_off). */
while (first > 0 && (rec_first.flags & IF_CONT)) {
first--;
idx_get(first, &rec_first);
}
uint32_t p_start = rec_first.off;
uint32_t content_off = p_start;
uint8_t first_kind = classify_line(p_start, &content_off);
uint8_t indent = marker_visible_col(first_kind, p_start, content_off);
if (first_kind == LK_QUOTE) {
/* Для продолжения цитаты нужно помнить тип потока,
* чтобы далее на newline корректно пропускать сырой `>`. */
quote_stream = 1;
uint8_t q = 0;
while (q + 2 < content_off && q < SCREEN_W && fb(p_start + q) == ' ') q++;
if (q > (uint8_t)(SCREEN_W - col)) q = (uint8_t)(SCREEN_W - col);
if (q > 0) {
bios_set_place(row, col);
bios_fillcharattr(' ', ATTR_TEXT, q);
col = (uint8_t)(col + q);
}
if (col < SCREEN_W) wrchar(col++, row, 0xB3, ATTR_QUOTE_MARKER);
if (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT);
} else if (first_kind == LK_ULIST || first_kind == LK_OLIST) {
uint8_t q = indent;
if (q > (uint8_t)(SCREEN_W - col)) q = (uint8_t)(SCREEN_W - col);
if (q > 0) {
bios_set_place(row, col);
bios_fillcharattr(' ', ATTR_TEXT, q);
col = (uint8_t)(col + q);
}
}
/* p/content_off уже равны rec.off — внутри cont-блока они не
* менялись (там свои локальные тени с тем же именем),
* пересчитывать через seg_off() заново не нужно. */
}
/* Кодовый блок:
* - строка-разделитель показывается как пустая;
* - тело рисуется «как есть» с ATTR_TEXT_CODE без inline-парсинга.
* is_fence_delim(line_idx) разворачено на месте: cont и p уже
* известны, повторный idx_get()/is_cont() не нужен. */
if (!cont && p + 2 < file_size &&
fb(p) == '`' && fb(p + 1) == '`' && fb(p + 2) == '`') {
bios_set_place(row, col);
bios_fillcharattr(' ', ATTR_TEXT, (uint8_t)(SCREEN_W - col));
return;
}
if (is_code) {
base_attr = ATTR_TEXT_CODE;
cur_attr = ATTR_TEXT_CODE;
parse_inline = 0;
kind = LK_PLAIN;
quote_stream = 0;
} else if (cont) {
/* Строка-продолжение: префикс уже нарисован выше.
* Содержимое рисуем как plain, без повторной классификации,
* иначе начало слова могло бы ложно стать маркером. */
kind = LK_PLAIN;
} else {
kind = classify_line(p, &content_off);
if (kind == LK_QUOTE) quote_stream = 1;
}
if (kind == LK_HR) {
bios_set_place(row, col);
bios_fillcharattr(0xC4, ATTR_HR, (uint8_t)(SCREEN_W - col));
return;
}
if (kind >= LK_H1 && kind <= LK_H4) {
switch (kind) {
case LK_H1: base_attr = ATTR_TEXT_TITLE1; break;
case LK_H2: base_attr = ATTR_TEXT_TITLE2; break;
case LK_H3: base_attr = ATTR_TEXT_TITLE3; break;
default: base_attr = ATTR_TEXT_TITLE4; break;
}
cur_attr = base_attr;
p = content_off;
} else if (kind == LK_ULIST) {
/* Базовая поддержка вложенности: ведущие пробелы сохраняем,
* затем рисуем маркер списка и пробел перед содержимым. */
uint32_t q = p;
uint32_t qmax = p + SCREEN_W;
while (q + 2 < content_off && q < qmax && fb(q) == ' ') q++;
uint8_t nsp = (uint8_t)(q - p);
if (nsp > (uint8_t)(SCREEN_W - col)) nsp = (uint8_t)(SCREEN_W - col);
if (nsp > 0) {
bios_set_place(row, col);
bios_fillcharattr(' ', ATTR_TEXT, nsp);
col = (uint8_t)(col + nsp);
}
if (col < SCREEN_W) wrchar(col++, row, 0x07, ATTR_LIST_MARKER); /* символ маркера списка */
if (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT);
p = content_off;
} else if (kind == LK_OLIST) {
/* Отступ -> пробелы; цифры и '.' / ')' рисуем как маркер;
* завершающий пробел — обычным атрибутом текста. */
uint32_t q = p;
uint32_t qmax = p + SCREEN_W;
while (q < content_off && q < qmax && fb(q) == ' ') q++;
uint8_t nsp = (uint8_t)(q - p);
if (nsp > (uint8_t)(SCREEN_W - col)) nsp = (uint8_t)(SCREEN_W - col);
if (nsp > 0) {
bios_set_place(row, col);
bios_fillcharattr(' ', ATTR_TEXT, nsp);
col = (uint8_t)(col + nsp);
}
/* Маркер (цифры/'.'/')') читается из fb() — это окно 3,
* BIOS принимает указатели только из #4000..#BFFF, поэтому
* сначала копируем во временный буфер на стеке (окно 2),
* чанками по 8 байт (маркер почти всегда короче, но длинный
* корректно не обрезается, просто шлётся несколькими
* bios_writeattr вместо одного). */
{
char mkbuf[8];
uint8_t first_chunk = 1;
while (q < content_off - 1 && col < SCREEN_W) {
uint8_t n = 0;
while (q < content_off - 1 &&
(uint8_t)(col + n) < SCREEN_W &&
n < sizeof(mkbuf)) {
mkbuf[n] = fb(q);
q++;
n++;
}
if (n == 0) break;
if (first_chunk) { bios_set_place(row, col); first_chunk = 0; }
bios_writeattr(mkbuf, n, ATTR_LIST_MARKER);
col = (uint8_t)(col + n);
}
}
if (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT);
p = content_off;
} else if (kind == LK_QUOTE) {
/* Отступ -> пробелы; затем quote-префикс '│' (0xB3),
* пробел и далее содержимое. */
uint32_t q = p;
uint32_t qmax = p + SCREEN_W;
while (q + 1 < content_off && q < qmax && fb(q) == ' ') q++;
uint8_t nsp = (uint8_t)(q - p);
if (nsp > (uint8_t)(SCREEN_W - col)) nsp = (uint8_t)(SCREEN_W - col);
if (nsp > 0) {
bios_set_place(row, col);
bios_fillcharattr(' ', ATTR_TEXT, nsp);
col = (uint8_t)(col + nsp);
}
if (col < SCREEN_W) wrchar(col++, row, 0xB3, ATTR_QUOTE_MARKER);
if (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT);
p = content_off;
}
/* Эффективный горизонтальный сдвиг: применяется только к nowrap-строкам. */
uint8_t effective_vx = (rec.flags & IF_NOWRAP) ? viewport_x : 0;
/* cc — видимая колонка внутри области контента
* (после фиксированного префикса строки). */
uint8_t cc = 0;
char prev_ch = ' '; /* используется в проверке границ emphasis */
/* Буферизация "ровного" прогона одного атрибута: символ и атрибут
* внутри этого while переменные (emphasis/tab/soft-wrap), поэтому
* весь цикл одним BIOS-вызовом не свернуть — но между сменами
* атрибута почти всегда идёт протяжённый одноцветный текст, и
* именно его стоит копить в runbuf и сбрасывать одним
* bios_writeattr() вместо посимвольных wrchar().
* runbuf вмещает целую строку экрана — переполнение невозможно,
* т.к. весь цикл и так ограничен col < SCREEN_W. */
char runbuf[SCREEN_W];
uint8_t run_len = 0;
uint8_t run_attr = cur_attr;
uint8_t need_place = 1;
while (p < seg_end && col < SCREEN_W) {
uint8_t soft_join = 0; /* 1: вставлен виртуальный пробел мягкой склейки, p уже сдвинут */
char ch = fb(p);
/* '\'
* перед концом сегмента — это маркер wide-break:
* поглощаем его без вывода (кроме verbatim code-body). */
if (parse_inline && ch == '\\' && (p + 1) < seg_end) {
char nb = fb(p + 1);
if (nb == '\n' || nb == '\r') { p++; continue; }
}
/* Мягкий перенос (перевод строки внутри одного сегмента).
* Рендер должен повторить логику индексатора:
* - quote: пропустить сырой `> ` следующей строки;
* - list/plain: схлопнуть перенос + ведущий отступ в 1 пробел. */
if (parse_inline && (ch == '\n' || ch == '\r')) {
uint32_t next = p + 1;
if (quote_stream && next < seg_end) {
uint32_t next_content = next;
if (classify_line(next, &next_content) == LK_QUOTE) {
/* Мягкая склейка quote-строк: `> ` не рендерим. */
p = next_content;
ch = ' ';
soft_join = 1;
} else {
/* Защита: если внутри quote-сегмента встретился
* не-цитатный перенос, работаем как обычная мягкая склейка. */
while (next < seg_end) {
char ws = fb(next);
if (ws == ' ' || ws == '\t') next++;
else break;
}
if (next > (p + 1)) {
p = next;
soft_join = 1;
}
ch = ' ';
}
} else {
while (next < seg_end) {
char ws = fb(next);
if (ws == ' ' || ws == '\t') next++;
else break;
}
if (next > (p + 1)) {
/* Схлопываем перенос + отступ (ленивое продолжение). */
p = next;
soft_join = 1;
}
ch = ' ';
}
} else if (ch == '\n' || ch == '\r') {
ch = ' ';
}
/* inline-маркеры (**, ~~, *, _, `):
* helper синхронизирует состояние emphasis и возвращает режим
* обработки текущего токена (поглотить/вывести литерал/обычный символ). */
if (parse_inline) {
char lit_ch = 0;
uint8_t mk = handle_inline_marker(ch, &p, seg_end, prev_ch, &emph, &lit_ch);
if (mk == 1) {
/* Атрибут меняется -> сбросить накопленный прогон
* со СТАРЫМ атрибутом, прежде чем переключиться. */
flush_run(row, &col, runbuf, run_len, run_attr, &need_place);
run_len = 0;
cur_attr = emph_to_attr(emph, base_attr);
run_attr = cur_attr;
continue;
}
if (mk == 2) {
if (cc >= effective_vx && col < SCREEN_W && run_len < sizeof(runbuf)) {
runbuf[run_len++] = lit_ch;
col++;
}
cc++;
if (cc >= effective_vx && col < SCREEN_W && run_len < sizeof(runbuf)) {
runbuf[run_len++] = lit_ch;
col++;
}
cc++;
prev_ch = lit_ch;
continue;
}
}
if (!soft_join) p++;
if (ch == '\t') {
/* Таб расширяется в координатах контента (cc).
* Сначала сбросить накопленный текстовый прогон — таб
* выводится отдельным bios_fillcharattr(), не через runbuf. */
flush_run(row, &col, runbuf, run_len, run_attr, &need_place);
run_len = 0;
uint8_t cc_start = cc;
uint8_t tgt = (uint8_t)((cc & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP);
uint8_t n_total = (uint8_t)(tgt - cc_start);
uint8_t skip = (effective_vx > cc_start) ? (uint8_t)(effective_vx - cc_start) : 0;
if (skip > n_total) skip = n_total;
uint8_t want = (uint8_t)(n_total - skip);
if (want > (uint8_t)(SCREEN_W - col)) want = (uint8_t)(SCREEN_W - col);
if (want > 0) {
bios_set_place(row, col);
bios_fillcharattr(' ', cur_attr, want);
col = (uint8_t)(col + want);
need_place = 0;
}
cc = (uint8_t)(cc_start + skip + want);
prev_ch = ' ';
} else {
if (cc >= effective_vx && col < SCREEN_W && run_len < sizeof(runbuf)) {
runbuf[run_len++] = ch;
col++;
}
cc++;
prev_ch = ch;
}
}
flush_run(row, &col, runbuf, run_len, run_attr, &need_place);
/* Индикатор обрезки справа для nowrap-строки:
* если после текущей позиции есть ещё видимый контент,
* поверх последней колонки рисуем '>'. */
if ((rec.flags & IF_NOWRAP) && col >= SCREEN_W) {
uint32_t pp = p;
uint32_t left_bound = rec.off;
while (pp < seg_end) {
char c = fb(pp);
if (c == '\n' || c == '\r') break;
if (c == '`') { pp++; continue; }
if (c == '*' && (pp + 1) < seg_end && fb(pp + 1) == '*') {
char pc = (pp > left_bound) ? fb(pp - 1) : ' ';
char nc = (pp + 2 < seg_end) ? fb(pp + 2) : '\n';
if (is_emph_flanked(pc, nc)) { pp += 2; continue; }
/* Литерал `**` тоже считается видимым контентом справа. */
} else if (c == '*' || c == '_') {
char pc = (pp > left_bound) ? fb(pp - 1) : ' ';
char nc = (pp + 1 < seg_end) ? fb(pp + 1) : '\n';
if (is_emph_flanked(pc, nc)) { pp++; continue; }
}
wrchar(SCREEN_W - 1, row, '>', ATTR_TRUNC);
break;
}
}
/* Индикатор скрытого контента слева при горизонтальном сдвиге. */
if ((rec.flags & IF_NOWRAP) && viewport_x > 0) {
wrchar(0, row, '<', ATTR_TRUNC);
}
}
/* Дозаполняем остаток строки пробелами, чтобы стереть хвост
* от предыдущего кадра рендера. */
{
uint8_t pad_attr = (parse_inline ? ATTR_TEXT : ATTR_TEXT_CODE);
if (col < SCREEN_W) {
bios_set_place(row, col);
bios_fillcharattr(' ', pad_attr, (uint8_t)(SCREEN_W - col));
}
}
}
/* Пишет одну (char,attr) ячейку в локальный буфер кэш-строки. Молча /* Пишет одну (char,attr) ячейку в локальный буфер кэш-строки. Молча
* не пишет (и не даёт col перевалить за MAX_CACHE_LINE_LEN), если буфер * не пишет (и не даёт col перевалить за MAX_CACHE_LINE_LEN), если буфер
* уже заполнен — длинные nowrap-строки просто обрезаются по канону, * уже заполнен — длинные nowrap-строки просто обрезаются по канону,
@@ -1983,9 +1541,10 @@ static void render_line_to_cache(uint16_t line_idx)
} }
} }
/* Разделитель fenced-блока — пустая строка в кэше (как IF_BLANK). */ /* Разделитель fenced-блока — пустая строка в кэше (как IF_BLANK).
if (!cont && p + 2 < file_size && * is_fence_raw пропускает ведущие пробелы, поэтому отступленный ``` тоже
fb(p) == '`' && fb(p + 1) == '`' && fb(p + 2) == '`') { * распознаётся (и язык ```c прячется вместе со всей строкой-границей). */
if (!cont && is_fence_raw(p)) {
out_rec.page = 0; out_rec.page = 0;
out_rec.off = 0; out_rec.off = 0;
out_rec.len = 0; out_rec.len = 0;
@@ -2145,25 +1704,13 @@ static void render_line_to_cache(uint16_t line_idx)
cache_dir_put(line_idx, &out_rec); cache_dir_put(line_idx, &out_rec);
} }
/* Рендерит все 30 строк окна, начиная от top_line. ЖИВОЙ путь (парсинг /* Фаза 4-5 — cache-only draw path: рисует одну строку ИЗ КЭША, без
* через fb()) — после Фазы 4 используется только для горизонтального * единого обращения к fb()/исходному файлу. Горизонтальный скролл для
* скролла (scroll_h(), Фаза 5 ещё не мигрировала его на кэш), для * nowrap-строк (Фаза 5) — это просто смещение начала среза на vx*2
* вертикального скролла и обычной перерисовки см. * байт ВНУТРИ ТОГО ЖЕ кэш-буфера (cache_reserve гарантирует, что вся
* draw_viewport_from_cache() ниже. */ * строка лежит в одной странице, так что off+vx*2 не пересекает
static void render_viewport(void) * границу страницы); не nowrap-строки игнорируют viewport_x, как и
{ * раньше в живом render_line() (там это называлось effective_vx). */
for (uint8_t i = 0; i < VIEW_H; i++) {
render_line((uint16_t)(top_line + i), (uint8_t)(VIEW_TOP_ROW + i));
}
}
/* Фаза 4 — cache-only draw path: рисует одну строку ИЗ КЭША, без
* единого обращения к fb()/исходному файлу. rec.len==0 — пустая
* строка, остаётся только фон. rec.len>SCREEN_W — nowrap-строка шире
* экрана: показываем первые SCREEN_W ячеек (viewport_x=0, как и
* остальные строки этим путём — горизонтальный скролл пока только
* через scroll_h()/render_viewport(), см. Фазу 5) и индикатор обрезки
* '>' в последней колонке. */
static void draw_line_from_cache(uint16_t line_idx, uint8_t row) static void draw_line_from_cache(uint16_t line_idx, uint8_t row)
{ {
cache_rec_t rec; cache_rec_t rec;
@@ -2172,20 +1719,32 @@ static void draw_line_from_cache(uint16_t line_idx, uint8_t row)
bios_set_place(row, 0); bios_set_place(row, 0);
bios_fillcharattr(' ', ATTR_TEXT, SCREEN_W); bios_fillcharattr(' ', ATTR_TEXT, SCREEN_W);
if (rec.len == 0) return; /* Горизонтальный сдвиг применяется ко ВСЕМ строкам hscroll-блока (код,
* таблицы) — по ТИПУ, а не по длине: блок двигается целиком, включая
* строки короче 80 (короткая строка, ушедшая за левый край, просто
* показывает маркер '<' на пустом месте). HR и границы fence не имеют
* IF_HSCROLL и не сдвигаются. */
uint8_t vx = (rec.flags & IF_HSCROLL) ? viewport_x : 0;
uint8_t w = (rec.len > SCREEN_W) ? SCREEN_W : rec.len; if (rec.len > vx) {
win_rest(row, 0, 1, w, rec.page, rec.off); uint16_t remaining = (uint16_t)(rec.len - vx);
if (rec.len > SCREEN_W) { uint8_t w = (remaining > SCREEN_W) ? SCREEN_W : (uint8_t)remaining;
uint16_t off = (uint16_t)(rec.off + (uint16_t)vx * 2u);
win_rest(row, 0, 1, w, rec.page, off);
if (remaining > SCREEN_W) {
wrchar(SCREEN_W - 1, row, '>', ATTR_TRUNC); wrchar(SCREEN_W - 1, row, '>', ATTR_TRUNC);
} }
}
if (vx > 0) {
wrchar(0, row, '<', ATTR_TRUNC);
}
} }
/* Перерисовывает все VIEW_H строк окна из кэша — основной путь /* Перерисовывает все VIEW_H строк окна из кэша — основной путь
* перерисовки видимой области после загрузки файла (Фаза 3-4). */ * перерисовки видимой области после загрузки файла (Фаза 3-4). */
static void draw_viewport_from_cache(void) static void draw_viewport_from_cache(void)
{ {
for (uint8_t i = 0; i < VIEW_H; i++) { for (uint8_t i = 0; i < VIEW_H - 1; i++) {
draw_line_from_cache((uint16_t)(top_line + i), (uint8_t)(VIEW_TOP_ROW + i)); draw_line_from_cache((uint16_t)(top_line + i), (uint8_t)(VIEW_TOP_ROW + i));
} }
} }
@@ -2304,21 +1863,22 @@ static void scroll_down(uint16_t n)
} }
/* Горизонтальный сдвиг (только если в окне есть nowrap-строки). /* Горизонтальный сдвиг (только если в окне есть nowrap-строки).
* Максимум сдвига ограничен самой широкой nowrap-строкой на экране. */ * Максимум сдвига ограничен самой широкой nowrap-строкой на экране.
* Фаза 5: ширина берётся прямо из директории кэша (rec.len — уже
* посчитанная реальная длина в ячейках после рендера), без единого
* обращения к fb()/seg_off()/исходному файлу. */
static void scroll_h(int8_t delta) static void scroll_h(int8_t delta)
{ {
uint16_t maxw = 0; uint16_t maxw = 0;
for (uint8_t i = 0; i < VIEW_H; i++) { for (uint8_t i = 0; i < VIEW_H; i++) {
uint16_t li = (uint16_t)(top_line + i); uint16_t li = (uint16_t)(top_line + i);
if (li >= n_lines) break; if (li >= n_lines) break;
if (!is_nowrap(li)) continue; cache_rec_t rec;
uint32_t a = seg_off(li); cache_dir_get(li, &rec);
uint32_t b = seg_off((uint16_t)(li + 1)); if (!(rec.flags & IF_HSCROLL)) continue; /* только код/таблицы */
if (b > file_size) b = file_size; if (rec.len > maxw) maxw = rec.len; /* самая широкая строка блока */
uint16_t w = (b > a) ? (uint16_t)(b - a) : 0; /* приблизительная видимая ширина */
if (w > maxw) maxw = w;
} }
if (maxw == 0) return; /* в окне нет nowrap-контента */ if (maxw == 0) return; /* нет строк шире экрана */
/* Максимальный сдвиг = ширина за пределами экрана, в границах uint8. */ /* Максимальный сдвиг = ширина за пределами экрана, в границах uint8. */
uint16_t over = (maxw > SCREEN_W) ? (uint16_t)(maxw - SCREEN_W) : 0; uint16_t over = (maxw > SCREEN_W) ? (uint16_t)(maxw - SCREEN_W) : 0;
@@ -2328,10 +1888,22 @@ static void scroll_h(int8_t delta)
int16_t nx = (int16_t)viewport_x + delta; int16_t nx = (int16_t)viewport_x + delta;
if (nx < 0) nx = 0; if (nx < 0) nx = 0;
if (nx > (int16_t)max_vx) nx = max_vx; if (nx > (int16_t)max_vx) nx = max_vx;
if ((uint8_t)nx != viewport_x) {
viewport_x = (uint8_t)nx; /* ПОРЯДОК ВАЖЕН: записываем viewport_x ДО сравнения, а сравниваем уже
render_viewport(); * сохранённую копию old_vx с new_vx. Иначе SDCC генерирует для
} * `if (new_vx != viewport_x) viewport_x = new_vx;` такой код:
* ld a, new_vx ; sub a,(viewport_x) ; jr Z,.. ; ld (viewport_x),a
* — т.е. для записи переиспользует регистр A, испорченный вычитанием в
* сравнении, и кладёт в viewport_x не new_vx, а (new_vx - old_vx).
* (asm-дамп 2026-06-24 подтвердил; даёт 10-8=2, 10-2=8 — ровно
* наблюдавшийся "прыжок" скролла.) Записав viewport_x первой, мы убираем
* портящий sub с пути записи; сравнение ниже только решает, перерисовывать
* ли экран. */
uint8_t new_vx = (uint8_t)nx;
uint8_t old_vx = viewport_x;
viewport_x = new_vx;
if (new_vx != old_vx)
draw_viewport_from_cache();
} }