X68k fixes. Documentation updated.

This commit is contained in:
Scott Duensing 2026-08-06 19:06:36 -05:00
parent 2c54e60824
commit b8b8c7dd93
6 changed files with 752 additions and 81 deletions

264
PERF.md
View file

@ -5,6 +5,79 @@ same-day captures of all four ports. This supersedes the 2026-07-04
Phase 0 baseline; every phase of the audit fix plan (0-8) has landed, Phase 0 baseline; every phase of the audit fix plan (0-8) has landed,
so these are the numbers the library actually ships with. so these are the numbers the library actually ships with.
**2026-08-06 update -- Sharp X68000 moved to the 256-colour graphics
plane.** The port previously drew native-planar into the TEXT plane, which has a
single 16-entry hardware palette and therefore could not honour the SCB. To make
SCB per-band palettes work it now presents into the 256-colour GRAPHICS plane
(`_iocs_crtmod(13)`), expanding the four 1bpp planes to one byte per pixel in
GVRAM with the DOS port's encoding, `(scb[y] << 4) | nibble`, against the
256-entry palette at `$E82000`. Storage is UNCHANGED -- still
`JOEYLIB_NATIVE_PLANAR`; only the present target moved. No raster interrupt is
involved. All 51 golden hashes are unchanged (jlSurfaceHash hashes the surface
planes, not GVRAM) and the port stays at 51/51. (The "total wall time is
identical at 15945 ms" originally written here was an artifact of the broken
frame clock -- see the clock note at the end of this entry.)
Measured 2026-08-06 on the passing 256-colour run with both clocks fixed.
Honest UBER total wall time is **40890 ms (2249 frames)**. Two earlier figures
in this entry's history were wrong and are corrected below: 15945 ms came from a
broken frame clock, and 108309 ms was real but predated the present rewrite.
The "pre-LUT" column is the straightforward per-pixel expansion the graphics-plane
switch first shipped with; "now" is after the word-store expansion tables.
| op | pre-LUT | now | IIgs | now / IIgs |
|---|---|---|---|---|
| `jlSurfaceClear` | 30 | 106 | 33 | 3.21x |
| `jlPaletteSet` | 16175 | 58516 | 1561 | 37.49x |
| `jlScbSetRange` | 5946 | 21206 | 163 | 130.10x |
| `jlDrawPixel` | 10225 | 36220 | 5576 | 6.50x |
| `jlDrawLine H` | 2632 | 9370 | 1322 | 7.09x |
| `jlFillRect 16x16` | 687 | 2414 | 785 | 3.08x |
| `jlFillRect 80x80` | 84 | 306 | 133 | 2.30x |
| `jlFillRect 320x200` | 29 | 103 | 22 | 4.68x |
| `jlDrawCircle r=16` | 165 | 591 | 303 | 1.95x |
| `jlDrawCircle r=80` | 35 | 125 | 70 | 1.79x |
| `jlSpriteDraw` | 2059 | 7397 | 1142 | 6.48x |
| `jlSpriteSaveUnder` | 3570 | 12803 | 602 | 21.27x |
| `jlTileMapPaste 10x5` | 295 | 1055 | 140 | 7.54x |
| `jlStagePresent full` | 392 | 1373 | 73 | 18.81x |
| `gameFrame composite` | 2 | 12 | 12 | 1.00x |
| `gameFrameClean composite` | 2 | 137 | 20 | 6.85x |
**All 39 shared ops now meet or exceed the Apple IIgs reference; median 7.54x,
slower on none.** The two composite rows are the ones that mattered -- they are
the closest thing UBER has to a real game frame, and they were the reason the
port missed the "within ~1.5x of the native ceiling" directive at 0.17x and
0.10x. `gameFrame composite` is now exactly at parity and `gameFrameClean` is
6.85x.
The fix was NOT a port of DOS's gExpandLut. DOS fuses two ADJACENT output bytes
into one 16-bit store (mode 13h pixels are neighbouring bytes); here each pixel
is a 16-bit WORD, so its bytes are two apart and that fusion is impossible.
Inverting it works: write whole words, and one 32-bit store covers exactly two
pixels. The high byte is free because the 256-colour index reads only
`$000F | $00F0` (x68k_v.cpp:339-340). Expansion tables indexed by plane and
nibble are pre-interleaved into word positions, so four planes OR together with a
replicated palBase and the inner loop issues four long stores per eight pixels
with no shifting at all -- against roughly 64 operations and eight byte-stores
before. Verified three ways, because each layer misses what the others catch:
51/51 golden (surface correct), a GVRAM dump under PATTERN.X byte-identical to
the pre-rewrite run (pixel encoding correct), and an earlier framebuffer capture
(the display actually renders the bands).
**Two clock bugs had to be fixed before any of these numbers meant anything, and
every X68000 figure published before 2026-08-06 was wrong.** The port had no
`JL_HAS_MILLIS_ELAPSED` override, so milliseconds derived from the frame counter;
and that counter was POLLED (`vdispPoll`), so an op spanning more than a frame
lost the V-DISP edges in between. The undercount scaled with the op's cost and
cancelled it, making every op report ~10,000 ops/sec regardless of workload --
`jlFillRect 320x200` (9735) matched `jlFillRect 16x16` (10505) despite 250x the
work. The fix chains Human68k's MFP Timer C, reading its rate from TCDCR/TCDR
rather than assuming it, and derives the frame counter from that tick.
`UBER-CLK` now agrees frame-vs-millisecond on every row, which it could not
before because both figures came from the one broken source.
**2026-07-06 update:** the IIgs sprite column is now COMPILED. Finding **2026-07-06 update:** the IIgs sprite column is now COMPILED. Finding
#77 (the loader/segment-BSS + malloc-heap reservation that forced the #77 (the loader/segment-BSS + malloc-heap reservation that forced the
codegen arena into the app's own BSS) was fixed toolchain-side and codegen arena into the app's own BSS) was fixed toolchain-side and
@ -285,10 +358,11 @@ glue is Phase 2 Step D, still open). The present-row jumps (ST 467,
DOS 1,137) are the dirty-union idle early-out running flag-gated; DOS 1,137) are the dirty-union idle early-out running flag-gated;
gameFrame is the honest present signal. gameFrame is the honest present signal.
Correctness statement backing this table: all four ports produce Correctness statement backing this table: all FIVE ports produce
BYTE-IDENTICAL pixels on every measured op and every correctness BYTE-IDENTICAL pixels on every measured op and every correctness
check -- **49/49** surface hashes match the frozen goldens in check -- **51/51** surface hashes match the frozen goldens in
tests/goldens/uber/ (37 timed ops + 12 hashed checks; the PASS/FAIL tests/goldens/uber/ (39 timed ops + 12 hashed checks; the X68000 has no
golden of its own and is diffed straight against iigs.txt; the PASS/FAIL
checks -- palette round-trip, PRNG golden sequence, sprite clip checks -- palette round-trip, PRNG golden sequence, sprite clip
round-trip, arena churn, sprite-from-surface -- pass everywhere). round-trip, arena churn, sprite-from-surface -- pass everywhere).
The Phase 0-era divergences (#75 tilePasteMono three-way, #78 ST The Phase 0-era divergences (#75 tilePasteMono three-way, #78 ST
@ -353,10 +427,137 @@ so the per-window Own/Disown it removes was already negligible -- kept
for the right blitter discipline, not a speedup. Session present3win arc for the right blitter discipline, not a speedup. Session present3win arc
106 -> 128 -> 153 -> 154 = +45%. No known remaining flush lever. 106 -> 128 -> 153 -> 154 = +45%. No known remaining flush lever.
**2026-08-05, FIFTH PORT: Sharp X68000 -- the hashes PASS, the clock
does NOT.** src/x68000 + make/x68000.mk (JOEYLIB_PLATFORM_X68000) is on
the golden gate: tools/diff-uber-hashes reports OK: 51/51 ops match. The
port carries NO frozen golden of its own -- it is diffed straight
against tests/goldens/uber/iigs.txt, which it matches exactly. Shape:
10 MHz 68000, NATIVE PLANAR storage (4 x 1bpp planes, 40 bytes/row,
X68K_PLANE_SIZE 8,000), byte-for-byte the Amiga surface layout -- but
the DISPLAY is not the surface. It draws into the TEXT plane (four 1bpp
planes at $E00000/$E20000/$E40000/$E60000, 128-byte stride), NOT
graphics VRAM, so jlpPresent scatters the compact 40-byte rows into that
stride: 32,000 bytes/frame against 128,000 bytes of GVRAM word writes on
the chunky path it replaced (src/x68000/x68kPlanar.h:20-33). The 320x200
window is CENTRED in a 512x512 mode (_iocs_crtmod 12) at x=96 / y=156,
byte-aligned by construction (96/8 = 12). Palette is the 16-entry
PCG/text palette at $E82200 -- NOT the $E82000 graphic palette -- in
GGGGGRRRRRBBBBBI order, green and red SWAPPED versus RGB555 plus ONE
SHARED intensity LSB; and because the text plane has exactly one
16-entry palette, per-scanline SCB palettes cannot be honoured (palette
0 always wins) -- unlike the ST, which really does emulate per-band SCB
palettes from an MFP Timer B ISR, so this port stands alone in ignoring
the SCB on screen. [SUPERSEDED 2026-08-06: the port moved to the
256-colour graphics plane (_iocs_crtmod 13, palette $E82000) precisely to
fix this -- SCB per-band palettes now work, DOS-style. See the
2026-08-06 entry at the top. The rest of this paragraph still describes
the port accurately.] Sprites COMPILE (shared 68k planar emitter, see the
Reading notes). Toolchain is elf2x68k at toolchains/x68000/m68k-xelf
(gcc 13.4.0, ELF -- so no leading-underscore asm symbols, and
src/m68k/*.s does not link there); disks are built by tools/xdftool.py
(Human68k FAT12, 1024-byte sectors, .X executables); the emulator is a
patched MAME 0.264 single-driver build (patches/mame-0.264-x68k-*.patch).
The only recorded rate-card input so far is from those patches: TVRAM
costs ~1.92 extra cycles/access and GVRAM ~1.03 versus main memory,
applied only at a 10 MHz CPU clock (docs/x68000_mame_crtc_timing.md:307-309,
328) -- which is why the 4x traffic cut to the text plane wins despite
TVRAM's higher per-access cost. NO ceilings are derived for this port
anywhere in this file, and none should be until the clock is fixed.
THE COLUMN BELOW IS NOT COMPARABLE TO THE OTHER FOUR. It is recorded raw
because the capture is expensive and the log is not kept in-tree, not
because the numbers mean what they say -- see measurement artifact 5.
Capture route: UBER built with the serial log tee
(EXTRA_CFLAGS=-DJOEY_LOG_SERIAL_TEE, which now reaches every port via
make/common.mk:26; src/core/debug.c calls jlpLogTee, implemented at
src/x68000/hal.c:353) against scripts/x68kSerialPeer.py, which MUST be
run as `python3 -u` or a killed peer loses the whole capture to stdio
buffering. This is the only route that gets a full log off the machine:
the disk route (scripts/verify-x68000-golden.sh) forces -DUBER_FRAMES=1u
and cannot produce timings at all -- and in practice cannot produce the
log either, because joeylog.txt never commits its size to the Human68k
image. Run header as reported: total wall time 15,945 ms, 877 frames
@ 55 Hz (both from the suspect clock).
| Op | X68000 (reported ops/sec) |
| --- | --- |
| jlSurfaceClear | 8775 |
| jlPaletteSet | 14353 |
| jlScbSetRange | 8305 |
| jlDrawPixel | 8580 |
| jlDrawLine H | 8855 |
| jlDrawLine V | 8800 |
| jlDrawLine diag | 8195 |
| jlDrawRect 100x100 | 8470 |
| jlDrawCircle r=16 | 8305 |
| jlDrawCircle r=80 | 7316 |
| jlFillRect 16x16 | 9295 |
| jlFillRect 80x80 | 22182 |
| jlFillRect 320x200 | 6986 |
| jlFillCircle r=40 | 9240 |
| jlSamplePixel | 10395 |
| jlTileFill | 8195 |
| jlTileCopy | 8525 |
| jlTileCopyMasked | 8745 |
| jlTilePaste | 9405 |
| jlTileSnap | 8690 |
| jlSpriteSaveUnder | 8580 |
| jlSpriteDraw | 8250 |
| jlSpriteRestoreUnder | 8886 |
| jlSpriteSaveAndDraw | 8305 |
| jlStagePresent full | 572 |
| jlInputPoll | 8250 |
| jlKeyDown | 53006 |
| jlKeyPressed | 53611 |
| jlMouseX | 125529 |
| joeyJoyConnected | 53006 |
| jlAudioFrameTick | 23756 |
| jlAudioIsPlayingMod | 84472 |
| surfaceMarkDirtyRect (via jlFillRect 32x32) | 8965 |
| jlSpriteDraw unaligned | 8030 |
| jlSpriteDraw sweep16 | 8470 |
| jlTilePaste map 10x5 | 8305 |
| gameFrame composite | 938 |
| jlTileMapPaste 10x5 | 8030 |
| gameFrameClean composite | 1245 |
The tell is in the table itself, no model needed: 26 of the 39 rows land
inside a single 7,316-9,405 band -- a 1.29x spread across ops whose real
work differs by orders of magnitude. `jlDrawPixel` (8,580), a 100x100
rect outline (8,470) and a 16x16 sprite draw (8,250) cannot all cost the
same; `jlFillRect 80x80` (22,182) cannot be 2.4x FASTER than `jlFillRect
16x16` (9,295); `jlSurfaceClear` (8,775) cannot land within 1.2x of
`jlSamplePixel` (10,395). The physics check, in the style of artifact 2:
the stage is 4 x 8,000 = 32,000 bytes, so 8,775 clears/sec is 114
us/clear = ~1,140 cycles at 10 MHz = 28 bytes PER CYCLE. Even at the ST
rate card's write-only fill rate of ~2.2 cyc/B, 32,000 bytes is ~70k
cycles = ~7.0 ms = ~140 ops/sec, so that row reads ~60x high. Same for
the composite: `gameFrame` at 938/s is 1.07 ms = ~10,700 cycles for a
256x96 fill + 16 tile pastes + 3 saveAndDraw + 3 restores + a 96-row
band present, when the fill ALONE is 12,288 B x 2.2 = 27k cycles (the
very figure this file uses for the ST gameFrame ceiling) -- 2.5x the
whole measured frame. Only the cheap rows survive: at ~8 us/call
`jlMouseX` (125,529), `jlKeyDown` (53,006) and `jlAudioIsPlayingMod`
(84,472) poll the clock far more often than once per frame and read
honestly.
For calibration once the clock is fixed, this port sits between the two
68k ports already in the table: a 10 MHz 68000 with NO blitter -- the
ST's situation with 25% more clock -- writing a compact 4-plane surface
in plain malloc'd main RAM (no Chip RAM constraint, nothing but the CPU
ever reads it) and then scattering 32,000 bytes/frame into TVRAM at its
~1.92-cycle access penalty. So CPU-bound rows should be expected ABOVE
the 8.0 MHz ST roughly on clock ratio, while the present should land
between the ST (bus-floor CPU copy, 8 MHz) and the Amiga (7.09 MHz but a
blitter and display-DMA contention). None of that is measured yet, and
none of it can be until artifact 5 is closed.
## Measurement honesty (read before trusting any number) ## Measurement honesty (read before trusting any number)
Four measurement artifacts have shaped this table's history. Three are Five measurement artifacts have shaped this table's history. Three are
fixed, one is inherent -- know them before comparing rows: fixed, one is inherent, and one -- the X68000 frame clock -- is OPEN and
invalidates that port's entire ops/sec column: know them before comparing
rows:
1. **Per-iteration poll tax -- FIXED (Phase 0).** The pre-audit bench 1. **Per-iteration poll tax -- FIXED (Phase 0).** The pre-audit bench
loop called jlFrameCount() between every op; on the IIgs that was a loop called jlFrameCount() between every op; on the IIgs that was a
@ -383,9 +584,12 @@ fixed, one is inherent -- know them before comparing rows:
Timer-C 200 Hz millis clock (refresh-independent), which ran ~16-22% Timer-C 200 Hz millis clock (refresh-independent), which ran ~16-22%
faster; the Amiga's two clocks agreed exactly (FS-UAE = PAL 50 Hz), faster; the Amiga's two clocks agreed exactly (FS-UAE = PAL 50 Hz),
confirming it was ST-only. Two fixes landed: (a) UBER now computes confirming it was ST-only. Two fixes landed: (a) UBER now computes
ops/sec from `jlMillisElapsed` (refresh-independent on all four ops/sec from `jlMillisElapsed` (its own hardware clock on the ST and
ports; identical to the old formula on IIgs where millis is DOS; derived from the frame counter on the IIgs -- identical to the
GetTick-derived), and (b) ST `jlpFrameHz()` now reads the shifter old formula -- and on the Amiga and X68000, neither of which
overrides jlpMillisElapsed; on the X68000 that counter is POLLED, so
both UBER clocks carry the same error and the frame-vs-millis
cross-check cannot see it -- artifact 5), and (b) ST `jlpFrameHz()` now reads the shifter
resolution + sync-mode registers to return the true 50/60/71 Hz. The resolution + sync-mode registers to return the true 50/60/71 Hz. The
per-op `UBER-CLK:` log line reports frame-vs-millis as a standing per-op `UBER-CLK:` log line reports frame-vs-millis as a standing
cross-check (they now agree). ST cells rose ~17% (e.g. jlSpriteDraw cross-check (they now agree). ST cells rose ~17% (e.g. jlSpriteDraw
@ -400,6 +604,31 @@ fixed, one is inherent -- know them before comparing rows:
gets the whole CPU. Amiga/DOS/IIgs audited clean for the same bug gets the whole CPU. Amiga/DOS/IIgs audited clean for the same bug
class (~1.8% / ~0.5% / 0% idle cost). class (~1.8% / ~0.5% / 0% idle cost).
5. **X68000 polled frame clock -- OPEN; that port's whole ops/sec
column is invalid.** _iocs_vdispst HANGS the machine when handed a
plain C handler, so the V-DISP interrupt is never installed
(src/x68000/hal.c:85-92; gVdispInstalled stays false) and
jlpFrameCount instead edge-detects the MFP GPIP V-DISP bit each time
something calls it (hal.c:310-323). A polled edge detector loses
ticks exactly when the work is heavy, and the HAL says so in its own
comment: "Measured: 400 audio refills across ~19 frames reported ZERO
elapsed frames, because nothing polled in between" (hal.c:275-279).
UBER polls once per 16-op batch (uber.c:201-219), so any op whose
batch stretches past a V-DISP period makes the counter under-count,
and ops/sec = iters * 1000 / millis over-reports by that same factor.
The frame-vs-millis cross-check built for artifact 3 cannot catch it:
the X68000 has no jlpMillisElapsed override, so millis comes from
jlpGenericMillisElapsed, which is computed FROM jlpFrameCount
(src/core/port.h:753-758, src/generic/genericPort.c:52-97) -- every
UBER-CLK line on that port compares a number with itself, which is
why they all agree to within 0.5%. Cheap rows (jlMouseX, jlKeyDown,
jlAudioIsPlayingMod) poll often enough to catch every edge and read
honestly; everything else is inflated, ~60x on the full-screen rows.
THE FIX is recorded in the HAL: an asm thunk for _iocs_vdispst (save
d0-d1/a0-a1, call, restore, correct return) so the interrupt owns the
counter (hal.c:87-91, 298-300). Until that lands the X68000 gates on
HASHES ONLY -- it is a correctness column, not a performance column.
## Baseline (2026-07-08): absolute ops/sec per port ## Baseline (2026-07-08): absolute ops/sec per port
The IIgs is the reference and the perf floor (every other port must The IIgs is the reference and the perf floor (every other port must
@ -407,7 +636,10 @@ meet or beat it -- project directive). Non-IIgs cells show absolute
ops/sec plus percentage of the IIgs number; sub-100% cells are bolded ops/sec plus percentage of the IIgs number; sub-100% cells are bolded
as below-floor. `tools/uber-perf-table` emits this table directly; as below-floor. `tools/uber-perf-table` emits this table directly;
regenerate after any re-capture (Amiga needs TIMEOUT=600 -- the regenerate after any re-capture (Amiga needs TIMEOUT=600 -- the
default cuts off the final checks). Capture provenance: all four default cuts off the final checks). The tool still knows only the
original four ports (its PORTS list, tools/uber-perf-table:40-45) and
there is no bench-x68000.sh, so an X68000 column has to be added by
hand from a serial-tee capture -- see the 2026-08-05 entry. Capture provenance: all four
ports 2026-07-08 from a full `make clean` rebuild (Phase 6 W1+W2+W3 ports 2026-07-08 from a full `make clean` rebuild (Phase 6 W1+W2+W3
landed: fused IIgs sprite/fillRect entries, sprite-mark landed: fused IIgs sprite/fillRect entries, sprite-mark
specialization, per-row/fused presents on Amiga/ST/IIgs, ST movep specialization, per-row/fused presents on Amiga/ST/IIgs, ST movep
@ -798,13 +1030,19 @@ the real-HW column is the datasheet claim.
## Reading notes ## Reading notes
* **Sprite rows measure COMPILED code on all four ports** since * **Sprite rows measure COMPILED code on all five ports** since
NATIVE-PERF Phase 1 (2026-07-06): the 68k ports JIT all-shift draw NATIVE-PERF Phase 1 (2026-07-06): the 68k ports JIT all-shift draw
variants plus group-window save/restore (previously stubs that fell variants plus group-window save/restore (previously stubs that fell
to interpreters), IIgs codegen re-enabled via finding #77, DOS via to interpreters), IIgs codegen re-enabled via finding #77, DOS via
finding #74. The interpreted walkers remain only as arena-full finding #74. The X68000 joined on the shared 68k PLANAR emitter with
no emitter changes -- it takes the AMIGA arm of
src/core/spriteDispatch.h (4-plane cdecl routines from
src/m68k/spriteEmitPlanar68k.c, one pre-shifted variant per x % 8
phase, all 8 compiled), because its surface stride is the Amiga's 40
bytes and the display's 128-byte text-plane stride never reaches the
emitter. The interpreted walkers remain only as arena-full
degrade paths -- and are proven bit-identical to the compiled code degrade paths -- and are proven bit-identical to the compiled code
by the 49-hash gate. by the 51-hash gate.
* **The below-floor cells are the standing punch list**, and after the * **The below-floor cells are the standing punch list**, and after the
2026-07-07 audio-tax fix the ST has NONE -- every remaining 2026-07-07 audio-tax fix the ST has NONE -- every remaining
below-floor graphics cell is the Amiga's: the circle family (diag below-floor graphics cell is the Amiga's: the circle family (diag
@ -833,7 +1071,7 @@ the real-HW column is the datasheet claim.
call on the 65816; 32-bit shifts there are per-bit helper loops). call on the 65816; 32-bit shifts there are per-bit helper loops).
Output is bit-identical -- host-proven over the golden sequence and Output is bit-identical -- host-proven over the golden sequence and
a 50M-state sweep, and gated by UBER's random-golden check on all a 50M-state sweep, and gated by UBER's random-golden check on all
four ports. five ports.
* **Input predicates stay functions** (Phase 8 #40 decision): the * **Input predicates stay functions** (Phase 8 #40 decision): the
post-clock-fix numbers (12K-77K ops/sec) put call overhead nowhere post-clock-fix numbers (12K-77K ops/sec) put call overhead nowhere
near a real frame budget; macro forms in the public header were near a real frame budget; macro forms in the public header were

181
README.md
View file

@ -1,12 +1,13 @@
# JoeyLib # JoeyLib
A unified C game-development library targeting four early 16-bit A unified C game-development library targeting five early 16-bit
platforms from a single codebase: platforms from a single codebase:
- Apple IIgs (reference platform) - Apple IIgs (reference platform)
- Commodore Amiga (A500 / 68000 baseline) - Commodore Amiga (A500 / 68000 baseline)
- Atari ST (STF / 68000 baseline) - Atari ST (STF / 68000 baseline)
- MS-DOS (386 / VGA, DJGPP) - MS-DOS (386 / VGA, DJGPP)
- Sharp X68000 (68000 @ 10 MHz, Human68k, elf2x68k)
The Apple IIgs defines the capability ceiling. Stronger platforms coast. The Apple IIgs defines the capability ceiling. Stronger platforms coast.
Hot paths are hand-written assembly per port; the public API is C. Hot paths are hand-written assembly per port; the public API is C.
@ -26,8 +27,12 @@ make
``` ```
This builds `libjoey.a` for every target whose toolchain is installed, This builds `libjoey.a` for every target whose toolchain is installed,
plus the example programs (`hello`, `pattern`, `keys`, `serial`, `save`, plus the example programs (`hello`, `pattern`, `draw`, `keys`, `serial`,
`joy`, `sprite`, `audio`) for each. `sertest`, `save`, `joy`, `sprite`, `audio`, `uber`, `adventure`,
`adventure2`, `agi`, `spacetaxi`) for each. The IIgs is the exception:
`make iigs` compile-checks the library and runs the clang smoke test, and
`make -f make/iigs.mk iigs-examples` builds its example binaries. The
Sharp X68000 is not covered at all -- see below.
## Building for a single target ## Building for a single target
@ -40,6 +45,43 @@ make atarist
make dos make dos
``` ```
The Sharp X68000 has no top-level target: `toolchains/install.sh` does not
fetch elf2x68k and the root `Makefile` has no `x68000` rule. Stage the
elf2x68k tarball under `toolchains/x68000/m68k-xelf` and drive the fragment
directly -- it resolves its own compiler relative to the repo, so this one
does not need `env.sh`:
```
make -f make/x68000.mk # libjoey.a plus SERIAL.X, UBER.X, AUDIO.X
```
Every fragment also answers to a bare `clean`, and every fragment that builds
through `make/common.mk` -- all of them except `make/iigs.mk`, which assembles
its own clang flags -- honors `EXTRA_CFLAGS`:
```
make -f make/atarist.mk clean
make -f make/x68000.mk EXTRA_CFLAGS=-DJOEY_LOG_SERIAL_TEE
```
## Conformance gate
`examples/uber` is the conformance vehicle: it exercises every public op and
logs a surface hash per op. `tools/diff-uber-hashes <reference-log> <test-log>`
compares two `joeylog.txt` captures and exits non-zero on any mismatch. The
frozen references live in `tests/goldens/uber/`. All five ports currently agree
on all 51 hashes; the X68000 has no golden of its own and is diffed against
`tests/goldens/uber/iigs.txt`, which it matches exactly:
```
make -f make/x68000.mk x68000-verify-golden # ~70 min under the patched MAME
make -f make/x68000.mk x68000-verify-serial # both-directions RS-232C, ~1 min
```
Both X68000 gates want `X68K_SCRATCH` pointing at a work dir containing
`x68mame/`, and a MAME built with the patches in `patches/`.
## Repository layout ## Repository layout
@ -47,12 +89,24 @@ make dos
docs/ design and reference documentation docs/ design and reference documentation
include/joey/ public headers include/joey/ public headers
src/core/ portable library code src/core/ portable library code
src/codegen/ runtime sprite codegen (per-CPU emitters) src/generic/ portable-C default for every overridable op
src/port/<plat>/ per-platform HAL implementations src/codegen/ shared sprite-codegen staging / compile machinery
src/shared68k/ assembly shared by Amiga and Atari ST src/<machine>/ per-machine HAL + per-CPU sprite emitter
(iigs, amiga, atarist, dos, x68000)
src/m68k/ 68k code shared across machines (planar and word-
interleaved sprite emitters; surface68k.s, which is
linked by Amiga and Atari ST only)
src/blank/ copy-to-start template for a new port
tools/assetbake/ PNG -> native .tbk / .spr baker (Python) tools/assetbake/ PNG -> native .tbk / .spr baker (Python)
tools/joeymod/ Protracker .MOD converter (passthrough or .NTP) tools/joeymod/ Protracker .MOD converter (passthrough or .NTP)
tools/spritebake/ offline .spr -> .spc pre-compiled sprite baker
tools/songbake/ chip-tracker text notation -> JYM1 stream
tools/xdftool.py Human68k FAT12 .XDF disk image reader / writer
tools/diff-uber-hashes UBER golden-hash comparator
examples/ example programs examples/ example programs
scripts/ run / bench / verification scripts
patches/ MAME patches the X68000 gates need
tests/goldens/uber/ frozen UBER conformance hashes
toolchains/ self-contained cross-build tools toolchains/ self-contained cross-build tools
make/ per-target Makefile fragments make/ per-target Makefile fragments
build/<plat>/ per-target build outputs build/<plat>/ per-target build outputs
@ -77,8 +131,7 @@ is a quick reference. Every entry point is plain C, no C++ extensions.
```c ```c
typedef struct { typedef struct {
uint32_t codegenBytes; // runtime compiled-sprite cache size uint32_t codegenBytes; // runtime compiled-sprite cache size
uint16_t maxSurfaces; // maximum concurrent surfaces uint32_t audioBytes; // reserved; not yet consulted by any engine
uint32_t audioBytes; // audio sample / module RAM pool
} jlConfigT; } jlConfigT;
bool jlInit (const jlConfigT *config); bool jlInit (const jlConfigT *config);
@ -87,9 +140,17 @@ const char *jlLastError (void);
const char *jlPlatformName (void); const char *jlPlatformName (void);
const char *jlVersionString(void); const char *jlVersionString(void);
void *jlAlloc (uint32_t bytes); // native allocator, not malloc
void jlFree (void *p);
void jlWaitVBL (void); // block until next VBL void jlWaitVBL (void); // block until next VBL
uint16_t jlFrameCount (void); // monotonic 16-bit frame counter uint16_t jlFrameCount (void); // monotonic 16-bit frame counter
uint16_t jlFrameHz (void); // 50 / 60 / 70 depending on port uint16_t jlFrameHz (void); // 50 / 55 / 60 / 70 depending on port
uint32_t jlMillisElapsed(void); // monotonic ms since jlInit
uint32_t jlRandom (void); // portable, bit-identical per seed
uint16_t jlRandomRange (uint16_t bound);
void jlRandomSeed (uint32_t seed);
``` ```
@ -98,9 +159,9 @@ uint16_t jlFrameHz (void); // 50 / 60 / 70 depending on port
All surfaces are 320x200 16-color images with a 200-entry SCB table All surfaces are 320x200 16-color images with a 200-entry SCB table
and 16 palettes of 16 `$0RGB` colors. In-memory storage is and 16 palettes of 16 `$0RGB` colors. In-memory storage is
target-native: chunky 4bpp packed on IIgs and DOS, native planar target-native: chunky 4bpp packed on IIgs and DOS, native planar
(separate bitplanes on Amiga, word-interleaved planes on Atari ST) (separate bitplanes on Amiga and Sharp X68000, word-interleaved planes
on the 68k ports. The public API speaks in color indices (0..15) and on Atari ST) on the 68k ports. The public API speaks in color indices
hides the storage format. (0..15) and hides the storage format.
```c ```c
#define SURFACE_WIDTH 320 #define SURFACE_WIDTH 320
@ -159,7 +220,11 @@ void jlFloodFillBounded (jlSurfaceT *s, int16_t x, int16_t y,
Colors are 12-bit `$0RGB`. Color 0 of every palette is forced to Colors are 12-bit `$0RGB`. Color 0 of every palette is forced to
black on `jlPaletteSet`. Each scanline picks one of the 16 palettes black on `jlPaletteSet`. Each scanline picks one of the 16 palettes
via the SCB. via the SCB. Every port honours it. The X68000 does it the same way
the DOS port does: it renders through the 256-colour graphics plane,
so all 16 palettes are resident at once and each pixel byte is
`(scb[y] << 4) | nibble` -- the pixel value selects the band, with no
raster interrupt involved.
```c ```c
void jlPaletteSet (jlSurfaceT *s, uint8_t paletteIndex, const uint16_t *colors16); void jlPaletteSet (jlSurfaceT *s, uint8_t paletteIndex, const uint16_t *colors16);
@ -244,8 +309,18 @@ void jlSpriteDestroy (jlSpriteT *sp);
uint16_t jlSpriteBankLoad(const char *path, jlSpriteT **outCels, uint16_t jlSpriteBankLoad(const char *path, jlSpriteT **outCels,
uint16_t maxCels, uint16_t *outPalette); uint16_t maxCels, uint16_t *outPalette);
// Same contract, but reading a `.spc` bank pre-compiled for THIS target by
// the offline baker `tools/spritebake`: the native blit routines are copied
// straight into the codegen arena, so there is no startup JIT. The runtime
// twin writes one back out after a jlSpriteCompile pass.
uint16_t jlSpriteBankLoadPrecompiled(const char *path, jlSpriteT **outCels,
uint16_t maxCels, uint16_t *outPalette);
bool jlSpriteBankSavePrecompiled(const char *path, jlSpriteT **cels,
uint16_t count, const uint16_t *palette);
bool jlSpriteCompile (jlSpriteT *sp); // build per-shift fast path bool jlSpriteCompile (jlSpriteT *sp); // build per-shift fast path
void jlSpritePrewarm (jlSpriteT *sp); // hint: compile if not already void jlSpritePrewarm (jlSpriteT *sp); // hint: compile if not already
uint32_t jlSpriteCompiledSize (const jlSpriteT *sp);
void jlSpriteDraw (jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y); void jlSpriteDraw (jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y);
void jlSpriteSaveUnder (const jlSurfaceT *s, jlSpriteT *sp, void jlSpriteSaveUnder (const jlSurfaceT *s, jlSpriteT *sp,
@ -254,6 +329,17 @@ void jlSpriteRestoreUnder (jlSurfaceT *s, const jlSpriteBackupT *backup
void jlSpriteSaveAndDraw (jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y, void jlSpriteSaveAndDraw (jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y,
jlSpriteBackupT *backup); jlSpriteBackupT *backup);
// TRUSTED variants: skip the per-call geometry validation for a caller that
// pre-compiles its sprites and guarantees the sprite is FULLY on-surface.
// UB if that is violated -- no clipping happens. They fall back to the
// validated entry for uncompiled sprites and on ports with no compiled path.
void jlSpriteDrawTrusted (jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y);
void jlSpriteSaveUnderTrusted (const jlSurfaceT *s, jlSpriteT *sp,
int16_t x, int16_t y, jlSpriteBackupT *backup);
void jlSpriteRestoreUnderTrusted(jlSurfaceT *s, const jlSpriteBackupT *backup);
void jlSpriteSaveAndDrawTrusted(jlSurfaceT *s, jlSpriteT *sp, int16_t x, int16_t y,
jlSpriteBackupT *backup);
void jlSpriteCompact (void); // defrag the codegen arena void jlSpriteCompact (void); // defrag the codegen arena
uint32_t jlSpriteCodegenBytesUsed (void); uint32_t jlSpriteCodegenBytesUsed (void);
uint32_t jlSpriteCodegenBytesTotal (void); uint32_t jlSpriteCodegenBytesTotal (void);
@ -273,7 +359,11 @@ Two blob formats:
* **`.tbk` (tile bank)** -- one or more 8x8 tiles in per-target * **`.tbk` (tile bank)** -- one or more 8x8 tiles in per-target
planar layout (Amiga plane-major; Atari ST row-major-with-planes- planar layout (Amiga plane-major; Atari ST row-major-with-planes-
per-row; DOS / IIgs chunky 4bpp). The loader `jlTileBankLoad` per-row; DOS / IIgs chunky 4bpp). The loader `jlTileBankLoad`
rejects files baked for the wrong target. rejects files baked for the wrong target. There is no X68000 target:
`assetbake.py` bakes only the four above, and the loader still expects
the DOS chunky target byte there (`src/core/assetLoad.c`) even though
that port's surfaces and tile ops are now planar -- so `.tbk` baking
is not wired up for the X68000 yet.
* **`.spr` (sprite cel set)** -- one or more uniform-sized sprite * **`.spr` (sprite cel set)** -- one or more uniform-sized sprite
cels in cross-target chunky 4bpp. The Phase 11 walker reads chunky cels in cross-target chunky 4bpp. The Phase 11 walker reads chunky
and converts to planar at draw time, so the same blob serves every and converts to planar at draw time, so the same blob serves every
@ -286,10 +376,13 @@ tools/assetbake/assetbake.py --type tile --target {amiga|atarist|dos|iigs} in.
tools/assetbake/assetbake.py --type sprite --cell WxH in.png out.spr tools/assetbake/assetbake.py --type sprite --cell WxH in.png out.spr
``` ```
The Makefiles (`make/{amiga,atarist,dos,iigs}.mk`) wire bake rules The Makefiles (`make/{amiga,atarist,dos}.mk`) wire bake rules
per target so `make <target>` produces baked blobs under per target so `make <target>` produces baked blobs under
`examples/<game>/generated/<target>/` and stages them into the `examples/<game>/generated/<target>/` and stages them into the
runtime tree at `build/<target>/.../DATA/`. runtime tree at `build/<target>/.../DATA/`. `make/iigs.mk` runs no
`assetbake.py`: its `generated/iigs` tree is produced out-of-band and the
fragment only bakes `.spc` from the committed `.spr`. `make/x68000.mk`
has no asset rules at all.
For runtime-extracted content (sprites peeled out of a procedural For runtime-extracted content (sprites peeled out of a procedural
or captured surface), use `jlSpriteCreateFromSurface` -- the or captured surface), use `jlSpriteCreateFromSurface` -- the
@ -351,14 +444,18 @@ void jlJoystickReset (jlJoystickE js, uint8_t deadZone);
### Audio (`joey/audio.h`) ### Audio (`joey/audio.h`)
4-channel Protracker-style music plus four one-shot SFX slots. Module 4-channel Protracker-style music plus five one-shot SFX slots, three
data must be the platform-native form produced by `tools/joeymod` PSG-style tone voices and a noise channel. Module data must be the
(`.mod` for Amiga/DOS/ST; `.ntp` for IIgs; `.amod` if you want platform-native form produced by `tools/joeymod` (`.mod` for
loop=false on Amiga). A failed `jlAudioInit` is non-fatal; the rest Amiga/DOS/ST/X68000; `.ntp` for IIgs; `.amod` if you want loop=false on
of the API stays callable as no-ops. Amiga). Amiga plays `.mod` through PTPlayer and the IIgs through the
NinjaTrackerPlus replayer; DOS, Atari ST and X68000 decode with
libxmp-lite. A failed `jlAudioInit` is non-fatal; the rest of the API
stays callable as no-ops.
```c ```c
#define JOEY_AUDIO_SFX_SLOTS 4 #define JOEY_AUDIO_SFX_SLOTS 5
#define JOEY_AUDIO_VOICES 3
bool jlAudioInit (void); bool jlAudioInit (void);
void jlAudioShutdown (void); void jlAudioShutdown (void);
@ -369,8 +466,24 @@ bool jlAudioIsPlayingMod (void);
void jlAudioPlaySfx (uint8_t slot, const uint8_t *sample, void jlAudioPlaySfx (uint8_t slot, const uint8_t *sample,
uint32_t length, uint16_t rateHz); uint32_t length, uint16_t rateHz);
void jlAudioPlaySfxStream (uint8_t slot, jlAudioStreamFillT fill, void *ctx,
uint16_t rateHz);
void jlAudioStopSfx (uint8_t slot); void jlAudioStopSfx (uint8_t slot);
// Portable chip-tracker music: a JYM1 event stream baked by tools/songbake,
// played through the tone/noise layer at register-write cost on every port.
// No CPU mixing, so it is viable where jlAudioPlayMod is not.
bool jlMusicPlay (const uint8_t *data, uint32_t length, bool loop);
void jlMusicStop (void);
bool jlMusicIsPlaying (void);
void jlMusicPause (void);
void jlMusicResume (void);
void jlMusicSetAtten (uint8_t atten);
void jlAudioTone (uint16_t freqHz);
void jlAudioVoice (uint8_t voice, uint16_t freqHz, uint8_t atten);
void jlAudioNoise (uint8_t pitch, uint8_t atten);
void jlAudioFrameTick (void); void jlAudioFrameTick (void);
``` ```
@ -418,12 +531,12 @@ void jlSerialFlush (void); // discard buffered input
Per-port device mapping: Per-port device mapping:
| Device | IIgs | DOS | Amiga | Atari ST | | Device | IIgs | DOS | Amiga | Atari ST | X68000 |
|---------------------|-------------------------|-----------|-------------------|--------------| |---------------------|-------------------------|-----------|-------------------|--------------|-----------------|
| `JL_SERIAL_DEFAULT` | modem port (SCC A) | COM1 | serial.device 0 | Modem/RS-232 | | `JL_SERIAL_DEFAULT` | modem port (SCC A) | COM1 | serial.device 0 | Modem/RS-232 | RS-232C (SCC A) |
| `JL_SERIAL_MODEM` | modem port (SCC A) | COM1 | serial.device 0 | Modem/RS-232 | | `JL_SERIAL_MODEM` | modem port (SCC A) | COM1 | serial.device 0 | Modem/RS-232 | RS-232C (SCC A) |
| `JL_SERIAL_PRINTER` | printer port (SCC B) | COM1 | serial.device 0 | Modem/RS-232 | | `JL_SERIAL_PRINTER` | printer port (SCC B) | COM1 | serial.device 0 | Modem/RS-232 | RS-232C (SCC A) |
| `JL_SERIAL_SLOT` | 6551 SSC in slot `unit` | COM`unit` | serial.device `unit` | Modem/RS-232 | | `JL_SERIAL_SLOT` | 6551 SSC in slot `unit` | COM`unit` | serial.device `unit` | Modem/RS-232 | RS-232C (SCC A) |
Only the IIgs has multiple built-in ports; elsewhere `MODEM`/`PRINTER` fall Only the IIgs has multiple built-in ports; elsewhere `MODEM`/`PRINTER` fall
back to the natural port. The `serial` example is an echo terminal and back to the natural port. The `serial` example is an echo terminal and
@ -456,7 +569,8 @@ uint32_t jlDiskFree (void); // free bytes on the save volume (0 if unknown)
Directory create / delete / free-space use a per-port HAL (POSIX `mkdir` + Directory create / delete / free-space use a per-port HAL (POSIX `mkdir` +
`remove` + INT 21h on DOS; `mkdir` + GEMDOS `Dfree` on ST; dos.library `remove` + INT 21h on DOS; `mkdir` + GEMDOS `Dfree` on ST; dos.library
`CreateDir`/`DeleteFile`/`Info` on Amiga; GS/OS `Create` + a `CreateDir`/`DeleteFile`/`Info` on Amiga; GS/OS `Create` + a
`GetDevNumber`/`DInfo`/`Volume` chain on the IIgs). `scripts/check-save.sh` `GetDevNumber`/`DInfo`/`Volume` chain on the IIgs; Human68k `_dos_mkdir` +
`remove` + `_dos_dskfre` on the X68000). `scripts/check-save.sh`
round-trips the core against real files; `make iigs-verify-save` boots the round-trips the core against real files; `make iigs-verify-save` boots the
IIgs save path under MAME. Full reference, per-port behavior, and porting IIgs save path under MAME. Full reference, per-port behavior, and porting
notes: [`docs/save.md`](docs/save.md). notes: [`docs/save.md`](docs/save.md).
@ -477,6 +591,15 @@ void jlLogReset(void);
Output goes to `joeylog.txt` in the program's working directory. Output goes to `joeylog.txt` in the program's working directory.
Building with `EXTRA_CFLAGS=-DJOEY_LOG_SERIAL_TEE` additionally mirrors every
`jlLog` / `jlLogF` line out the port's serial line as it is written --
unbounded, needs no disk, and arrives in real time, which is what makes a
wedged run diagnosable. It is OFF by default and is currently implemented
only for the X68000 (`jlpLogTee` in `src/x68000/hal.c`); the host end is
`scripts/x68kSerialPeer.py`, which must be run under `python3 -u` -- it never
passes `flush=True`, so a killed peer loses its whole capture to stdio
buffering.
### Platform macros (`joey/platform.h`) ### Platform macros (`joey/platform.h`)

View file

@ -436,6 +436,10 @@
#define JL_HAS_WAIT_VBL // _iocs_vsync #define JL_HAS_WAIT_VBL // _iocs_vsync
#define JL_HAS_FRAME_COUNT #define JL_HAS_FRAME_COUNT
#define JL_HAS_FRAME_HZ // ~55 Hz in the 31 kHz modes #define JL_HAS_FRAME_HZ // ~55 Hz in the 31 kHz modes
// Chains Human68k's MFP Timer C (~100 Hz). Without this the port fell back to
// the generic clock, which derives ms from the POLLED frame counter and
// undercounts exactly in proportion to how slow the op is.
#define JL_HAS_MILLIS_ELAPSED
// Save add-on: Human68k _dos_dskfre / _dos_mkdir, structurally the ST HAL. // Save add-on: Human68k _dos_dskfre / _dos_mkdir, structurally the ST HAL.
#define JL_HAS_DISK_FREE #define JL_HAS_DISK_FREE
#define JL_HAS_SAVE_DELETE #define JL_HAS_SAVE_DELETE

View file

@ -37,10 +37,10 @@ CFLAGS := $(COMMON_CFLAGS) -m68000 -fomit-frame-pointer \
LDFLAGS := -lm LDFLAGS := -lm
PORT_C_SRCS := $(wildcard $(SRC_DIR)/x68000/*.c) PORT_C_SRCS := $(wildcard $(SRC_DIR)/x68000/*.c)
# Shared 68k planar asm (src/m68k/surface68k.s), already used by Amiga and ST: # Port-local 68000 asm, if any lands. Empty today: SCB per-band palettes are
# circle outline and 4-plane span fill. Free reuse now the surface layout # done in the pixel expansion (DOS-style), not with a raster ISR, so the port is
# matches. # pure C again.
SHARED_S := $(wildcard $(SRC_68K)/*.s) PORT_S_SRCS := $(wildcard $(SRC_DIR)/x68000/*.s)
# No src/m68k sources yet: those are the planar sprite emitters and the planar # No src/m68k sources yet: those are the planar sprite emitters and the planar
# surface helpers, and this port is still chunky. They come in with the storage # surface helpers, and this port is still chunky. They come in with the storage
@ -63,6 +63,7 @@ LIB_OBJS := \
$(patsubst $(SRC_CORE)/%.c,$(BUILD)/obj/core/%.o,$(CORE_C_SRCS)) \ $(patsubst $(SRC_CORE)/%.c,$(BUILD)/obj/core/%.o,$(CORE_C_SRCS)) \
$(patsubst $(SRC_DIR)/generic/%.c,$(BUILD)/obj/generic/%.o,$(GENERIC_C_SRCS)) \ $(patsubst $(SRC_DIR)/generic/%.c,$(BUILD)/obj/generic/%.o,$(GENERIC_C_SRCS)) \
$(patsubst $(SRC_DIR)/x68000/%.c,$(BUILD)/obj/port/%.o,$(PORT_C_SRCS)) \ $(patsubst $(SRC_DIR)/x68000/%.c,$(BUILD)/obj/port/%.o,$(PORT_C_SRCS)) \
$(patsubst $(SRC_DIR)/x68000/%.s,$(BUILD)/obj/port/%.o,$(PORT_S_SRCS)) \
$(BUILD)/obj/68k/spriteEmitPlanar68k.o \ $(BUILD)/obj/68k/spriteEmitPlanar68k.o \
$(BUILD)/obj/codegen/spriteCompile.o \ $(BUILD)/obj/codegen/spriteCompile.o \
$(BUILD)/obj/codegen/spriteStage.o $(BUILD)/obj/codegen/spriteStage.o
@ -89,6 +90,7 @@ $(LIBXMP_AR): $(LIBXMP_OBJS)
LIB := $(LIBDIR)/libjoey.a LIB := $(LIBDIR)/libjoey.a
PATTERN_SRC := $(EXAMPLES)/pattern/pattern.c
SERIAL_SRC := $(EXAMPLES)/serial/serial.c SERIAL_SRC := $(EXAMPLES)/serial/serial.c
UBER_SRC := $(EXAMPLES)/uber/uber.c UBER_SRC := $(EXAMPLES)/uber/uber.c
AUDIO_SRC := $(EXAMPLES)/audio/audio.c AUDIO_SRC := $(EXAMPLES)/audio/audio.c
@ -99,7 +101,7 @@ all x68000: x68000-lib x68000-examples
x68000-lib: $(LIB) $(LIBXMP_AR) x68000-lib: $(LIB) $(LIBXMP_AR)
x68000-examples: $(BINDIR)/SERIAL.X $(BINDIR)/UBER.X $(BINDIR)/AUDIO.X x68000-examples: $(BINDIR)/SERIAL.X $(BINDIR)/UBER.X $(BINDIR)/AUDIO.X $(BINDIR)/PATTERN.X
$(BUILD)/obj/core/%.o: $(SRC_CORE)/%.c $(BUILD)/obj/core/%.o: $(SRC_CORE)/%.c
@mkdir -p $(dir $@) @mkdir -p $(dir $@)
@ -109,6 +111,12 @@ $(BUILD)/obj/generic/%.o: $(SRC_DIR)/generic/%.c
@mkdir -p $(dir $@) @mkdir -p $(dir $@)
$(X68K_CC) $(CFLAGS) -c $< -o $@ $(X68K_CC) $(CFLAGS) -c $< -o $@
# Port-local asm. Same object dir as the port C objects; make picks between the
# two pattern rules by which prerequisite exists.
$(BUILD)/obj/port/%.o: $(SRC_DIR)/x68000/%.s
@mkdir -p $(dir $@)
$(X68K_CC) $(CFLAGS) -c $< -o $@
$(BUILD)/obj/68k/%.o: $(SRC_68K)/%.s $(BUILD)/obj/68k/%.o: $(SRC_68K)/%.s
@mkdir -p $(dir $@) @mkdir -p $(dir $@)
$(X68K_CC) $(CFLAGS) -c $< -o $@ $(X68K_CC) $(CFLAGS) -c $< -o $@
@ -129,6 +137,12 @@ $(LIB): $(LIB_OBJS)
@mkdir -p $(dir $@) @mkdir -p $(dir $@)
$(X68K_AR) rcs $@ $(LIB_OBJS) $(X68K_AR) rcs $@ $(LIB_OBJS)
# PATTERN is the SCB acceptance vehicle: 8 bands of 25 lines with 8 gradient
# palettes, so a mis-placed band boundary is immediately visible.
$(BINDIR)/PATTERN.X: $(PATTERN_SRC) $(LIB) $(LIBXMP_AR)
@mkdir -p $(dir $@)
$(X68K_CC) $(CFLAGS) $(PATTERN_SRC) $(LIB) $(LIBXMP_AR) $(LDFLAGS) -o $@
$(BINDIR)/SERIAL.X: $(SERIAL_SRC) $(LIB) $(LIBXMP_AR) $(BINDIR)/SERIAL.X: $(SERIAL_SRC) $(LIB) $(LIBXMP_AR)
@mkdir -p $(dir $@) @mkdir -p $(dir $@)
$(X68K_CC) $(CFLAGS) $(SERIAL_SRC) $(LIB) $(LIBXMP_AR) $(LDFLAGS) -o $@ $(X68K_CC) $(CFLAGS) $(SERIAL_SRC) $(LIB) $(LIBXMP_AR) $(LDFLAGS) -o $@

View file

@ -20,6 +20,9 @@
#include <x68k/iocs.h> #include <x68k/iocs.h>
#include "port.h" #include "port.h"
#include <stddef.h>
#include "joey/debug.h"
#include <string.h> #include <string.h>
#include "surfaceInternal.h" #include "surfaceInternal.h"
@ -34,10 +37,11 @@
// width, so the row stride is a constant, not a function of SURFACE_WIDTH. // width, so the row stride is a constant, not a function of SURFACE_WIDTH.
#define X68K_GVRAM_STRIDE 512u #define X68K_GVRAM_STRIDE 512u
// _iocs_crtmod screen mode. 12 = 512x512, 16 colours, 31 kHz. The library's // _iocs_crtmod screen mode. 13 = 512x512, 256 colours, 31 kHz -- same timing as
// 320x200 stage is drawn into the top-left corner; there is no 320x200 mode on // mode 12 (512x512/16) one slot below it, but with the 256-entry graphics
// this machine, so some form of letterboxing is unavoidable. // palette this port needs for SCB per-band palettes. There is no 320x200 mode on
#define X68K_CRTMOD_512_16 12 // this machine, so the stage is centred and letterboxed.
#define X68K_CRTMOD_512_256 13
// _iocs_bitsns key-group numbers that carry the keys jlKeyE cares about. // _iocs_bitsns key-group numbers that carry the keys jlKeyE cares about.
#define X68K_KEYGROUP_COUNT 15 #define X68K_KEYGROUP_COUNT 15
@ -49,6 +53,57 @@
#define X68K_TEE_SPIN_LIMIT 200000ul #define X68K_TEE_SPIN_LIMIT 200000ul
// ----- SCB per-band palette (256-colour graphics plane) ----------------------
//
// JoeyLib surfaces carry an SCB: one palette index per scanline. This port does
// it the way the DOS port does (src/dos/hal.c:7), not the way the Atari ST does.
// DOS writes `pixel byte = (scb[y] << 4) | nibble` into a 256-entry DAC, so all
// 16 palettes are resident at once and the PIXEL VALUE selects the band. No
// interrupt, no timing window, correct by construction.
//
// The ST needs a raster interrupt only because the STF shifter really has 16
// hardware colours. This machine does not: the graphics plane in 256-colour mode
// has a 256-entry palette at $E82000 -- exactly 16 palettes x 16 colours. The
// TEXT plane cannot do it (four 1bpp planes cap it at 16, and MAME's
// get_text_pixel resolves 0-15 only), which is why this port moved off it.
//
// GVRAM layout in 256-colour mode, from MAME x68k_v.cpp:339-340:
// colour = gvram[lineoffset0 + x] & 0x000F; // low nibble
// colour |= gvram[lineoffset1 + x] & 0x00F0; // high nibble
// With both graphics scroll registers 0 the two line offsets are equal, so both
// nibbles come from the SAME word: the palette index is that word's LOW BYTE.
// One byte write per pixel, 512 words per line.
#define X68K_GVRAM_BASE 0x00C00000UL
#define X68K_GVRAM_WORDS_ROW 512u
// 256-entry graphics palette (x68k.cpp:965). NOT $E82200, the 16-entry text/PCG
// block the old text-plane path used.
#define X68K_GFX_PALETTE ((volatile uint16_t *)0xE82000L)
#define X68K_PALETTE_ENTRIES (SURFACE_PALETTE_COUNT * SURFACE_COLORS_PER_PALETTE)
// ----- Millisecond clock -----------------------------------------------------
//
// Without this the port fell back to jlpGenericMillisElapsed(), which derives
// milliseconds from the FRAME COUNTER -- and this port's counter is polled
// (vdispPoll), not interrupt-driven. An op spanning more than one frame lets
// V-DISP edges pass unobserved, so the measured time shrank in proportion to how
// slow the op was and the two errors cancelled: every UBER row landed near
// 10,000 ops/sec regardless of workload. Games pacing on jlMillisElapsed were
// equally wrong.
//
// Fix: chain Human68k's existing MFP Timer C tick (~100 Hz) and count it. MFP
// channel 5, so vector ($40 | 5) = $45 at address $45 * 4 = $114.
#define X68K_TICK_VEC_ADDR 0x00000114ul
// Timer C's rate is NOT assumed -- it is derived at init from the MFP's own
// registers, because Human68k's programming is not what you would guess. A
// measured run showed ~178 Hz, not the 100 Hz a first guess suggested:
// 4 MHz / (prescale 200 * TCDR 112) = 178.6 Hz.
#define X68K_MFP_TCDCR ((volatile uint8_t *)0xE8801DL) // reg 14
#define X68K_MFP_TCDR ((volatile uint8_t *)0xE88023L) // reg 17
#define X68K_MFP_CLOCK_HZ 4000000ul
#define X68K_TCDCR_C_SHIFT 4u
#define X68K_TCDCR_C_MASK 0x07u
// ----- Module state --------------------------------------------------------- // ----- Module state ---------------------------------------------------------
static int gPrevCrtMode = -1; static int gPrevCrtMode = -1;
@ -62,10 +117,30 @@ static uint8_t gLastVdisp = 0;
// True once the V-DISP interrupt handler is live; the poll then stands down. // True once the V-DISP interrupt handler is live; the poll then stands down.
static bool gVdispInstalled = false; static bool gVdispInstalled = false;
// Bumped by x68kTickIsr.s on every Human68k Timer C interrupt. Written from
// interrupt context, so volatile; read with a single long load, which is atomic
// against the handler's addq.l on a 68000.
volatile uint32_t gX68kTicks = 0ul;
extern void x68kTickIsr(void);
extern uint32_t x68kTickChainAddr; // the JMP operand inside the ISR
static uint32_t gTickBase = 0ul;
static uint32_t gTickHz = 0ul;
static bool gTickBaseSet = false;
static bool gTickHooked = false;
// ----- Prototypes -----
static uint32_t tickHzFromMfp(void);
uint32_t jlpMillisElapsed(void);
uint16_t jlpFrameHz(void);
static void buildSpreadTables(void);
static void installTickIsr(void);
static void uploadGfxPalette(const jlSurfaceT *src);
// Defined below with the timing code; declared here because jlpInit installs it.
static void vdispHandler(void); static void vdispHandler(void);
static void vdispPoll(void); static void vdispPoll(void);
extern void *const gX68kVdispHandlerRef; extern void *const gX68kVdispHandlerRef;
@ -80,16 +155,19 @@ bool jlpInit(const jlConfigT *config) {
// if we were already supervisor -- in which case do not switch back. // if we were already supervisor -- in which case do not switch back.
gPrevSsp = _dos_super(0); gPrevSsp = _dos_super(0);
gPrevCrtMode = _iocs_crtmod(-1); // -1 queries without changing gPrevCrtMode = _iocs_crtmod(-1); // -1 queries without changing
_iocs_crtmod(X68K_CRTMOD_512_16); _iocs_crtmod(X68K_CRTMOD_512_256);
_iocs_g_clr_on(); // clear graphics + enable the plane _iocs_g_clr_on(); // clear graphics + enable the plane
// NOTE: installing vdispHandler via _iocs_vdispst HANGS the machine -- // NOTE: installing vdispHandler via _iocs_vdispst HANGS the machine --
// tested, no serial output at all, so it wedges before main() gets going. // tested, no serial output at all, so it wedges before main() gets going.
// The handler ABI is not a plain C function: a _VDISPST handler is entered // IOCS does NOT wrap the handler: _VDISPST writes the pointer straight into
// from the interrupt and this one returns with RTS from a context IOCS does // exception vector $134 (iplrom.dat $FF9DC8), exactly as _CRTCRAS does for
// not expect. Getting it right needs an asm thunk (save d0-d1/a0-a1, call, // $138. So the handler must be a raw interrupt routine -- preserve every
// restore, correct return), which is worth doing but is not a one-liner. // register, return with RTE -- and a C function (RTS, free to clobber
// d0/d1/a0/a1) can never be valid. A raw asm handler would be required;
// the polled fallback below owns the frame counter until then.
// Until then the polled fallback below owns the counter. // Until then the polled fallback below owns the counter.
gVdispInstalled = false; gVdispInstalled = false;
installTickIsr();
gModeSet = true; gModeSet = true;
return true; return true;
} }
@ -110,6 +188,120 @@ void jlpShutdown(void) {
} }
// Timer C's interrupt rate, read from the MFP rather than assumed:
// MFP clock / (prescaler * data register). The prescaler is a 3-bit code in the
// high nibble of TCDCR; code 0 means the timer is stopped.
static uint32_t tickHzFromMfp(void) {
static const uint16_t kPrescale[8] = { 0u, 4u, 10u, 16u, 50u, 64u, 100u, 200u };
uint16_t code;
uint16_t divisor;
uint16_t data;
code = (uint16_t)((*X68K_MFP_TCDCR >> X68K_TCDCR_C_SHIFT) & X68K_TCDCR_C_MASK);
divisor = kPrescale[code];
if (divisor == 0u) {
return 0ul;
}
data = *X68K_MFP_TCDR;
if (data == 0u) {
data = 256u; // 0 in the data register means 256
}
return X68K_MFP_CLOCK_HZ / ((uint32_t)divisor * (uint32_t)data);
}
// Chain our counter onto Human68k's Timer C interrupt. The previous handler is
// patched into the ISR's JMP operand, so it still runs and still owns the RTE --
// the OS clock keeps working. Supervisor already (jlpInit's _dos_super), so the
// vector write is legal. Refuses if the vector looks unset, because chaining to
// nothing would fault on the first tick.
static void installTickIsr(void) {
// Laundered through a volatile: gcc treats a constant pointer this close to
// zero as a null dereference and -Werror=array-bounds rejects it, though
// $114 is a perfectly real supervisor-writable exception vector.
static volatile uintptr_t addr = X68K_TICK_VEC_ADDR;
volatile uint32_t *vec;
vec = (volatile uint32_t *)addr;
if (*vec == 0ul) {
return;
}
gTickHz = tickHzFromMfp();
if (gTickHz == 0ul) {
return; // timer stopped: no usable clock
}
x68kTickChainAddr = *vec;
*vec = (uint32_t)x68kTickIsr;
gTickHooked = true;
}
// ----- SCB per-band palette --------------------------------------------------
// Load all 16 palettes into the 256-entry graphics palette: entry p*16+c is
// palette p colour c, so a pixel byte of (p << 4) | c selects it directly.
// Rebuilt only when a palette actually changes; the SCB flag is deliberately
// NOT consumed here, because the per-line choice is applied in the pixel
// expansion, which reads src->scb directly. Same contract as
// src/dos/hal.c:232-234.
static void uploadGfxPalette(const jlSurfaceT *src) {
uint16_t pal;
uint16_t col;
for (pal = 0u; pal < SURFACE_PALETTE_COUNT; pal++) {
for (col = 0u; col < SURFACE_COLORS_PER_PALETTE; col++) {
X68K_GFX_PALETTE[(pal << 4) | col] = x68kColorFromRgb12(src->palette[pal][col]);
}
}
}
// ----- Present expansion tables ----------------------------------------------
//
// The naive loop extracted one nibble per pixel with four shifts and a mask,
// then stored one byte: about 8 operations per pixel over 64,000 pixels, which
// put the composite game frame at 0.10x of the IIgs.
//
// DOS's gExpandLut cannot be copied directly: mode 13h pixels are ADJACENT
// bytes so DOS fuses two into one 16-bit store, whereas GVRAM here is one
// 16-bit WORD per pixel (index in the low byte), so the output bytes are two
// apart. The fusion that works on this machine is the other way up -- write
// WHOLE WORDS, because one 32-bit store then covers exactly two pixels. The
// high byte belongs to graphics pages 2/3, which the 256-colour index does not
// read (x68k_v.cpp:339-340 masks $000F | $00F0), so writing 0 there is safe.
//
// So the tables are indexed by a PLANE and a NIBBLE (4 source pixels) and are
// pre-interleaved into word positions: gSpreadHi holds the first two of those
// pixels as (px0 << 16) | px1, gSpreadLo the second two. Four planes OR
// together, palBase ORs in replicated, and the result is two long stores per
// four pixels -- no shifting in the inner loop at all.
#define X68K_SPREAD_NIBBLES 16u
static uint32_t gSpreadHi[X68K_BITPLANES][X68K_SPREAD_NIBBLES];
static uint32_t gSpreadLo[X68K_BITPLANES][X68K_SPREAD_NIBBLES];
static bool gSpreadBuilt = false;
// Bit 3 of the nibble is the LEFTMOST of its four pixels, matching the plane
// byte convention (bit 7 leftmost of eight).
static void buildSpreadTables(void) {
uint16_t plane;
uint16_t nib;
uint32_t bit;
for (plane = 0u; plane < X68K_BITPLANES; plane++) {
bit = (uint32_t)1ul << plane;
for (nib = 0u; nib < X68K_SPREAD_NIBBLES; nib++) {
gSpreadHi[plane][nib] = (((nib >> 3) & 1u) ? (bit << 16) : 0ul)
| (((nib >> 2) & 1u) ? bit : 0ul);
gSpreadLo[plane][nib] = (((nib >> 1) & 1u) ? (bit << 16) : 0ul)
| (((nib >> 0) & 1u) ? bit : 0ul);
}
}
gSpreadBuilt = true;
}
// ----- Present -------------------------------------------------------------- // ----- Present --------------------------------------------------------------
// Copy the surface's four planes into the TEXT PLANE at $E00000. // Copy the surface's four planes into the TEXT PLANE at $E00000.
@ -123,27 +315,34 @@ void jlpShutdown(void) {
// //
// Dirty rows only, and only the marked band within a row. // Dirty rows only, and only the marked band within a row.
void jlpPresent(const jlSurfaceT *src) { void jlpPresent(const jlSurfaceT *src) {
X68kPlanarT *pd; X68kPlanarT *pd;
uint16_t y; volatile uint32_t *wordDst;
uint16_t plane; const uint8_t *p0;
uint16_t firstByte; const uint8_t *p1;
uint16_t rowBytes; const uint8_t *p2;
const uint8_t *s; const uint8_t *p3;
uint8_t *d; uint32_t rowBase;
uint16_t y;
uint16_t firstByte;
uint16_t rowBytes;
uint32_t pal2;
uint16_t b;
uint16_t hi0, hi1, hi2, hi3;
uint16_t lo0, lo1, lo2, lo3;
uint8_t palBase;
if (src == NULL) { if (src == NULL) {
return; return;
} }
vdispPoll(); // keep the frame counter honest: see the note on vdispPoll vdispPoll(); // keep the frame counter honest: see the note on vdispPoll
// Push the palette when it has changed. Without this NOTHING ever uploads // Reload the 256-entry graphics palette when a palette changed. The SCB
// colours and the display keeps whatever Human68k left in the text palette. // flag is NOT consumed: the per-line palette choice is applied below in the
// The text plane has ONE 16-entry palette, so SCB per-scanline palette // pixel expansion, which reads src->scb directly (as DOS does).
// selection cannot be honoured -- palette 0 is used, matching the Atari ST. if (!gSpreadBuilt) {
buildSpreadTables();
}
if (gStagePaletteDirty) { if (gStagePaletteDirty) {
uint16_t c; uploadGfxPalette(src);
for (c = 0u; c < 16u; c++) {
X68K_TEXT_PALETTE[c] = x68kColorFromRgb12(src->palette[0][c]);
}
gStagePaletteDirty = false; gStagePaletteDirty = false;
} }
pd = x68kSurfacePlanar(src); pd = x68kSurfacePlanar(src);
@ -160,14 +359,39 @@ void jlpPresent(const jlSurfaceT *src) {
if ((uint16_t)(firstByte + rowBytes) > X68K_BYTES_PER_ROW) { if ((uint16_t)(firstByte + rowBytes) > X68K_BYTES_PER_ROW) {
rowBytes = (uint16_t)(X68K_BYTES_PER_ROW - firstByte); rowBytes = (uint16_t)(X68K_BYTES_PER_ROW - firstByte);
} }
for (plane = 0u; plane < X68K_BITPLANES; plane++) { // This row's band selects the high nibble of every pixel byte -- the
s = pd->planes[plane] + ((uint32_t)y * X68K_BYTES_PER_ROW) + firstByte; // whole SCB mechanism, exactly src/dos/hal.c:83.
// Centred: shift down X68K_ORIGIN_Y rows and right X68K_ORIGIN_BYTE palBase = (uint8_t)(src->scb[y] << 4);
// bytes. X is byte-aligned (96/8 = 12) so no bit shifting is needed. p0 = pd->planes[0] + ((uint32_t)y * X68K_BYTES_PER_ROW) + firstByte;
d = (uint8_t *)(X68K_TVRAM_PLANE0 + ((uint32_t)plane * X68K_TVRAM_PLANE_STEP) p1 = pd->planes[1] + ((uint32_t)y * X68K_BYTES_PER_ROW) + firstByte;
+ x68kTvramOffset(0u, (uint16_t)(y + X68K_ORIGIN_Y)) p2 = pd->planes[2] + ((uint32_t)y * X68K_BYTES_PER_ROW) + firstByte;
+ X68K_ORIGIN_BYTE + firstByte); p3 = pd->planes[3] + ((uint32_t)y * X68K_BYTES_PER_ROW) + firstByte;
memcpy(d, s, rowBytes); // Centred in the 512x512 screen. One 16-bit GVRAM word per pixel, and
// the palette index is that word's LOW byte -- odd address on this
// big-endian bus.
rowBase = X68K_GVRAM_BASE
+ (((uint32_t)(y + X68K_ORIGIN_Y) * X68K_GVRAM_WORDS_ROW)
+ X68K_ORIGIN_X + ((uint32_t)firstByte * 8u)) * 2u;
wordDst = (volatile uint32_t *)rowBase;
// Two long stores per four pixels; each long is two 16-bit GVRAM words.
pal2 = (uint32_t)(((uint32_t)palBase << 16) | (uint32_t)palBase);
for (b = 0u; b < rowBytes; b++) {
hi0 = (uint16_t)(p0[b] >> 4);
hi1 = (uint16_t)(p1[b] >> 4);
hi2 = (uint16_t)(p2[b] >> 4);
hi3 = (uint16_t)(p3[b] >> 4);
lo0 = (uint16_t)(p0[b] & 0x0Fu);
lo1 = (uint16_t)(p1[b] & 0x0Fu);
lo2 = (uint16_t)(p2[b] & 0x0Fu);
lo3 = (uint16_t)(p3[b] & 0x0Fu);
*wordDst++ = gSpreadHi[0][hi0] | gSpreadHi[1][hi1]
| gSpreadHi[2][hi2] | gSpreadHi[3][hi3] | pal2;
*wordDst++ = gSpreadLo[0][hi0] | gSpreadLo[1][hi1]
| gSpreadLo[2][hi2] | gSpreadLo[3][hi3] | pal2;
*wordDst++ = gSpreadHi[0][lo0] | gSpreadHi[1][lo1]
| gSpreadHi[2][lo2] | gSpreadHi[3][lo3] | pal2;
*wordDst++ = gSpreadLo[0][lo0] | gSpreadLo[1][lo1]
| gSpreadLo[2][lo2] | gSpreadLo[3][lo3] | pal2;
} }
} }
} }
@ -339,9 +563,24 @@ void jlpWaitVBL(void) {
} }
// Frames since init, derived from the honest millisecond tick rather than from
// counting V-DISP edges.
//
// The edge counter (vdispPoll) can only see a transition while the CPU happens
// to be inside a JoeyLib call, so any op that spans more than one frame loses
// the edges in between. That undercount is what made UBER's "16 frame" windows
// run for 88 seconds and what made every op report the same throughput. The
// tick is interrupt-driven and cannot miss, so time -> frames is monotonic and
// correct regardless of what the caller is doing.
//
// vdispPoll still runs: jlpWaitVBL needs the real V-DISP edge to synchronise
// to, and gFrameCount remains the fallback when the tick could not be hooked.
uint16_t jlpFrameCount(void) { uint16_t jlpFrameCount(void) {
vdispPoll(); vdispPoll();
return gFrameCount; if (!gTickHooked) {
return gFrameCount;
}
return (uint16_t)((jlpMillisElapsed() * (uint32_t)jlpFrameHz()) / 1000ul);
} }
@ -371,6 +610,29 @@ void jlpLogTee(const char *text) {
#endif #endif
// Milliseconds since the first call. Latches its base explicitly rather than
// treating 0 as a sentinel, because 0 is a legal tick value at power-on and at
// every wrap. Falls back to the frame-derived generic clock if the tick could
// not be hooked, which is wrong in the ways described above but better than
// returning a constant.
uint32_t jlpMillisElapsed(void) {
uint32_t ticks;
if (!gTickHooked) {
return jlpGenericMillisElapsed();
}
ticks = gX68kTicks;
if (!gTickBaseSet) {
gTickBase = ticks;
gTickBaseSet = true;
}
// Scale by the measured rate. Multiply first (ticks fit comfortably in 32
// bits for any realistic session) so the divide does not quantize away the
// sub-tick remainder.
return (uint32_t)(((ticks - gTickBase) * 1000ul) / gTickHz);
}
uint16_t jlpFrameHz(void) { uint16_t jlpFrameHz(void) {
// 31 kHz modes on this machine run at ~55.5 Hz rather than 60. Reported // 31 kHz modes on this machine run at ~55.5 Hz rather than 60. Reported
// only -- the generic millisElapsed divides by it. // only -- the generic millisElapsed divides by it.

30
src/x68000/x68kTickIsr.s Normal file
View file

@ -0,0 +1,30 @@
| Sharp X68000 millisecond tick.
|
| Human68k already runs MFP Timer C as its ~100 Hz system tick, so rather than
| program a timer of our own we CHAIN its interrupt: bump a counter, then jump
| to whatever handler was there before. That keeps the OS clock working and
| costs one add per tick.
|
| Entered DIRECTLY from 68000 exception vector $45 (MFP channel 5 = Timer C,
| vector base $40 | 5), so this is a raw interrupt handler -- it must not
| disturb any register and must not return with RTS. It does neither: addq.l on
| an absolute address touches no register, and control leaves through a JMP to
| the previous handler, which owns the RTE.
|
| The JMP's operand is patched at install time by installTickIsr() in hal.c via
| the x68kTickChainAddr symbol below. Patching the operand rather than doing an
| indirect jump through a pointer is what keeps this register-free -- a 68000
| has no jmp ([abs]) addressing mode.
|
| GAS m68k syntax, ELF target -- symbols carry NO leading underscore.
.text
.even
.globl x68kTickIsr
.globl x68kTickChainAddr
x68kTickIsr:
addq.l #1,gX68kTicks
.word 0x4EF9 | jmp <abs.l>
x68kTickChainAddr:
.long 0 | patched with the previous handler